Showing 4 changed files with 150 additions and 1 deletion.
| @@ -140,7 +140,10 @@ above a prompt: | ||
| 140 | 140 | Every event hangs off a dot in the left gutter, and everything belonging to it |
| 141 | 141 | is indented under that dot, so a screen of tool output still reads as a |
| 142 | 142 | sequence of things that happened. Diffs are shown in full and in colour with a |
| 143 | - count of what changed; other output is elided after six lines. | |
| 143 | + count of what changed; other output is elided after six lines. A finished | |
| 144 | + answer closes with how long it took — `took 1m 30s · 3 turns` — because the | |
| 145 | + activity line counts those seconds only while the turn runs, and that number | |
| 146 | + is worth keeping in the scrollback beside the answer it paid for. | |
| 144 | 147 | |
| 145 | 148 | What the model says is Markdown, and it is rendered as Markdown — headings, |
| 146 | 149 | weight, lists, block quotes, rules, and tables laid out in columns: |
| @@ -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 | + } | |
| @@ -5,6 +5,7 @@ import ( | ||
| 5 | 5 | "net/url" |
| 6 | 6 | "os" |
| 7 | 7 | "strings" |
| 8 | + "time" | |
| 8 | 9 | "unicode" |
| 9 | 10 | |
| 10 | 11 | "codey/internal/llm" |
| @@ -21,6 +22,22 @@ func human(n int) string { | ||
| 21 | 22 | return fmt.Sprint(n) |
| 22 | 23 | } |
| 23 | 24 | |
| 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 | + | |
| 24 | 41 | // prettyModel turns an API model id into the name a person uses for it: |
| 25 | 42 | // claude-opus-5 becomes "Opus 5", claude-haiku-4-5-20251001 becomes |
| 26 | 43 | // "Haiku 4.5". An id that does not fit the shape is returned unchanged, which |
| @@ -891,6 +891,16 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd { | ||
| 891 | 891 | default: |
| 892 | 892 | m.tr.addError(msg.err.Error()) |
| 893 | 893 | } |
| 894 | + | |
| 895 | + // How long that took, under the answer it belongs to. The activity line | |
| 896 | + // counted the seconds while the turn ran and is gone the moment it ends, | |
| 897 | + // so without this the one number you wanted to keep is the one the screen | |
| 898 | + // throws away. | |
| 899 | + if took := m.turnTook(); took != "" { | |
| 900 | + m.tr.addNotice(took) | |
| 901 | + } | |
| 902 | + m.started = time.Time{} | |
| 903 | + | |
| 894 | 904 | if !m.bar.hasKeys() { |
| 895 | 905 | m.input.Focus() |
| 896 | 906 | } |
| @@ -898,6 +908,21 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd { | ||
| 898 | 908 | return tea.Batch(m.sync(), textarea.Blink, m.deliverReports()) |
| 899 | 909 | } |
| 900 | 910 | |
| 911 | + // turnTook reports how long the answer took, and over how many turns of the | |
| 912 | + // tool loop when it took more than one. It is empty when nothing timed the | |
| 913 | + // turn — a turnDone with no run behind it — because a duration measured from | |
| 914 | + // the zero time is worse than no duration at all. | |
| 915 | + func (m *model) turnTook() string { | |
| 916 | + if m.started.IsZero() { | |
| 917 | + return "" | |
| 918 | + } | |
| 919 | + s := "took " + elapsed(time.Since(m.started)) | |
| 920 | + if m.turn > 1 { | |
| 921 | + s += fmt.Sprintf(" · %d turns", m.turn) | |
| 922 | + } | |
| 923 | + return s | |
| 924 | + } | |
| 925 | + | |
| 901 | 926 | func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { |
| 902 | 927 | if m.phase == phaseApproving { |
| 903 | 928 | return m.onApprovalKey(msg) |