diff --git a/tui/tui.go b/tui/tui.go index bfa056f..6f54360 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -540,6 +540,23 @@ func previewLines(lines []string, n int) []string { return out } +// formatElapsed renders a running task's elapsed time, at a precision that +// suits its scale: mm:ss under an hour, h:mm:ss under a day, and kubectl's +// compact d+h notation beyond that, where seconds are just noise. +// +// The longest output is 8 characters ("23:59:59", or "1000d23h"), which is +// what sizes the card's elapsed column. +func formatElapsed(d time.Duration) string { + d = d.Round(time.Second) + if days := int(d.Hours()) / 24; days > 0 { + return fmt.Sprintf("%dd%dh", days, int(d.Hours())%24) + } + if h := int(d.Hours()); h > 0 { + return fmt.Sprintf("%d:%02d:%02d", h, int(d.Minutes())%60, int(d.Seconds())%60) + } + return fmt.Sprintf("%02d:%02d", int(d.Minutes()), int(d.Seconds())%60) +} + // renderCard renders a task as a boxed card. // Non-selected: 3 lines (top border + content + bottom border). // Selected: 3 + len(preview) lines. @@ -549,8 +566,7 @@ func renderCard(r *agentfleet.Runner, selected bool, w int, preview []string, fr badge := statusBadge(r.Status(), frameCount) elapsed := "" if r.Status() == agentfleet.StatusRunning { - d := time.Since(r.StartedAt()).Round(time.Second) - elapsed = fmt.Sprintf("%02d:%02d", int(d.Minutes()), int(d.Seconds())%60) + elapsed = formatElapsed(time.Since(r.StartedAt())) } task := r.Task() @@ -562,7 +578,8 @@ func renderCard(r *agentfleet.Runner, selected bool, w int, preview []string, fr } idStr := idStyle.Render(shortID(task.ID())) - elapsedStr := styleMeta.Width(5).Render(elapsed) + // Width fits "25:01:02"; shorter values are padded to keep the column aligned. + elapsedStr := styleMeta.Width(8).Render(elapsed) rightStr := idStr + " " + elapsedStr leftPrefix := cursor + badge + " " diff --git a/tui/tui_test.go b/tui/tui_test.go new file mode 100644 index 0000000..fb8dc91 --- /dev/null +++ b/tui/tui_test.go @@ -0,0 +1,53 @@ +package tui + +import ( + "testing" + "time" +) + +func TestFormatElapsed(t *testing.T) { + tests := []struct { + name string + in time.Duration + want string + }{ + {"zero", 0, "00:00"}, + {"seconds", 5 * time.Second, "00:05"}, + {"sub-second rounds", 5*time.Second + 400*time.Millisecond, "00:05"}, + {"minutes", 90 * time.Second, "01:30"}, + {"just under an hour", 59*time.Minute + 59*time.Second, "59:59"}, + {"exactly one hour", time.Hour, "1:00:00"}, + {"hours minutes seconds", time.Hour + 15*time.Minute + 30*time.Second, "1:15:30"}, + {"just under a day", 23*time.Hour + 59*time.Minute + 59*time.Second, "23:59:59"}, + {"exactly one day", 24 * time.Hour, "1d0h"}, + {"day drops seconds", 25*time.Hour + time.Minute + 2*time.Second, "1d1h"}, + {"multi-day", 2*24*time.Hour + 3*time.Hour + 15*time.Minute, "2d3h"}, + {"two weeks", 14 * 24 * time.Hour, "14d0h"}, + {"a year", 365*24*time.Hour + 23*time.Hour, "365d23h"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := formatElapsed(tc.in); got != tc.want { + t.Errorf("formatElapsed(%v) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestFormatElapsedFitsColumn pins the invariant that sizes the card's elapsed +// column: lipgloss wraps rather than truncates when content exceeds Width, so +// an over-long value would corrupt the card layout. +func TestFormatElapsedFitsColumn(t *testing.T) { + const columnWidth = 8 + for _, d := range []time.Duration{ + 0, + 59*time.Minute + 59*time.Second, + 23*time.Hour + 59*time.Minute + 59*time.Second, + 365 * 24 * time.Hour, + 999*24*time.Hour + 23*time.Hour, + } { + if got := formatElapsed(d); len(got) > columnWidth { + t.Errorf("formatElapsed(%v) = %q is %d chars, exceeds the %d-wide column", d, got, len(got), columnWidth) + } + } +}