Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
Expand All @@ -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 + " "

Expand Down
53 changes: 53 additions & 0 deletions tui/tui_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading