From e6445af3d17411ce87be947c41ed803e48661e1d Mon Sep 17 00:00:00 2001 From: nwebbot Date: Fri, 17 Jul 2026 12:07:08 +1000 Subject: [PATCH 1/2] feat(tui): show hours in elapsed time past one hour A 90-minute session rendered as 90:30, which reads as a minute count rather than an hour and a half. Past 1h the timer now renders h:mm:ss. The elapsed column widens from 5 to 8 to fit 25:01:02; sub-hour rendering is unchanged. renderCard derives nameMaxW from the rendered width of the right-hand side, so the name column reflows automatically. --- tui/tui.go | 16 +++++++++++++--- tui/tui_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 tui/tui_test.go diff --git a/tui/tui.go b/tui/tui.go index bfa056f..e4f8479 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -540,6 +540,16 @@ func previewLines(lines []string, n int) []string { return out } +// formatElapsed renders a running task's elapsed time. Past one hour it gains +// an hours component, so a long session reads 1:30:00 rather than 90:00. +func formatElapsed(d time.Duration) string { + d = d.Round(time.Second) + 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 +559,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 +571,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..4b7b0b9 --- /dev/null +++ b/tui/tui_test.go @@ -0,0 +1,30 @@ +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"}, + {"multi-day", 25*time.Hour + time.Minute + 2*time.Second, "25:01:02"}, + } + 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) + } + }) + } +} From bb537c8f284b19c08794fd5740f5b6a00884dca8 Mon Sep 17 00:00:00 2001 From: nwebbot Date: Fri, 17 Jul 2026 12:25:45 +1000 Subject: [PATCH 2/2] feat(tui): use compact d+h notation past 24 hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A three-day session rendered as 73:15:30, which has the same readability problem as 90:30 did, one scale up. Past 24h the timer now uses kubectl's compact AGE notation (2d3h) — at day scale the seconds are noise. This also makes the column width exact rather than optimistic: the h:mm:ss branch now only covers sub-24h, so its longest value is 23:59:59 (8 chars) and the day branch maxes at 1000d23h (8). Nothing can overflow Width(8) and wrap the card, which a 100h+ session would have done before. Pinned by a test. --- tui/tui.go | 11 +++++++++-- tui/tui_test.go | 25 ++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/tui/tui.go b/tui/tui.go index e4f8479..6f54360 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -540,10 +540,17 @@ func previewLines(lines []string, n int) []string { return out } -// formatElapsed renders a running task's elapsed time. Past one hour it gains -// an hours component, so a long session reads 1:30:00 rather than 90:00. +// 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) } diff --git a/tui/tui_test.go b/tui/tui_test.go index 4b7b0b9..fb8dc91 100644 --- a/tui/tui_test.go +++ b/tui/tui_test.go @@ -18,7 +18,12 @@ func TestFormatElapsed(t *testing.T) { {"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"}, - {"multi-day", 25*time.Hour + time.Minute + 2*time.Second, "25:01:02"}, + {"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) { @@ -28,3 +33,21 @@ func TestFormatElapsed(t *testing.T) { }) } } + +// 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) + } + } +}