Vectrum
Sign In

tui: an answer says how long it took

The activity line counts the seconds while a turn runs and vanishes the
moment it ends, so the one number you were watching is the one the screen
throws away. Close a finished answer with it instead: "took 1m 30s · 3
turns", in the scrollback beside the answer it paid for.

The turn count is only worth a field when the tool loop went round more
than once. An interrupted turn still took the time it took, so the report
lands under the notice that says it was cut short rather than replacing
it, and a turnDone with no run behind it says nothing at all.
Jjonicommitted 1 parent 9e0d91acommit2960389

Showing 4 changed files with 150 additions and 1 deletion.

+4−1README.mdView File
@@ -125,7 +125,10 @@ above a prompt:
125125 Every event hangs off a dot in the left gutter, and everything belonging to it
126126 is indented under that dot, so a screen of tool output still reads as a
127127 sequence of things that happened. Diffs are shown in full and in colour with a
128- count of what changed; other output is elided after six lines.
128+ count of what changed; other output is elided after six lines. A finished
129+ answer closes with how long it took — `took 1m 30s · 3 turns` — because the
130+ activity line counts those seconds only while the turn runs, and that number
131+ is worth keeping in the scrollback beside the answer it paid for.
129132
130133 What the model says is Markdown, and it is rendered as Markdown — headings,
131134 weight, lists, block quotes, rules, and tables laid out in columns:
+104−0internal/tui/duration_test.goaddedView File
@@ -0,0 +1,104 @@
1+ package tui
2+
3+ import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/charmbracelet/x/ansi"
10+ )
11+
12+ func TestElapsed(t *testing.T) {
13+ cases := []struct {
14+ d time.Duration
15+ want string
16+ }{
17+ {0, "0.0s"},
18+ {-time.Second, "0.0s"},
19+ {1500 * time.Millisecond, "1.5s"},
20+ {59900 * time.Millisecond, "59.9s"},
21+ {time.Minute, "1m 0s"},
22+ {72 * time.Second, "1m 12s"},
23+ {59*time.Minute + 59*time.Second, "59m 59s"},
24+ {time.Hour, "1h 0m"},
25+ {2*time.Hour + 5*time.Minute, "2h 5m"},
26+ }
27+ for _, tc := range cases {
28+ if got := elapsed(tc.d); got != tc.want {
29+ t.Errorf("elapsed(%v) = %q, want %q", tc.d, got, tc.want)
30+ }
31+ }
32+ }
33+
34+ // The turn's own timer is what the status line counted while it ran; when the
35+ // turn ends that line is gone, so the number has to land in the scrollback.
36+ func TestTurnDoneReportsHowLongItTook(t *testing.T) {
37+ m := newTestModel(t)
38+ m.opts.SessionDir = t.TempDir()
39+ m.phase = phaseRunning
40+ m.started = time.Now().Add(-90 * time.Second)
41+ m.turn = 3
42+
43+ m.Update(turnDone{})
44+
45+ got := ansi.Strip(m.tr.render())
46+ if !strings.Contains(got, "took 1m 30s") {
47+ t.Errorf("transcript does not report the duration:\n%s", got)
48+ }
49+ if !strings.Contains(got, "3 turns") {
50+ t.Errorf("transcript does not report the turn count:\n%s", got)
51+ }
52+ }
53+
54+ // One turn of the tool loop is the ordinary case and says nothing worth a
55+ // second field.
56+ func TestTurnDoneOmitsASingleTurnCount(t *testing.T) {
57+ m := newTestModel(t)
58+ m.opts.SessionDir = t.TempDir()
59+ m.phase = phaseRunning
60+ m.started = time.Now().Add(-2 * time.Second)
61+ m.turn = 1
62+
63+ m.Update(turnDone{})
64+
65+ got := ansi.Strip(m.tr.render())
66+ if !strings.Contains(got, "took 2.0s") {
67+ t.Errorf("transcript does not report the duration:\n%s", got)
68+ }
69+ if strings.Contains(got, "turns") {
70+ t.Errorf("a one-turn answer reported a turn count:\n%s", got)
71+ }
72+ }
73+
74+ // An interrupted turn still took the time it took, and the report lands under
75+ // the notice that says it was cut short rather than replacing it.
76+ func TestInterruptedTurnStillReportsDuration(t *testing.T) {
77+ m := newTestModel(t)
78+ m.opts.SessionDir = t.TempDir()
79+ m.phase = phaseRunning
80+ m.started = time.Now().Add(-5 * time.Second)
81+
82+ m.Update(turnDone{err: context.Canceled})
83+
84+ got := ansi.Strip(m.tr.render())
85+ if !strings.Contains(got, "interrupted") {
86+ t.Fatalf("the interruption went unreported:\n%s", got)
87+ }
88+ if i, j := strings.Index(got, "interrupted"), strings.Index(got, "took 5.0s"); j < i {
89+ t.Errorf("the duration is not under the answer it belongs to:\n%s", got)
90+ }
91+ }
92+
93+ // A turnDone with no run behind it has nothing to time, and a duration
94+ // measured from the zero time would be nonsense.
95+ func TestTurnDoneWithoutATimerSaysNothing(t *testing.T) {
96+ m := newTestModel(t)
97+ m.opts.SessionDir = t.TempDir()
98+
99+ m.Update(turnDone{})
100+
101+ if got := ansi.Strip(m.tr.render()); strings.Contains(got, "took ") {
102+ t.Errorf("an untimed turn reported a duration:\n%s", got)
103+ }
104+ }
+17−0internal/tui/format.goView File
@@ -5,6 +5,7 @@ import (
55 "net/url"
66 "os"
77 "strings"
8+ "time"
89 "unicode"
910
1011 "codey/internal/llm"
@@ -21,6 +22,22 @@ func human(n int) string {
2122 return fmt.Sprint(n)
2223 }
2324
25+ // elapsed renders how long something ran, in the coarsest unit that still
26+ // says something useful. Under a minute is the common answer and keeps a
27+ // decimal, because the difference between 3.2s and 3.9s is one a person
28+ // notices; past that the tenths stop mattering and the minutes start.
29+ func elapsed(d time.Duration) string {
30+ switch {
31+ case d < 0:
32+ return "0.0s"
33+ case d < time.Minute:
34+ return fmt.Sprintf("%.1fs", d.Seconds())
35+ case d < time.Hour:
36+ return fmt.Sprintf("%dm %ds", int(d/time.Minute), int(d%time.Minute/time.Second))
37+ }
38+ return fmt.Sprintf("%dh %dm", int(d/time.Hour), int(d%time.Hour/time.Minute))
39+ }
40+
2441 // prettyModel turns an API model id into the name a person uses for it:
2542 // claude-opus-5 becomes "Opus 5", claude-haiku-4-5-20251001 becomes
2643 // "Haiku 4.5". An id that does not fit the shape is returned unchanged, which
+25−0internal/tui/model.goView File
@@ -699,6 +699,16 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd {
699699 default:
700700 m.tr.addError(msg.err.Error())
701701 }
702+
703+ // How long that took, under the answer it belongs to. The activity line
704+ // counted the seconds while the turn ran and is gone the moment it ends,
705+ // so without this the one number you wanted to keep is the one the screen
706+ // throws away.
707+ if took := m.turnTook(); took != "" {
708+ m.tr.addNotice(took)
709+ }
710+ m.started = time.Time{}
711+
702712 if !m.bar.hasKeys() {
703713 m.input.Focus()
704714 }
@@ -706,6 +716,21 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd {
706716 return tea.Batch(m.sync(), textarea.Blink, m.deliverReports())
707717 }
708718
719+ // turnTook reports how long the answer took, and over how many turns of the
720+ // tool loop when it took more than one. It is empty when nothing timed the
721+ // turn — a turnDone with no run behind it — because a duration measured from
722+ // the zero time is worse than no duration at all.
723+ func (m *model) turnTook() string {
724+ if m.started.IsZero() {
725+ return ""
726+ }
727+ s := "took " + elapsed(time.Since(m.started))
728+ if m.turn > 1 {
729+ s += fmt.Sprintf(" · %d turns", m.turn)
730+ }
731+ return s
732+ }
733+
709734 func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
710735 if m.phase == phaseApproving {
711736 return m.onApprovalKey(msg)