Add a Skills field to the TUI's Options, a /skills command that lists discovered skills, and an isSkill guard so a recognised /<slug> falls through to the ordinary user-turn path instead of being rejected as an unknown command. An unrecognised slash still errors as before. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing 12 changed files with 2922 additions and 0 deletions.
| @@ -0,0 +1,100 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + | |
| 6 | + tea "github.com/charmbracelet/bubbletea" | |
| 7 | + | |
| 8 | + "harness/internal/agent" | |
| 9 | + "harness/internal/approval" | |
| 10 | + "harness/internal/llm" | |
| 11 | + ) | |
| 12 | + | |
| 13 | + // The agent loop runs on its own goroutine and must not know a UI exists. It | |
| 14 | + // reaches the model through two one-way hatches: | |
| 15 | + // | |
| 16 | + // - events go out through Program.Send, which is safe from any goroutine; | |
| 17 | + // - approvals go out the same way and then block the agent on a reply | |
| 18 | + // channel until the user presses a key. | |
| 19 | + // | |
| 20 | + // Blocking the agent goroutine is the correct behaviour, not a compromise: the | |
| 21 | + // tool must not run until a human says so. The Bubble Tea update loop keeps | |
| 22 | + // running throughout, so the UI stays responsive while the agent waits. | |
| 23 | + | |
| 24 | + // sender is the part of tea.Program the bridge needs, narrowed to an interface | |
| 25 | + // so tests can drive it without starting a terminal program. | |
| 26 | + type sender interface{ Send(tea.Msg) } | |
| 27 | + | |
| 28 | + // Event messages delivered into the Bubble Tea loop. | |
| 29 | + type ( | |
| 30 | + evTurnStart struct{ turn int } | |
| 31 | + evTextDelta struct{ text string } | |
| 32 | + evText struct{ text string } | |
| 33 | + evToolStart struct{ tool, input string } | |
| 34 | + evToolResult struct { | |
| 35 | + tool, result string | |
| 36 | + isError bool | |
| 37 | + } | |
| 38 | + evUsage struct{ usage llm.Usage } | |
| 39 | + evCompact struct{} | |
| 40 | + | |
| 41 | + // approvalMsg asks the user to decide. reply is buffered, so answering | |
| 42 | + // never blocks the UI even if the agent's context was cancelled first. | |
| 43 | + approvalMsg struct { | |
| 44 | + req approval.Request | |
| 45 | + reply chan<- approval.Decision | |
| 46 | + } | |
| 47 | + | |
| 48 | + // turnDone reports the end of one prompt, with whatever the run returned. | |
| 49 | + turnDone struct { | |
| 50 | + err error | |
| 51 | + usage llm.Usage | |
| 52 | + turns int | |
| 53 | + } | |
| 54 | + ) | |
| 55 | + | |
| 56 | + // bridge adapts the agent's callbacks to Bubble Tea messages. | |
| 57 | + type bridge struct{ p sender } | |
| 58 | + | |
| 59 | + // Observe is the agent.Observe callback. It is called from the agent | |
| 60 | + // goroutine; Send is safe there. | |
| 61 | + func (b *bridge) Observe(ev agent.Event) { | |
| 62 | + switch ev.Kind { | |
| 63 | + case agent.EventTurnStart: | |
| 64 | + b.p.Send(evTurnStart{turn: ev.Turn}) | |
| 65 | + case agent.EventTextDelta: | |
| 66 | + b.p.Send(evTextDelta{text: ev.Text}) | |
| 67 | + case agent.EventText: | |
| 68 | + b.p.Send(evText{text: ev.Text}) | |
| 69 | + case agent.EventToolStart: | |
| 70 | + b.p.Send(evToolStart{tool: ev.Tool, input: summarizeInput(ev.Input)}) | |
| 71 | + case agent.EventToolResult: | |
| 72 | + b.p.Send(evToolResult{tool: ev.Tool, result: ev.Result, isError: ev.IsError}) | |
| 73 | + case agent.EventUsage: | |
| 74 | + b.p.Send(evUsage{usage: ev.Usage}) | |
| 75 | + case agent.EventCompact: | |
| 76 | + b.p.Send(evCompact{}) | |
| 77 | + } | |
| 78 | + } | |
| 79 | + | |
| 80 | + // Prompt implements approval.Prompt. It hands the request to the UI and waits. | |
| 81 | + // | |
| 82 | + // The context is honoured while waiting, so ctrl-c during an approval prompt | |
| 83 | + // interrupts the turn instead of stranding the agent on a channel nobody will | |
| 84 | + // ever read. | |
| 85 | + func (b *bridge) Prompt(ctx context.Context, req approval.Request) (approval.Decision, error) { | |
| 86 | + if err := ctx.Err(); err != nil { | |
| 87 | + return approval.Deny, err | |
| 88 | + } | |
| 89 | + reply := make(chan approval.Decision, 1) | |
| 90 | + b.p.Send(approvalMsg{req: req, reply: reply}) | |
| 91 | + | |
| 92 | + select { | |
| 93 | + case d := <-reply: | |
| 94 | + return d, nil | |
| 95 | + case <-ctx.Done(): | |
| 96 | + // Denying on cancellation is the only safe reading: the turn is going | |
| 97 | + // away and nobody approved anything. | |
| 98 | + return approval.Deny, ctx.Err() | |
| 99 | + } | |
| 100 | + } | |
| @@ -0,0 +1,179 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "sync" | |
| 7 | + "testing" | |
| 8 | + "time" | |
| 9 | + | |
| 10 | + tea "github.com/charmbracelet/bubbletea" | |
| 11 | + | |
| 12 | + "harness/internal/agent" | |
| 13 | + "harness/internal/approval" | |
| 14 | + ) | |
| 15 | + | |
| 16 | + // recorder stands in for tea.Program: it captures what the bridge sends | |
| 17 | + // without needing a terminal. | |
| 18 | + type recorder struct { | |
| 19 | + mu sync.Mutex | |
| 20 | + msgs []tea.Msg | |
| 21 | + // onSend runs for each message, so a test can answer an approval from the | |
| 22 | + // "UI side" the way the Update loop would. | |
| 23 | + onSend func(tea.Msg) | |
| 24 | + } | |
| 25 | + | |
| 26 | + func (r *recorder) Send(m tea.Msg) { | |
| 27 | + r.mu.Lock() | |
| 28 | + r.msgs = append(r.msgs, m) | |
| 29 | + f := r.onSend | |
| 30 | + r.mu.Unlock() | |
| 31 | + if f != nil { | |
| 32 | + f(m) | |
| 33 | + } | |
| 34 | + } | |
| 35 | + | |
| 36 | + func (r *recorder) all() []tea.Msg { | |
| 37 | + r.mu.Lock() | |
| 38 | + defer r.mu.Unlock() | |
| 39 | + return append([]tea.Msg(nil), r.msgs...) | |
| 40 | + } | |
| 41 | + | |
| 42 | + func TestObserveTranslatesEvents(t *testing.T) { | |
| 43 | + r := &recorder{} | |
| 44 | + b := &bridge{p: r} | |
| 45 | + | |
| 46 | + b.Observe(agent.Event{Kind: agent.EventTurnStart, Turn: 2}) | |
| 47 | + b.Observe(agent.Event{Kind: agent.EventTextDelta, Text: "hi"}) | |
| 48 | + b.Observe(agent.Event{Kind: agent.EventToolStart, Tool: "grep", Input: json.RawMessage(`{"pattern":"x"}`)}) | |
| 49 | + b.Observe(agent.Event{Kind: agent.EventToolResult, Tool: "grep", Result: "x.go:1", IsError: false}) | |
| 50 | + b.Observe(agent.Event{Kind: agent.EventCompact}) | |
| 51 | + | |
| 52 | + msgs := r.all() | |
| 53 | + if len(msgs) != 5 { | |
| 54 | + t.Fatalf("got %d messages, want 5", len(msgs)) | |
| 55 | + } | |
| 56 | + if m, ok := msgs[0].(evTurnStart); !ok || m.turn != 2 { | |
| 57 | + t.Errorf("msgs[0] = %#v, want evTurnStart{2}", msgs[0]) | |
| 58 | + } | |
| 59 | + if m, ok := msgs[2].(evToolStart); !ok || m.input != "pattern=x" { | |
| 60 | + t.Errorf("msgs[2] = %#v, want summarised tool input", msgs[2]) | |
| 61 | + } | |
| 62 | + if _, ok := msgs[4].(evCompact); !ok { | |
| 63 | + t.Errorf("msgs[4] = %#v, want evCompact", msgs[4]) | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + // The agent goroutine must block until the UI answers, and must get exactly | |
| 68 | + // the decision the user picked. | |
| 69 | + func TestPromptBlocksUntilAnswered(t *testing.T) { | |
| 70 | + r := &recorder{} | |
| 71 | + r.onSend = func(m tea.Msg) { | |
| 72 | + if am, ok := m.(approvalMsg); ok { | |
| 73 | + go func() { | |
| 74 | + time.Sleep(10 * time.Millisecond) | |
| 75 | + am.reply <- approval.AlwaysThisTool | |
| 76 | + }() | |
| 77 | + } | |
| 78 | + } | |
| 79 | + b := &bridge{p: r} | |
| 80 | + | |
| 81 | + start := time.Now() | |
| 82 | + got, err := b.Prompt(context.Background(), approval.Request{Tool: "bash", Summary: "run a shell command"}) | |
| 83 | + if err != nil { | |
| 84 | + t.Fatalf("Prompt: %v", err) | |
| 85 | + } | |
| 86 | + if got != approval.AlwaysThisTool { | |
| 87 | + t.Errorf("decision = %v, want AlwaysThisTool", got) | |
| 88 | + } | |
| 89 | + if time.Since(start) < 10*time.Millisecond { | |
| 90 | + t.Error("Prompt returned before the UI answered") | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + // A cancelled turn must not strand the agent on a channel no one will read. | |
| 95 | + // This is the deadlock the whole design has to avoid. | |
| 96 | + func TestPromptUnblocksOnCancel(t *testing.T) { | |
| 97 | + r := &recorder{} // never answers | |
| 98 | + b := &bridge{p: r} | |
| 99 | + | |
| 100 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 101 | + go func() { | |
| 102 | + time.Sleep(10 * time.Millisecond) | |
| 103 | + cancel() | |
| 104 | + }() | |
| 105 | + | |
| 106 | + done := make(chan struct{}) | |
| 107 | + var got approval.Decision | |
| 108 | + var err error | |
| 109 | + go func() { | |
| 110 | + got, err = b.Prompt(ctx, approval.Request{Tool: "bash"}) | |
| 111 | + close(done) | |
| 112 | + }() | |
| 113 | + | |
| 114 | + select { | |
| 115 | + case <-done: | |
| 116 | + case <-time.After(2 * time.Second): | |
| 117 | + t.Fatal("Prompt did not return after its context was cancelled") | |
| 118 | + } | |
| 119 | + if got != approval.Deny { | |
| 120 | + t.Errorf("decision = %v, want Deny on cancellation", got) | |
| 121 | + } | |
| 122 | + if err == nil { | |
| 123 | + t.Error("want a non-nil error on cancellation") | |
| 124 | + } | |
| 125 | + } | |
| 126 | + | |
| 127 | + func TestPromptRefusesAnAlreadyCancelledContext(t *testing.T) { | |
| 128 | + r := &recorder{} | |
| 129 | + b := &bridge{p: r} | |
| 130 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 131 | + cancel() | |
| 132 | + | |
| 133 | + got, err := b.Prompt(ctx, approval.Request{Tool: "bash"}) | |
| 134 | + if got != approval.Deny || err == nil { | |
| 135 | + t.Errorf("Prompt = (%v, %v), want (Deny, error)", got, err) | |
| 136 | + } | |
| 137 | + if len(r.all()) != 0 { | |
| 138 | + t.Error("a cancelled prompt still bothered the UI") | |
| 139 | + } | |
| 140 | + } | |
| 141 | + | |
| 142 | + // The reply channel is buffered, so a UI that answers a request whose agent | |
| 143 | + // has already given up must not block forever. | |
| 144 | + func TestReplyChannelNeverBlocksTheUI(t *testing.T) { | |
| 145 | + r := &recorder{} | |
| 146 | + b := &bridge{p: r} | |
| 147 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 148 | + | |
| 149 | + var captured approvalMsg | |
| 150 | + got := make(chan struct{}) | |
| 151 | + r.onSend = func(m tea.Msg) { | |
| 152 | + if am, ok := m.(approvalMsg); ok { | |
| 153 | + captured = am | |
| 154 | + close(got) | |
| 155 | + } | |
| 156 | + } | |
| 157 | + go b.Prompt(ctx, approval.Request{Tool: "bash"}) | |
| 158 | + <-got | |
| 159 | + cancel() | |
| 160 | + time.Sleep(20 * time.Millisecond) // let Prompt give up | |
| 161 | + | |
| 162 | + done := make(chan struct{}) | |
| 163 | + go func() { | |
| 164 | + captured.reply <- approval.Once // must not block | |
| 165 | + close(done) | |
| 166 | + }() | |
| 167 | + select { | |
| 168 | + case <-done: | |
| 169 | + case <-time.After(time.Second): | |
| 170 | + t.Fatal("answering an abandoned approval blocked the UI") | |
| 171 | + } | |
| 172 | + } | |
| 173 | + | |
| 174 | + // The bridge must satisfy the interfaces the agent and policy expect. If these | |
| 175 | + // signatures drift, this stops compiling, which is the point. | |
| 176 | + var ( | |
| 177 | + _ = func(b *bridge) { var _ func(agent.Event) = b.Observe } | |
| 178 | + _ = func(b *bridge) { var _ approval.Prompt = b.Prompt } | |
| 179 | + ) | |
| @@ -0,0 +1,106 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | + "unicode/utf8" | |
| 7 | + | |
| 8 | + "github.com/charmbracelet/lipgloss" | |
| 9 | + "github.com/muesli/termenv" | |
| 10 | + | |
| 11 | + "harness/internal/llm" | |
| 12 | + ) | |
| 13 | + | |
| 14 | + // forceColour makes styles emit real escape sequences. Without it the test | |
| 15 | + // renderer detects no terminal, strips every colour, and any assertion about | |
| 16 | + // what a style actually painted passes vacuously. | |
| 17 | + func forceColour(t *testing.T) { | |
| 18 | + t.Helper() | |
| 19 | + prev := lipgloss.ColorProfile() | |
| 20 | + lipgloss.SetColorProfile(termenv.TrueColor) | |
| 21 | + t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) | |
| 22 | + } | |
| 23 | + | |
| 24 | + // unbackedCells returns the printable characters that were emitted with no | |
| 25 | + // background colour in force. | |
| 26 | + func unbackedCells(s string) string { | |
| 27 | + var out []rune | |
| 28 | + active := "" | |
| 29 | + for i := 0; i < len(s); { | |
| 30 | + if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' { | |
| 31 | + j := i + 2 | |
| 32 | + for j < len(s) && s[j] != 'm' { | |
| 33 | + j++ | |
| 34 | + } | |
| 35 | + if j >= len(s) { | |
| 36 | + break | |
| 37 | + } | |
| 38 | + if p := s[i+2 : j]; p == "" || p == "0" { | |
| 39 | + active = "" | |
| 40 | + } else { | |
| 41 | + active = p | |
| 42 | + } | |
| 43 | + i = j + 1 | |
| 44 | + continue | |
| 45 | + } | |
| 46 | + r, size := utf8.DecodeRuneInString(s[i:]) | |
| 47 | + if !strings.Contains(active, "48;") { | |
| 48 | + out = append(out, r) | |
| 49 | + } | |
| 50 | + i += size | |
| 51 | + } | |
| 52 | + return string(out) | |
| 53 | + } | |
| 54 | + | |
| 55 | + // A style nested inside a background ends with a reset, which drops the | |
| 56 | + // background for everything after it and tears a visible hole in the pill. | |
| 57 | + func TestPillsAreFilledEdgeToEdge(t *testing.T) { | |
| 58 | + forceColour(t) | |
| 59 | + | |
| 60 | + m := newTestModel(t) | |
| 61 | + m.opts.ContextWindow = 200_000 | |
| 62 | + m.Update(evUsage{usage: llm.Usage{InputTokens: 2_000, CacheReadInputTokens: 18_000}}) | |
| 63 | + | |
| 64 | + pill, ok := m.contextPill() | |
| 65 | + if !ok { | |
| 66 | + t.Fatal("no context pill after usage was reported") | |
| 67 | + } | |
| 68 | + if got := unbackedCells(pill); got != "" { | |
| 69 | + t.Errorf("context pill has unfilled cells %q in %q", got, pill) | |
| 70 | + } | |
| 71 | + | |
| 72 | + if got := unbackedCells(styName.Render(username())); got != "" { | |
| 73 | + t.Errorf("name pill has unfilled cells %q", got) | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + func TestContextPillColoursByPressure(t *testing.T) { | |
| 78 | + forceColour(t) | |
| 79 | + | |
| 80 | + cases := []struct { | |
| 81 | + tokens int | |
| 82 | + pct string | |
| 83 | + colour lipgloss.AdaptiveColor | |
| 84 | + }{ | |
| 85 | + {10_000, "(10%)", colOK}, | |
| 86 | + {65_000, "(65%)", colWarn}, | |
| 87 | + {90_000, "(90%)", colErr}, | |
| 88 | + } | |
| 89 | + for _, tc := range cases { | |
| 90 | + m := newTestModel(t) | |
| 91 | + m.opts.ContextWindow = 100_000 | |
| 92 | + m.Update(evUsage{usage: llm.Usage{InputTokens: tc.tokens}}) | |
| 93 | + | |
| 94 | + pill, ok := m.contextPill() | |
| 95 | + if !ok { | |
| 96 | + t.Fatalf("%d tokens produced no pill", tc.tokens) | |
| 97 | + } | |
| 98 | + if !strings.Contains(pill, tc.pct) { | |
| 99 | + t.Errorf("%d tokens: %q does not report %s", tc.tokens, pill, tc.pct) | |
| 100 | + } | |
| 101 | + want := termenv.TrueColor.Color(tc.colour.Dark).Sequence(false) | |
| 102 | + if !strings.Contains(pill, want) { | |
| 103 | + t.Errorf("%d tokens: pill is not %s: %q", tc.tokens, tc.colour.Dark, pill) | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } | |
| @@ -0,0 +1,139 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "fmt" | |
| 5 | + "net/url" | |
| 6 | + "os" | |
| 7 | + "strings" | |
| 8 | + "unicode" | |
| 9 | + | |
| 10 | + "harness/internal/llm" | |
| 11 | + ) | |
| 12 | + | |
| 13 | + // human renders a token count the way a status bar wants to read it. | |
| 14 | + func human(n int) string { | |
| 15 | + switch { | |
| 16 | + case n >= 1_000_000: | |
| 17 | + return fmt.Sprintf("%.1fM", float64(n)/1e6) | |
| 18 | + case n >= 1_000: | |
| 19 | + return fmt.Sprintf("%.1fk", float64(n)/1e3) | |
| 20 | + } | |
| 21 | + return fmt.Sprint(n) | |
| 22 | + } | |
| 23 | + | |
| 24 | + // prettyModel turns an API model id into the name a person uses for it: | |
| 25 | + // claude-opus-5 becomes "Opus 5", claude-haiku-4-5-20251001 becomes | |
| 26 | + // "Haiku 4.5". An id that does not fit the shape is returned unchanged, which | |
| 27 | + // matters because this also runs against gateways serving models we have | |
| 28 | + // never heard of. | |
| 29 | + func prettyModel(id string) string { | |
| 30 | + s := strings.TrimSuffix(strings.TrimPrefix(id, "claude-"), "-latest") | |
| 31 | + // Anything bracketed is a variant tag, not part of the name: opus-5[1m]. | |
| 32 | + tag := "" | |
| 33 | + if i := strings.IndexByte(s, '['); i >= 0 { | |
| 34 | + tag, s = s[i:], s[:i] | |
| 35 | + } | |
| 36 | + parts := strings.Split(s, "-") | |
| 37 | + if len(parts) < 2 { | |
| 38 | + return id | |
| 39 | + } | |
| 40 | + // A trailing eight-digit date stamp is a build, not a version. | |
| 41 | + if last := parts[len(parts)-1]; len(last) == 8 && allDigits(last) { | |
| 42 | + parts = parts[:len(parts)-1] | |
| 43 | + } | |
| 44 | + family, rest := parts[0], parts[1:] | |
| 45 | + if family == "" || !isAlpha(family) { | |
| 46 | + return id | |
| 47 | + } | |
| 48 | + name := strings.ToUpper(family[:1]) + family[1:] | |
| 49 | + var version []string | |
| 50 | + for _, p := range rest { | |
| 51 | + if !allDigits(p) { | |
| 52 | + return id | |
| 53 | + } | |
| 54 | + version = append(version, p) | |
| 55 | + } | |
| 56 | + if len(version) > 0 { | |
| 57 | + name += " " + strings.Join(version, ".") | |
| 58 | + } | |
| 59 | + return name + tag | |
| 60 | + } | |
| 61 | + | |
| 62 | + // endpointLabel names where the prompts are going. The default endpoint gets | |
| 63 | + // a short name; anything else shows its host, because "somewhere that is not | |
| 64 | + // Anthropic" is the single most important thing the header can say. | |
| 65 | + func endpointLabel(c *llm.Client) string { | |
| 66 | + if c == nil { | |
| 67 | + return "" | |
| 68 | + } | |
| 69 | + if c.IsDefaultEndpoint() { | |
| 70 | + return "anthropic" | |
| 71 | + } | |
| 72 | + if u, err := url.Parse(c.BaseURL); err == nil && u.Host != "" { | |
| 73 | + return u.Host | |
| 74 | + } | |
| 75 | + return c.BaseURL | |
| 76 | + } | |
| 77 | + | |
| 78 | + // camel renders a snake_case tool name as the transcript shows it: read_file | |
| 79 | + // becomes ReadFile. It is a display transform only — approvals and errors | |
| 80 | + // still quote the real name. | |
| 81 | + func camel(name string) string { | |
| 82 | + parts := strings.Split(name, "_") | |
| 83 | + for i, p := range parts { | |
| 84 | + if p == "" { | |
| 85 | + continue | |
| 86 | + } | |
| 87 | + parts[i] = strings.ToUpper(p[:1]) + p[1:] | |
| 88 | + } | |
| 89 | + return strings.Join(parts, "") | |
| 90 | + } | |
| 91 | + | |
| 92 | + func allDigits(s string) bool { | |
| 93 | + if s == "" { | |
| 94 | + return false | |
| 95 | + } | |
| 96 | + for _, r := range s { | |
| 97 | + if !unicode.IsDigit(r) { | |
| 98 | + return false | |
| 99 | + } | |
| 100 | + } | |
| 101 | + return true | |
| 102 | + } | |
| 103 | + | |
| 104 | + func isAlpha(s string) bool { | |
| 105 | + for _, r := range s { | |
| 106 | + if !unicode.IsLetter(r) { | |
| 107 | + return false | |
| 108 | + } | |
| 109 | + } | |
| 110 | + return s != "" | |
| 111 | + } | |
| 112 | + | |
| 113 | + func maxInt(a, b int) int { | |
| 114 | + if a > b { | |
| 115 | + return a | |
| 116 | + } | |
| 117 | + return b | |
| 118 | + } | |
| 119 | + | |
| 120 | + func minInt(a, b int) int { | |
| 121 | + if a < b { | |
| 122 | + return a | |
| 123 | + } | |
| 124 | + return b | |
| 125 | + } | |
| 126 | + | |
| 127 | + // shortenHome writes a path the way a person says it. The working root is | |
| 128 | + // shown in the welcome block, where "~/Work/thing" carries the same | |
| 129 | + // information as the absolute path in a third of the columns. | |
| 130 | + func shortenHome(path string) string { | |
| 131 | + home, err := os.UserHomeDir() | |
| 132 | + if err != nil || home == "" || path == home { | |
| 133 | + return path | |
| 134 | + } | |
| 135 | + if strings.HasPrefix(path, home+string(os.PathSeparator)) { | |
| 136 | + return "~" + path[len(home):] | |
| 137 | + } | |
| 138 | + return path | |
| 139 | + } | |
| @@ -0,0 +1,311 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "strings" | |
| 6 | + "testing" | |
| 7 | + | |
| 8 | + tea "github.com/charmbracelet/bubbletea" | |
| 9 | + "github.com/charmbracelet/lipgloss" | |
| 10 | + | |
| 11 | + "harness/internal/approval" | |
| 12 | + "harness/internal/llm" | |
| 13 | + ) | |
| 14 | + | |
| 15 | + func TestPrettyModel(t *testing.T) { | |
| 16 | + cases := map[string]string{ | |
| 17 | + "claude-opus-5": "Opus 5", | |
| 18 | + "claude-sonnet-5": "Sonnet 5", | |
| 19 | + "claude-haiku-4-5-20251001": "Haiku 4.5", | |
| 20 | + "claude-opus-5[1m]": "Opus 5[1m]", | |
| 21 | + "claude-sonnet-4-5-latest": "Sonnet 4.5", | |
| 22 | + "gpt-4o": "gpt-4o", // not ours: left alone rather than mangled | |
| 23 | + "some/gateway/model": "some/gateway/model", | |
| 24 | + "": "", | |
| 25 | + } | |
| 26 | + for in, want := range cases { | |
| 27 | + if got := prettyModel(in); got != want { | |
| 28 | + t.Errorf("prettyModel(%q) = %q, want %q", in, got, want) | |
| 29 | + } | |
| 30 | + } | |
| 31 | + } | |
| 32 | + | |
| 33 | + // The welcome block is laid out in columns, so a terminal too narrow to hold | |
| 34 | + // both must drop the mascot rather than slice it into noise. | |
| 35 | + func TestSplashDropsMascotWhenNarrow(t *testing.T) { | |
| 36 | + o := Options{Client: llm.New(llm.WithAPIKey("x")), Root: "/tmp/repo", Version: "v0.6.0"} | |
| 37 | + | |
| 38 | + wide := splash(o, 100) | |
| 39 | + if !strings.Contains(wide, mascot[2]) { | |
| 40 | + t.Error("mascot missing at a comfortable width") | |
| 41 | + } | |
| 42 | + if !strings.Contains(wide, "/tmp/repo") { | |
| 43 | + t.Error("root missing from the welcome block") | |
| 44 | + } | |
| 45 | + for _, line := range strings.Split(strings.TrimRight(wide, "\n"), "\n") { | |
| 46 | + if w := lipgloss.Width(line); w > 100 { | |
| 47 | + t.Errorf("welcome line is %d cells wide, over the 100 available: %q", w, line) | |
| 48 | + } | |
| 49 | + } | |
| 50 | + | |
| 51 | + // A deeply nested working directory is a long line, not a narrow | |
| 52 | + // terminal: it gets truncated, and the mascot stays. | |
| 53 | + deep := Options{Client: o.Client, Version: o.Version, | |
| 54 | + Root: "/tmp/claude-1000/-home-joni-Work-GO-coding-harness/b520442d-29dd-4870/scratchpad/demo"} | |
| 55 | + if !strings.Contains(splash(deep, 100), mascot[2]) { | |
| 56 | + t.Error("a long root path dropped the mascot on a wide terminal") | |
| 57 | + } | |
| 58 | + | |
| 59 | + narrow := splash(o, 24) | |
| 60 | + if strings.Contains(narrow, mascot[2]) { | |
| 61 | + t.Error("mascot survived into a terminal too narrow for it") | |
| 62 | + } | |
| 63 | + if !strings.Contains(narrow, "harness") { | |
| 64 | + t.Error("narrow welcome block lost its name") | |
| 65 | + } | |
| 66 | + } | |
| 67 | + | |
| 68 | + func TestStatusShowsContextGauge(t *testing.T) { | |
| 69 | + m := newTestModel(t) | |
| 70 | + m.opts.ContextWindow = 200_000 | |
| 71 | + m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) | |
| 72 | + m.Update(evUsage{usage: llm.Usage{InputTokens: 2_000, CacheReadInputTokens: 18_000, OutputTokens: 500}}) | |
| 73 | + | |
| 74 | + status := m.status() | |
| 75 | + if !strings.Contains(status, "20.0k") { | |
| 76 | + t.Errorf("context size missing from the status bar: %q", status) | |
| 77 | + } | |
| 78 | + if !strings.Contains(status, "(10%)") { | |
| 79 | + t.Errorf("context percentage wrong or missing: %q", status) | |
| 80 | + } | |
| 81 | + | |
| 82 | + // A second turn replaces the gauge rather than adding to it: the context | |
| 83 | + // is how much the model read this time, not the sum of every turn. | |
| 84 | + m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000, CacheReadInputTokens: 3_000}}) | |
| 85 | + if got := m.status(); !strings.Contains(got, "(2%)") { | |
| 86 | + t.Errorf("gauge accumulated across turns instead of replacing: %q", got) | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + func TestShiftTabCyclesApprovalMode(t *testing.T) { | |
| 91 | + m := newTestModel(t) | |
| 92 | + if strings.Contains(m.modeLine(), "accept-all") { | |
| 93 | + t.Fatal("started in accept-all without being asked to") | |
| 94 | + } | |
| 95 | + | |
| 96 | + m.Update(key("shift+tab")) | |
| 97 | + if !m.opts.Policy.YOLO { | |
| 98 | + t.Error("shift+tab did not turn accept-all on") | |
| 99 | + } | |
| 100 | + if !strings.Contains(m.modeLine(), "accept-all on") { | |
| 101 | + t.Errorf("mode line does not report accept-all: %q", m.modeLine()) | |
| 102 | + } | |
| 103 | + if !strings.Contains(m.tr.render(), "accept-all on") { | |
| 104 | + t.Error("a change to what the agent may do was not recorded in the transcript") | |
| 105 | + } | |
| 106 | + | |
| 107 | + m.Update(key("shift+tab")) | |
| 108 | + if m.opts.Policy.YOLO { | |
| 109 | + t.Error("shift+tab did not turn accept-all back off") | |
| 110 | + } | |
| 111 | + } | |
| 112 | + | |
| 113 | + func TestInputBoxGrowsWithContentAndStops(t *testing.T) { | |
| 114 | + m := newTestModel(t) | |
| 115 | + if got := m.input.Height(); got != 1 { | |
| 116 | + t.Errorf("empty prompt is %d lines tall, want 1", got) | |
| 117 | + } | |
| 118 | + | |
| 119 | + m.input.SetValue("one\ntwo\nthree") | |
| 120 | + m.growInput() | |
| 121 | + if got := m.input.Height(); got != 3 { | |
| 122 | + t.Errorf("three lines of input gave a box %d tall", got) | |
| 123 | + } | |
| 124 | + | |
| 125 | + m.input.SetValue(strings.Repeat("x\n", 40)) | |
| 126 | + m.growInput() | |
| 127 | + if got := m.input.Height(); got != inputMaxLines { | |
| 128 | + t.Errorf("box grew to %d, want it capped at %d", got, inputMaxLines) | |
| 129 | + } | |
| 130 | + } | |
| 131 | + | |
| 132 | + // The numbered options are the primary way to answer, so the number a person | |
| 133 | + // reads must reach the decision it is printed next to. | |
| 134 | + func TestApprovalNumbersMatchTheirLabels(t *testing.T) { | |
| 135 | + cases := []struct { | |
| 136 | + name string | |
| 137 | + rememberable bool | |
| 138 | + want map[string]approval.Decision | |
| 139 | + }{ | |
| 140 | + {"rememberable", true, map[string]approval.Decision{ | |
| 141 | + "1": approval.Once, | |
| 142 | + "2": approval.AlwaysThisCommand, | |
| 143 | + "3": approval.AlwaysThisTool, | |
| 144 | + "4": approval.Deny, | |
| 145 | + }}, | |
| 146 | + {"opaque", false, map[string]approval.Decision{ | |
| 147 | + "1": approval.Once, | |
| 148 | + "2": approval.AlwaysThisTool, | |
| 149 | + "3": approval.Deny, | |
| 150 | + }}, | |
| 151 | + } | |
| 152 | + for _, tc := range cases { | |
| 153 | + t.Run(tc.name, func(t *testing.T) { | |
| 154 | + for k, want := range tc.want { | |
| 155 | + m := newTestModel(t) | |
| 156 | + req := approval.Request{Tool: "bash", Summary: "run it", Detail: "ls", Rememberable: tc.rememberable} | |
| 157 | + reply := pend(m, req) | |
| 158 | + | |
| 159 | + view := m.approvalView() | |
| 160 | + opts := m.choices(req) | |
| 161 | + idx := int(k[0] - '1') | |
| 162 | + if !strings.Contains(view, opts[idx].label) { | |
| 163 | + t.Fatalf("option %s (%q) is not in the box:\n%s", k, opts[idx].label, view) | |
| 164 | + } | |
| 165 | + | |
| 166 | + m.Update(key(k)) | |
| 167 | + select { | |
| 168 | + case got := <-reply: | |
| 169 | + if got != want { | |
| 170 | + t.Errorf("key %s sent %v, want %v", k, got, want) | |
| 171 | + } | |
| 172 | + default: | |
| 173 | + t.Fatalf("key %s answered nothing", k) | |
| 174 | + } | |
| 175 | + } | |
| 176 | + }) | |
| 177 | + } | |
| 178 | + } | |
| 179 | + | |
| 180 | + // The chrome grows and shrinks — the prompt box with what is typed, the | |
| 181 | + // approval box when it appears — so the view has to stay exactly as tall as | |
| 182 | + // the terminal rather than pushing its own top off the screen. | |
| 183 | + func TestViewFillsExactlyTheTerminalHeight(t *testing.T) { | |
| 184 | + for _, size := range [][2]int{{80, 24}, {120, 40}, {60, 30}} { | |
| 185 | + m := newTestModel(t) | |
| 186 | + m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) | |
| 187 | + for i := 0; i < 40; i++ { | |
| 188 | + m.tr.addUser("a line of scrollback") | |
| 189 | + } | |
| 190 | + m.sync() | |
| 191 | + | |
| 192 | + if got := lipgloss.Height(m.View()); got != size[1] { | |
| 193 | + t.Errorf("%dx%d: view is %d rows, want %d", size[0], size[1], got, size[1]) | |
| 194 | + } | |
| 195 | + | |
| 196 | + m.input.SetValue("one\ntwo\nthree\nfour") | |
| 197 | + m.growInput() | |
| 198 | + if got := lipgloss.Height(m.View()); got != size[1] { | |
| 199 | + t.Errorf("%dx%d with a grown prompt: view is %d rows, want %d", size[0], size[1], got, size[1]) | |
| 200 | + } | |
| 201 | + | |
| 202 | + pend(m, approval.Request{Tool: "bash", Summary: "run it", Detail: "rm -rf /", Rememberable: true}) | |
| 203 | + if got := lipgloss.Height(m.View()); got != size[1] { | |
| 204 | + t.Errorf("%dx%d while approving: view is %d rows, want %d", size[0], size[1], got, size[1]) | |
| 205 | + } | |
| 206 | + } | |
| 207 | + } | |
| 208 | + | |
| 209 | + // Every line of chrome has to fit the terminal it is drawn in; a status bar | |
| 210 | + // one cell too wide wraps and silently eats a row of the transcript. | |
| 211 | + func TestChromeNeverExceedsTerminalWidth(t *testing.T) { | |
| 212 | + for _, w := range []int{40, 60, 80, 120} { | |
| 213 | + m := newTestModel(t) | |
| 214 | + m.opts.Effort = "high" | |
| 215 | + m.Update(tea.WindowSizeMsg{Width: w, Height: 24}) | |
| 216 | + m.Update(evUsage{usage: llm.Usage{InputTokens: 24_000, CacheReadInputTokens: 180_000, OutputTokens: 4_200}}) | |
| 217 | + m.phase = phaseRunning | |
| 218 | + | |
| 219 | + for name, line := range map[string]string{ | |
| 220 | + "activity": m.activity(), | |
| 221 | + "status": m.status(), | |
| 222 | + "mode": m.modeLine(), | |
| 223 | + "input": m.inputBox(), | |
| 224 | + } { | |
| 225 | + for _, row := range strings.Split(line, "\n") { | |
| 226 | + if got := lipgloss.Width(row); got > w { | |
| 227 | + t.Errorf("width %d: %s line is %d cells: %q", w, name, got, row) | |
| 228 | + } | |
| 229 | + } | |
| 230 | + } | |
| 231 | + } | |
| 232 | + } | |
| 233 | + | |
| 234 | + // A transcript entry the renderer does not know about must not vanish; the | |
| 235 | + // welcome block is passed through verbatim. | |
| 236 | + func TestRawEntryIsPassedThrough(t *testing.T) { | |
| 237 | + tr := newTranscript(80) | |
| 238 | + tr.addRaw("▐██████▌ verbatim\n") | |
| 239 | + if got := tr.render(); !strings.Contains(got, "▐██████▌ verbatim") { | |
| 240 | + t.Errorf("raw entry was rewritten: %q", got) | |
| 241 | + } | |
| 242 | + } | |
| 243 | + | |
| 244 | + func TestCamelNamesTools(t *testing.T) { | |
| 245 | + for in, want := range map[string]string{ | |
| 246 | + "read_file": "ReadFile", | |
| 247 | + "bash": "Bash", | |
| 248 | + "edit_file": "EditFile", | |
| 249 | + "": "", | |
| 250 | + } { | |
| 251 | + if got := camel(in); got != want { | |
| 252 | + t.Errorf("camel(%q) = %q, want %q", in, got, want) | |
| 253 | + } | |
| 254 | + } | |
| 255 | + } | |
| 256 | + | |
| 257 | + func TestNewModelOpensWithTheWelcomeBlock(t *testing.T) { | |
| 258 | + m := newModel(context.Background(), Options{ | |
| 259 | + Client: llm.New(llm.WithAPIKey("x")), | |
| 260 | + Root: "/tmp/repo", | |
| 261 | + Policy: approval.New(nil), | |
| 262 | + Banner: []string{"resumed session abc"}, | |
| 263 | + }) | |
| 264 | + out := m.tr.render() | |
| 265 | + if !strings.Contains(out, "harness") { | |
| 266 | + t.Error("welcome block missing") | |
| 267 | + } | |
| 268 | + if i, j := strings.Index(out, "harness"), strings.Index(out, "resumed session abc"); i < 0 || j < 0 || i > j { | |
| 269 | + t.Error("the banner should follow the welcome block, not precede it") | |
| 270 | + } | |
| 271 | + } | |
| 272 | + | |
| 273 | + func TestDiffStatCountsEditsNotHeaders(t *testing.T) { | |
| 274 | + diff := "--- a/x.go\n+++ b/x.go\n@@ -1,2 +1,4 @@\n ctx\n-gone\n+one\n+two\n+three\n" | |
| 275 | + if got, want := diffStat(strings.Split(diff, "\n")), "3 additions, 1 removal"; got != want { | |
| 276 | + t.Errorf("diffStat = %q, want %q", got, want) | |
| 277 | + } | |
| 278 | + // The +++/--- file headers look like edits and are not. | |
| 279 | + if got := diffStat([]string{"--- a/x.go", "+++ b/x.go"}); got != "0 additions, 0 removals" { | |
| 280 | + t.Errorf("file headers counted as edits: %q", got) | |
| 281 | + } | |
| 282 | + | |
| 283 | + tr := newTranscript(80) | |
| 284 | + tr.startTool("edit_file", "path=x.go") | |
| 285 | + tr.finishTool("edit_file", diff, false) | |
| 286 | + if out := tr.render(); !strings.Contains(out, "3 additions, 1 removal") { | |
| 287 | + t.Errorf("diff rendered without its stat line:\n%s", out) | |
| 288 | + } | |
| 289 | + } | |
| 290 | + | |
| 291 | + // Cache reads are the number this harness exists to move, so they belong on | |
| 292 | + // the status bar rather than only in the metrics file. | |
| 293 | + func TestStatusReportsCacheReadsSeparately(t *testing.T) { | |
| 294 | + m := newTestModel(t) | |
| 295 | + m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) | |
| 296 | + m.Update(evUsage{usage: llm.Usage{InputTokens: 4_200, CacheReadInputTokens: 58_000, OutputTokens: 1_340}}) | |
| 297 | + | |
| 298 | + status := m.status() | |
| 299 | + for _, want := range []string{"4.2k", "58.0k", "1.3k"} { | |
| 300 | + if !strings.Contains(status, want) { | |
| 301 | + t.Errorf("%s missing from the status bar: %q", want, status) | |
| 302 | + } | |
| 303 | + } | |
| 304 | + | |
| 305 | + // With nothing cached there is no cache field to read past. | |
| 306 | + m2 := newTestModel(t) | |
| 307 | + m2.Update(evUsage{usage: llm.Usage{InputTokens: 900, OutputTokens: 100}}) | |
| 308 | + if strings.Contains(m2.status(), "⟳") { | |
| 309 | + t.Errorf("empty cache field still drawn: %q", m2.status()) | |
| 310 | + } | |
| 311 | + } | |
| @@ -0,0 +1,787 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "fmt" | |
| 7 | + "os" | |
| 8 | + "strings" | |
| 9 | + "time" | |
| 10 | + | |
| 11 | + "github.com/charmbracelet/bubbles/spinner" | |
| 12 | + "github.com/charmbracelet/bubbles/textarea" | |
| 13 | + "github.com/charmbracelet/bubbles/viewport" | |
| 14 | + tea "github.com/charmbracelet/bubbletea" | |
| 15 | + "github.com/charmbracelet/lipgloss" | |
| 16 | + | |
| 17 | + "harness/internal/agent" | |
| 18 | + "harness/internal/approval" | |
| 19 | + "harness/internal/llm" | |
| 20 | + "harness/internal/skills" | |
| 21 | + ) | |
| 22 | + | |
| 23 | + // Options wires the TUI to an already-built agent. The TUI owns no policy of | |
| 24 | + // its own: it renders what the agent reports and asks what the policy asks. | |
| 25 | + type Options struct { | |
| 26 | + Agent *agent.Agent | |
| 27 | + Policy *approval.Policy | |
| 28 | + Client *llm.Client | |
| 29 | + Root string | |
| 30 | + SessionDir string | |
| 31 | + SessionID string | |
| 32 | + ToolNames []string | |
| 33 | + Verbose bool | |
| 34 | + YOLO bool | |
| 35 | + // Version, Effort and ContextWindow are display only: they let the | |
| 36 | + // welcome block and the status bar report the run's shape rather than | |
| 37 | + // guess at it. | |
| 38 | + Version string | |
| 39 | + Effort string | |
| 40 | + ContextWindow int | |
| 41 | + // Banner is an optional first line of scrollback, used for things the CLI | |
| 42 | + // wants to say before the loop starts: a resumed session, a non-default | |
| 43 | + // endpoint. | |
| 44 | + Banner []string | |
| 45 | + // Skills are the instruction sets discovered at startup, used to resolve | |
| 46 | + // /<slug> and to answer /skills. | |
| 47 | + Skills []skills.Skill | |
| 48 | + } | |
| 49 | + | |
| 50 | + type phase int | |
| 51 | + | |
| 52 | + const ( | |
| 53 | + phaseIdle phase = iota | |
| 54 | + phaseRunning | |
| 55 | + phaseApproving | |
| 56 | + ) | |
| 57 | + | |
| 58 | + // inputMaxLines caps how far the prompt box grows before it scrolls. Past | |
| 59 | + // about six lines the box is eating the transcript it exists to talk about. | |
| 60 | + const inputMaxLines = 6 | |
| 61 | + | |
| 62 | + type model struct { | |
| 63 | + opts Options | |
| 64 | + bridge *bridge | |
| 65 | + | |
| 66 | + vp viewport.Model | |
| 67 | + input textarea.Model | |
| 68 | + spin spinner.Model | |
| 69 | + tr *transcript | |
| 70 | + | |
| 71 | + phase phase | |
| 72 | + width int | |
| 73 | + height int | |
| 74 | + ready bool | |
| 75 | + | |
| 76 | + // Turn bookkeeping. | |
| 77 | + turn int | |
| 78 | + started time.Time | |
| 79 | + usage llm.Usage | |
| 80 | + turns int | |
| 81 | + lastCost float64 | |
| 82 | + costKnown bool | |
| 83 | + // ctxTokens is the last turn's input size — what the model actually read, | |
| 84 | + // which is the number the context gauge is about. It replaces itself each | |
| 85 | + // turn rather than accumulating, and compaction makes it drop. | |
| 86 | + ctxTokens int | |
| 87 | + // verbSeed varies the working verb per turn so two turns in a row do not | |
| 88 | + // open with the same word. | |
| 89 | + verbSeed int | |
| 90 | + | |
| 91 | + // pending approvals. The agent blocks on one at a time, but a queue costs | |
| 92 | + // nothing and means a second request can never be dropped. | |
| 93 | + pending []approvalMsg | |
| 94 | + | |
| 95 | + // ctx plumbing for the running turn. | |
| 96 | + parent context.Context | |
| 97 | + cancel context.CancelFunc | |
| 98 | + | |
| 99 | + // interrupting records that the user asked to stop, so the status line can | |
| 100 | + // say so while the agent unwinds. | |
| 101 | + interrupting bool | |
| 102 | + | |
| 103 | + quitting bool | |
| 104 | + err error | |
| 105 | + } | |
| 106 | + | |
| 107 | + // pulse is the spinner: a star opening and closing rather than a rotating | |
| 108 | + // bar, which reads as thought rather than as a progress bar that is lying | |
| 109 | + // about knowing how far along it is. | |
| 110 | + var pulse = spinner.Spinner{ | |
| 111 | + Frames: []string{"✢", "✳", "∗", "✻", "✽", "✻", "∗", "✳"}, | |
| 112 | + FPS: time.Second / 8, | |
| 113 | + } | |
| 114 | + | |
| 115 | + // verbs are what the status line calls the wait. They rotate so a long turn | |
| 116 | + // does not look frozen even when nothing is streaming. | |
| 117 | + var verbs = []string{ | |
| 118 | + "Thinking", "Pondering", "Deliberating", "Noodling", "Percolating", | |
| 119 | + "Puzzling", "Wrangling", "Simmering", "Cogitating", "Tinkering", | |
| 120 | + "Untangling", "Ruminating", | |
| 121 | + } | |
| 122 | + | |
| 123 | + // newModel builds the model. Construction lives here rather than inside Run so | |
| 124 | + // tests drive the same wiring the program does; a hand-built model with a | |
| 125 | + // zero-valued textarea panics on the first resize. | |
| 126 | + func newModel(ctx context.Context, opts Options) *model { | |
| 127 | + ta := textarea.New() | |
| 128 | + ta.Placeholder = "ask for a change, or /help" | |
| 129 | + ta.Prompt = "› " | |
| 130 | + ta.ShowLineNumbers = false | |
| 131 | + ta.CharLimit = 0 | |
| 132 | + ta.SetHeight(1) | |
| 133 | + ta.FocusedStyle.CursorLine = lipgloss.NewStyle() | |
| 134 | + ta.FocusedStyle.Prompt = styCoral | |
| 135 | + ta.BlurredStyle.Prompt = styFaint | |
| 136 | + ta.FocusedStyle.Placeholder = styFaint | |
| 137 | + ta.BlurredStyle.Placeholder = styFaint | |
| 138 | + ta.Focus() | |
| 139 | + // Enter submits; the model handles it before the textarea sees it. | |
| 140 | + ta.KeyMap.InsertNewline.SetKeys("ctrl+j", "alt+enter") | |
| 141 | + | |
| 142 | + sp := spinner.New() | |
| 143 | + sp.Spinner = pulse | |
| 144 | + sp.Style = styCoral | |
| 145 | + | |
| 146 | + m := &model{ | |
| 147 | + opts: opts, | |
| 148 | + bridge: &bridge{}, | |
| 149 | + input: ta, | |
| 150 | + spin: sp, | |
| 151 | + tr: newTranscript(80), | |
| 152 | + parent: ctx, | |
| 153 | + } | |
| 154 | + m.tr.setVerbose(opts.Verbose) | |
| 155 | + m.tr.addRaw(splash(opts, 80)) | |
| 156 | + for _, line := range opts.Banner { | |
| 157 | + m.tr.addNotice(line) | |
| 158 | + } | |
| 159 | + if opts.YOLO { | |
| 160 | + m.tr.addNotice("-yolo — no approval prompts. Use a throwaway clone or a container.") | |
| 161 | + } | |
| 162 | + return m | |
| 163 | + } | |
| 164 | + | |
| 165 | + // Run starts the program and blocks until the user exits. | |
| 166 | + func Run(ctx context.Context, opts Options) error { | |
| 167 | + m := newModel(ctx, opts) | |
| 168 | + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithContext(ctx)) | |
| 169 | + | |
| 170 | + // The bridge can only learn the program after it exists; the agent does | |
| 171 | + // not touch it until the first turn starts, which is after Run. | |
| 172 | + m.bridge.p = p | |
| 173 | + opts.Agent.Observe = m.bridge.Observe | |
| 174 | + if opts.Policy != nil { | |
| 175 | + opts.Policy.Prompt = m.bridge.Prompt | |
| 176 | + } | |
| 177 | + | |
| 178 | + final, err := p.Run() | |
| 179 | + if err != nil && !errors.Is(err, tea.ErrProgramKilled) && !errors.Is(err, context.Canceled) { | |
| 180 | + return err | |
| 181 | + } | |
| 182 | + if fm, ok := final.(*model); ok { | |
| 183 | + return fm.err | |
| 184 | + } | |
| 185 | + return nil | |
| 186 | + } | |
| 187 | + | |
| 188 | + func (m *model) Init() tea.Cmd { return textarea.Blink } | |
| 189 | + | |
| 190 | + // runTurn drives one prompt on its own goroutine. Events reach the UI through | |
| 191 | + // the bridge while this runs; the command's return value ends the turn. | |
| 192 | + func (m *model) runTurn(prompt string) tea.Cmd { | |
| 193 | + ctx, cancel := context.WithCancel(m.parent) | |
| 194 | + m.cancel = cancel | |
| 195 | + | |
| 196 | + a := m.opts.Agent | |
| 197 | + return func() tea.Msg { | |
| 198 | + _, err := a.Run(ctx, prompt) | |
| 199 | + cancel() | |
| 200 | + | |
| 201 | + if saveErr := a.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.Root, m.opts.Client.Model); saveErr != nil && err == nil { | |
| 202 | + err = fmt.Errorf("saving session: %w", saveErr) | |
| 203 | + } | |
| 204 | + u, turns := a.State().Usage() | |
| 205 | + return turnDone{err: err, usage: u, turns: turns} | |
| 206 | + } | |
| 207 | + } | |
| 208 | + | |
| 209 | + func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 210 | + switch msg := msg.(type) { | |
| 211 | + | |
| 212 | + case tea.WindowSizeMsg: | |
| 213 | + return m, m.resize(msg.Width, msg.Height) | |
| 214 | + | |
| 215 | + case tea.KeyMsg: | |
| 216 | + return m.onKey(msg) | |
| 217 | + | |
| 218 | + case spinner.TickMsg: | |
| 219 | + if m.phase == phaseRunning { | |
| 220 | + var cmd tea.Cmd | |
| 221 | + m.spin, cmd = m.spin.Update(msg) | |
| 222 | + return m, cmd | |
| 223 | + } | |
| 224 | + return m, nil | |
| 225 | + | |
| 226 | + // ---- agent events ------------------------------------------------- | |
| 227 | + | |
| 228 | + case evTurnStart: | |
| 229 | + m.turn = msg.turn | |
| 230 | + return m, nil | |
| 231 | + | |
| 232 | + case evTextDelta: | |
| 233 | + m.tr.appendDelta(msg.text) | |
| 234 | + return m, m.sync() | |
| 235 | + | |
| 236 | + case evText: | |
| 237 | + m.tr.add(entry{kind: entryAssistant, text: strings.TrimSpace(msg.text)}) | |
| 238 | + return m, m.sync() | |
| 239 | + | |
| 240 | + case evToolStart: | |
| 241 | + m.tr.startTool(msg.tool, msg.input) | |
| 242 | + return m, m.sync() | |
| 243 | + | |
| 244 | + case evToolResult: | |
| 245 | + m.tr.finishTool(msg.tool, msg.result, msg.isError) | |
| 246 | + return m, m.sync() | |
| 247 | + | |
| 248 | + case evUsage: | |
| 249 | + m.usage = addUsage(m.usage, msg.usage) | |
| 250 | + m.ctxTokens = msg.usage.InputTokens + msg.usage.CacheReadInputTokens + msg.usage.CacheCreationInputTokens | |
| 251 | + return m, nil | |
| 252 | + | |
| 253 | + case evCompact: | |
| 254 | + m.tr.addNotice("compacted the conversation") | |
| 255 | + return m, m.sync() | |
| 256 | + | |
| 257 | + case approvalMsg: | |
| 258 | + m.pending = append(m.pending, msg) | |
| 259 | + m.phase = phaseApproving | |
| 260 | + return m, m.sync() | |
| 261 | + | |
| 262 | + case turnDone: | |
| 263 | + return m, m.finishTurn(msg) | |
| 264 | + } | |
| 265 | + | |
| 266 | + return m, nil | |
| 267 | + } | |
| 268 | + | |
| 269 | + func (m *model) resize(w, h int) tea.Cmd { | |
| 270 | + m.width, m.height = w, h | |
| 271 | + m.tr.setWidth(w) | |
| 272 | + m.input.SetWidth(m.innerWidth()) | |
| 273 | + | |
| 274 | + if !m.ready { | |
| 275 | + m.vp = viewport.New(w, maxInt(1, h-8)) | |
| 276 | + m.ready = true | |
| 277 | + } else { | |
| 278 | + m.vp.Width = w | |
| 279 | + } | |
| 280 | + // The welcome block is laid out for a width, so it is re-rendered rather | |
| 281 | + // than wrapped: it is the one entry that owns its own lines. | |
| 282 | + if len(m.tr.entries) > 0 && m.tr.entries[0].kind == entryRaw { | |
| 283 | + m.tr.entries[0].text = splash(m.opts, w) | |
| 284 | + m.tr.dirty = true | |
| 285 | + } | |
| 286 | + m.vp.SetContent(m.tr.render()) | |
| 287 | + m.vp.GotoBottom() | |
| 288 | + return nil | |
| 289 | + } | |
| 290 | + | |
| 291 | + // sync re-renders the scrollback, keeping the view pinned to the bottom unless | |
| 292 | + // the user has scrolled up to read something. | |
| 293 | + func (m *model) sync() tea.Cmd { | |
| 294 | + if !m.ready { | |
| 295 | + return nil | |
| 296 | + } | |
| 297 | + atBottom := m.vp.AtBottom() | |
| 298 | + m.vp.SetContent(m.tr.render()) | |
| 299 | + if atBottom { | |
| 300 | + m.vp.GotoBottom() | |
| 301 | + } | |
| 302 | + return nil | |
| 303 | + } | |
| 304 | + | |
| 305 | + func (m *model) finishTurn(msg turnDone) tea.Cmd { | |
| 306 | + m.phase = phaseIdle | |
| 307 | + m.interrupting = false | |
| 308 | + m.usage, m.turns = msg.usage, msg.turns | |
| 309 | + m.lastCost, m.costKnown = msg.usage.Cost(m.opts.Client.Model) | |
| 310 | + m.tr.closeOpen() | |
| 311 | + | |
| 312 | + // Any approval still outstanding belongs to a turn that no longer exists. | |
| 313 | + for _, p := range m.pending { | |
| 314 | + p.reply <- approval.Deny | |
| 315 | + } | |
| 316 | + m.pending = nil | |
| 317 | + | |
| 318 | + switch { | |
| 319 | + case msg.err == nil: | |
| 320 | + case errors.Is(msg.err, context.Canceled): | |
| 321 | + m.tr.addNotice("interrupted — the session is intact; say what to do differently") | |
| 322 | + default: | |
| 323 | + m.tr.addError(msg.err.Error()) | |
| 324 | + } | |
| 325 | + m.input.Focus() | |
| 326 | + return tea.Batch(m.sync(), textarea.Blink) | |
| 327 | + } | |
| 328 | + | |
| 329 | + func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |
| 330 | + if m.phase == phaseApproving { | |
| 331 | + return m.onApprovalKey(msg) | |
| 332 | + } | |
| 333 | + | |
| 334 | + switch msg.String() { | |
| 335 | + case "ctrl+c": | |
| 336 | + // While a turn runs, ctrl-c interrupts it and leaves the session | |
| 337 | + // intact. Idle, it exits. | |
| 338 | + if m.phase == phaseRunning { | |
| 339 | + return m, m.interrupt() | |
| 340 | + } | |
| 341 | + m.quitting = true | |
| 342 | + return m, tea.Quit | |
| 343 | + | |
| 344 | + case "esc": | |
| 345 | + if m.phase == phaseRunning { | |
| 346 | + return m, m.interrupt() | |
| 347 | + } | |
| 348 | + return m, nil | |
| 349 | + | |
| 350 | + case "ctrl+d": | |
| 351 | + m.quitting = true | |
| 352 | + return m, tea.Quit | |
| 353 | + | |
| 354 | + case "ctrl+r": | |
| 355 | + m.tr.setVerbose(!m.tr.verbose) | |
| 356 | + state := "collapsed" | |
| 357 | + if m.tr.verbose { | |
| 358 | + state = "full" | |
| 359 | + } | |
| 360 | + m.tr.addNotice("tool output: " + state) | |
| 361 | + return m, m.sync() | |
| 362 | + | |
| 363 | + case "shift+tab": | |
| 364 | + return m, m.cycleMode() | |
| 365 | + | |
| 366 | + case "pgup", "pgdown", "shift+up", "shift+down", "ctrl+home", "ctrl+end": | |
| 367 | + var cmd tea.Cmd | |
| 368 | + m.vp, cmd = m.vp.Update(msg) | |
| 369 | + return m, cmd | |
| 370 | + | |
| 371 | + case "enter": | |
| 372 | + if m.phase == phaseRunning { | |
| 373 | + return m, nil // the agent is busy; queueing input would be a lie | |
| 374 | + } | |
| 375 | + line := strings.TrimSpace(m.input.Value()) | |
| 376 | + if line == "" { | |
| 377 | + return m, nil | |
| 378 | + } | |
| 379 | + m.input.Reset() | |
| 380 | + m.growInput() | |
| 381 | + return m.submit(line) | |
| 382 | + } | |
| 383 | + | |
| 384 | + var cmd tea.Cmd | |
| 385 | + m.input, cmd = m.input.Update(msg) | |
| 386 | + m.growInput() | |
| 387 | + return m, cmd | |
| 388 | + } | |
| 389 | + | |
| 390 | + func (m *model) interrupt() tea.Cmd { | |
| 391 | + if m.cancel != nil { | |
| 392 | + m.cancel() | |
| 393 | + } | |
| 394 | + m.interrupting = true | |
| 395 | + return nil | |
| 396 | + } | |
| 397 | + | |
| 398 | + // cycleMode flips the approval policy between asking and accepting. It is the | |
| 399 | + // one control in the chrome that changes what the agent is allowed to do, so | |
| 400 | + // it says so in the transcript rather than only in the mode line. | |
| 401 | + func (m *model) cycleMode() tea.Cmd { | |
| 402 | + if m.opts.Policy == nil { | |
| 403 | + return nil | |
| 404 | + } | |
| 405 | + m.opts.Policy.YOLO = !m.opts.Policy.YOLO | |
| 406 | + if m.opts.Policy.YOLO { | |
| 407 | + m.tr.addNotice("accept-all on — tool calls run without asking. Use a throwaway clone or a container.") | |
| 408 | + } else { | |
| 409 | + m.tr.addNotice("accept-all off — you will be asked before anything is changed.") | |
| 410 | + } | |
| 411 | + return m.sync() | |
| 412 | + } | |
| 413 | + | |
| 414 | + // growInput sizes the prompt box to what has been typed into it. | |
| 415 | + func (m *model) growInput() { | |
| 416 | + inner := m.innerWidth() | |
| 417 | + n := 0 | |
| 418 | + for _, line := range strings.Split(m.input.Value(), "\n") { | |
| 419 | + w := lipgloss.Width(line) + 2 // the "› " prompt | |
| 420 | + rows := (w + inner - 1) / inner | |
| 421 | + n += maxInt(1, rows) | |
| 422 | + } | |
| 423 | + if h := minInt(maxInt(1, n), inputMaxLines); h != m.input.Height() { | |
| 424 | + m.input.SetHeight(h) | |
| 425 | + } | |
| 426 | + } | |
| 427 | + | |
| 428 | + // submit handles slash commands locally and sends anything else to the agent. | |
| 429 | + func (m *model) submit(line string) (tea.Model, tea.Cmd) { | |
| 430 | + switch { | |
| 431 | + case line == "/exit", line == "/quit": | |
| 432 | + m.quitting = true | |
| 433 | + return m, tea.Quit | |
| 434 | + | |
| 435 | + case line == "/help": | |
| 436 | + m.tr.addNotice(helpText) | |
| 437 | + return m, m.sync() | |
| 438 | + | |
| 439 | + case line == "/clear": | |
| 440 | + m.tr.clear() | |
| 441 | + return m, m.sync() | |
| 442 | + | |
| 443 | + case line == "/allowed": | |
| 444 | + allowed := m.opts.Policy.Remembered() | |
| 445 | + if len(allowed) == 0 { | |
| 446 | + m.tr.addNotice("nothing is permanently allowed this session") | |
| 447 | + } else { | |
| 448 | + m.tr.addNotice("always allowed:\n " + strings.Join(allowed, "\n ")) | |
| 449 | + } | |
| 450 | + return m, m.sync() | |
| 451 | + | |
| 452 | + case line == "/skills": | |
| 453 | + if len(m.opts.Skills) == 0 { | |
| 454 | + m.tr.addNotice("no skills found in ~/" + skills.Dirname + " or ./" + skills.Dirname) | |
| 455 | + } else { | |
| 456 | + var b strings.Builder | |
| 457 | + b.WriteString("skills:") | |
| 458 | + for _, s := range m.opts.Skills { | |
| 459 | + fmt.Fprintf(&b, "\n /%s (%s) — %s", s.Name, s.Source, s.Description) | |
| 460 | + } | |
| 461 | + m.tr.addNotice(b.String()) | |
| 462 | + } | |
| 463 | + return m, m.sync() | |
| 464 | + | |
| 465 | + case strings.HasPrefix(line, "/") && !m.isSkill(line): | |
| 466 | + m.tr.addError("unknown command " + line + " — /help lists them") | |
| 467 | + return m, m.sync() | |
| 468 | + } | |
| 469 | + | |
| 470 | + m.tr.addUser(line) | |
| 471 | + m.phase = phaseRunning | |
| 472 | + m.turn = 0 | |
| 473 | + m.started = time.Now() | |
| 474 | + m.verbSeed++ | |
| 475 | + return m, tea.Batch(m.sync(), m.spin.Tick, m.runTurn(line)) | |
| 476 | + } | |
| 477 | + | |
| 478 | + // isSkill reports whether a slash command names a discovered skill. Matching | |
| 479 | + // here rather than sending every unrecognised slash to the model means a typo | |
| 480 | + // is caught by the harness instead of puzzled over by the agent. | |
| 481 | + func (m *model) isSkill(line string) bool { | |
| 482 | + name := strings.TrimPrefix(line, "/") | |
| 483 | + if i := strings.IndexAny(name, " \t"); i >= 0 { | |
| 484 | + name = name[:i] | |
| 485 | + } | |
| 486 | + for _, s := range m.opts.Skills { | |
| 487 | + if s.Name == name { | |
| 488 | + return true | |
| 489 | + } | |
| 490 | + } | |
| 491 | + return false | |
| 492 | + } | |
| 493 | + | |
| 494 | + // ---- approvals ---------------------------------------------------------- | |
| 495 | + | |
| 496 | + // choice is one answer offered by the approval box. Numbering it here rather | |
| 497 | + // than in the renderer keeps the keys and the labels from drifting apart. | |
| 498 | + type choice struct { | |
| 499 | + label string | |
| 500 | + keys []string | |
| 501 | + dec approval.Decision | |
| 502 | + note string | |
| 503 | + } | |
| 504 | + | |
| 505 | + func (m *model) choices(req approval.Request) []choice { | |
| 506 | + out := []choice{{label: "Yes", keys: []string{"y", "enter"}, dec: approval.Once, note: "approved once"}} | |
| 507 | + if req.Rememberable { | |
| 508 | + out = append(out, choice{ | |
| 509 | + label: "Yes, and always this exact one", | |
| 510 | + keys: []string{"a"}, | |
| 511 | + dec: approval.AlwaysThisCommand, | |
| 512 | + note: "always allowing this exact " + req.Tool + " this session", | |
| 513 | + }) | |
| 514 | + } | |
| 515 | + out = append(out, | |
| 516 | + choice{ | |
| 517 | + label: "Yes, and always this tool", | |
| 518 | + keys: []string{"t"}, | |
| 519 | + dec: approval.AlwaysThisTool, | |
| 520 | + note: "always allowing tool " + req.Tool + " this session", | |
| 521 | + }, | |
| 522 | + choice{label: "No, tell it to do something else", keys: []string{"n", "esc"}, dec: approval.Deny, note: "denied"}, | |
| 523 | + ) | |
| 524 | + return out | |
| 525 | + } | |
| 526 | + | |
| 527 | + func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |
| 528 | + if len(m.pending) == 0 { | |
| 529 | + m.phase = phaseRunning | |
| 530 | + return m, nil | |
| 531 | + } | |
| 532 | + req := m.pending[0] | |
| 533 | + key := msg.String() | |
| 534 | + | |
| 535 | + // ctrl-c mid-prompt denies the call and cancels the turn. | |
| 536 | + if key == "ctrl+c" { | |
| 537 | + req.reply <- approval.Deny | |
| 538 | + m.pending = m.pending[1:] | |
| 539 | + m.phase = phaseRunning | |
| 540 | + return m, tea.Batch(m.interrupt(), m.sync()) | |
| 541 | + } | |
| 542 | + | |
| 543 | + // "always this exact one" is not offered for an opaque command, and | |
| 544 | + // pressing its key must explain that rather than silently doing nothing. | |
| 545 | + opts := m.choices(req.req) | |
| 546 | + if key == "a" && !req.req.Rememberable { | |
| 547 | + m.tr.addError("this one cannot be remembered: its text does not determine what it runs") | |
| 548 | + return m, m.sync() | |
| 549 | + } | |
| 550 | + | |
| 551 | + for i, c := range opts { | |
| 552 | + if !matches(c.keys, key) && key != fmt.Sprint(i+1) { | |
| 553 | + continue | |
| 554 | + } | |
| 555 | + req.reply <- c.dec | |
| 556 | + m.pending = m.pending[1:] | |
| 557 | + if len(m.pending) == 0 { | |
| 558 | + m.phase = phaseRunning | |
| 559 | + } | |
| 560 | + // The decision belongs to the call it gated, not to whatever the | |
| 561 | + // transcript happens to end with once the result lands. | |
| 562 | + m.tr.annotateTool(req.req.Tool, c.note) | |
| 563 | + return m, tea.Batch(m.sync(), m.spin.Tick) | |
| 564 | + } | |
| 565 | + return m, nil | |
| 566 | + } | |
| 567 | + | |
| 568 | + func matches(keys []string, key string) bool { | |
| 569 | + for _, k := range keys { | |
| 570 | + if k == key { | |
| 571 | + return true | |
| 572 | + } | |
| 573 | + } | |
| 574 | + return false | |
| 575 | + } | |
| 576 | + | |
| 577 | + // ---- view --------------------------------------------------------------- | |
| 578 | + | |
| 579 | + const helpText = `commands: /allowed /clear /help /skills /exit | |
| 580 | + keys: enter send · ctrl+j newline · shift+tab cycle approval mode | |
| 581 | + ctrl+r toggle full tool output · pgup/pgdn scroll | |
| 582 | + esc or ctrl+c interrupt a turn · ctrl+c again to exit` | |
| 583 | + | |
| 584 | + func (m *model) View() string { | |
| 585 | + if !m.ready { | |
| 586 | + return "starting…" | |
| 587 | + } | |
| 588 | + if m.quitting { | |
| 589 | + return "" | |
| 590 | + } | |
| 591 | + | |
| 592 | + // The bottom chrome changes height — the prompt box grows, the approval | |
| 593 | + // box is taller than either — so the viewport is sized against what was | |
| 594 | + // actually rendered rather than against an assumed number of rows. | |
| 595 | + bottom := m.bottom() | |
| 596 | + if h := maxInt(1, m.height-lipgloss.Height(bottom)); h != m.vp.Height { | |
| 597 | + atBottom := m.vp.AtBottom() | |
| 598 | + m.vp.Height = h | |
| 599 | + if atBottom { | |
| 600 | + m.vp.GotoBottom() | |
| 601 | + } | |
| 602 | + } | |
| 603 | + return m.vp.View() + "\n" + bottom | |
| 604 | + } | |
| 605 | + | |
| 606 | + func (m *model) bottom() string { | |
| 607 | + var b strings.Builder | |
| 608 | + b.WriteString(m.activity() + "\n") | |
| 609 | + if m.phase == phaseApproving && len(m.pending) > 0 { | |
| 610 | + b.WriteString(m.approvalView() + "\n") | |
| 611 | + } else { | |
| 612 | + b.WriteString(m.inputBox() + "\n") | |
| 613 | + } | |
| 614 | + b.WriteString(m.status() + "\n") | |
| 615 | + b.WriteString(m.modeLine()) | |
| 616 | + return b.String() | |
| 617 | + } | |
| 618 | + | |
| 619 | + // activity is the line above the prompt: what the agent is doing on the left, | |
| 620 | + // the effort setting on the right. | |
| 621 | + func (m *model) activity() string { | |
| 622 | + var left string | |
| 623 | + switch m.phase { | |
| 624 | + case phaseRunning: | |
| 625 | + verb := verbs[(m.verbSeed+int(time.Since(m.started).Seconds())/4)%len(verbs)] | |
| 626 | + if m.interrupting { | |
| 627 | + verb = "Interrupting" | |
| 628 | + } | |
| 629 | + detail := fmt.Sprintf("%ds", int(time.Since(m.started).Seconds())) | |
| 630 | + if m.turn > 0 { | |
| 631 | + detail += fmt.Sprintf(" · turn %d", m.turn) | |
| 632 | + } | |
| 633 | + if m.usage.OutputTokens > 0 { | |
| 634 | + detail += " · ↓ " + human(m.usage.OutputTokens) + " tokens" | |
| 635 | + } | |
| 636 | + left = m.spin.View() + " " + styCoral.Render(verb+"…") + styDim.Render(" ("+detail+" · esc to interrupt)") | |
| 637 | + case phaseApproving: | |
| 638 | + left = styWarn.Render("● waiting for you") | |
| 639 | + } | |
| 640 | + | |
| 641 | + right := "" | |
| 642 | + if m.opts.Effort != "" { | |
| 643 | + right = styDim.Render("● "+m.opts.Effort) + styFaint.Render(" · /effort") | |
| 644 | + } | |
| 645 | + return spread(left, right, m.width) | |
| 646 | + } | |
| 647 | + | |
| 648 | + func (m *model) innerWidth() int { return maxInt(8, m.boxWidth()-4) } | |
| 649 | + | |
| 650 | + // boxWidth keeps the prompt frame usable on a narrow terminal rather than | |
| 651 | + // letting it collapse into a border with no room inside it. | |
| 652 | + func (m *model) boxWidth() int { return maxInt(14, m.width) } | |
| 653 | + | |
| 654 | + func (m *model) inputBox() string { | |
| 655 | + style := styBoxFocus | |
| 656 | + if m.phase == phaseRunning { | |
| 657 | + style = styBoxBlur | |
| 658 | + } | |
| 659 | + return style.Width(m.boxWidth() - 2).Render(m.input.View()) | |
| 660 | + } | |
| 661 | + | |
| 662 | + // status is the pill line under the prompt: who, which model, how full the | |
| 663 | + // context is, and what it has cost so far. The fields are separated by space | |
| 664 | + // rather than by a dot, because the pills already carry their own padding and | |
| 665 | + // a dot between them reads as a gap twice as wide as it is. | |
| 666 | + func (m *model) status() string { | |
| 667 | + parts := []string{ | |
| 668 | + styName.Render(username()), | |
| 669 | + styCoral.Render("✳ ") + styDim.Render(prettyModel(m.opts.Client.Model)), | |
| 670 | + } | |
| 671 | + | |
| 672 | + if pill, ok := m.contextPill(); ok { | |
| 673 | + parts = append(parts, pill) | |
| 674 | + } | |
| 675 | + | |
| 676 | + // Cache reads are reported separately from fresh input: the gap between | |
| 677 | + // the two is the whole point of the caching this harness measures. | |
| 678 | + u := m.usage | |
| 679 | + if u.InputTokens+u.OutputTokens+u.CacheReadInputTokens > 0 { | |
| 680 | + io := fmt.Sprintf("↑ %s", human(u.InputTokens)) | |
| 681 | + if u.CacheReadInputTokens > 0 { | |
| 682 | + io += fmt.Sprintf(" ⟳ %s", human(u.CacheReadInputTokens)) | |
| 683 | + } | |
| 684 | + io += fmt.Sprintf(" ↓ %s", human(u.OutputTokens)) | |
| 685 | + parts = append(parts, styDim.Render(io)) | |
| 686 | + if cost, known := u.Cost(m.opts.Client.Model); known { | |
| 687 | + parts = append(parts, styDim.Render(fmt.Sprintf("$%.4f", cost))) | |
| 688 | + } | |
| 689 | + } | |
| 690 | + return truncateStyled(strings.Join(parts, " "), maxInt(1, m.width)) | |
| 691 | + } | |
| 692 | + | |
| 693 | + // contextPill is the context gauge: how much the model read last turn against | |
| 694 | + // the window it has, coloured by how close that is to full. | |
| 695 | + // | |
| 696 | + // It is one style over the whole pill rather than a coloured glyph inside a | |
| 697 | + // background, because a nested style ends with a reset — which drops the | |
| 698 | + // background for the rest of the pill and leaves a visible tear in it. | |
| 699 | + func (m *model) contextPill() (string, bool) { | |
| 700 | + if m.ctxTokens <= 0 { | |
| 701 | + return "", false | |
| 702 | + } | |
| 703 | + pct := m.ctxTokens * 100 / m.contextWindow() | |
| 704 | + gauge := colOK | |
| 705 | + switch { | |
| 706 | + case pct >= 80: | |
| 707 | + gauge = colErr | |
| 708 | + case pct >= 60: | |
| 709 | + gauge = colWarn | |
| 710 | + } | |
| 711 | + return styPill.Foreground(gauge).Render(fmt.Sprintf("◐ %s (%d%%)", human(m.ctxTokens), pct)), true | |
| 712 | + } | |
| 713 | + | |
| 714 | + func (m *model) contextWindow() int { | |
| 715 | + if m.opts.ContextWindow > 0 { | |
| 716 | + return m.opts.ContextWindow | |
| 717 | + } | |
| 718 | + return agent.DefaultContextWindow | |
| 719 | + } | |
| 720 | + | |
| 721 | + // modeLine reports what the approval policy will do next, because a mode that | |
| 722 | + // silently lets the agent write to disk has to be visible at all times. | |
| 723 | + func (m *model) modeLine() string { | |
| 724 | + if m.phase == phaseApproving { | |
| 725 | + return "" | |
| 726 | + } | |
| 727 | + if m.opts.Policy != nil && m.opts.Policy.YOLO { | |
| 728 | + return truncateStyled(" "+styWarn.Render("⏵⏵ accept-all on")+styFaint.Render(" (shift+tab to cycle)"), maxInt(1, m.width)) | |
| 729 | + } | |
| 730 | + return truncateStyled(" "+styFaint.Render("⏵ approvals on (shift+tab to cycle) · /help for commands"), maxInt(1, m.width)) | |
| 731 | + } | |
| 732 | + | |
| 733 | + func (m *model) approvalView() string { | |
| 734 | + req := m.pending[0].req | |
| 735 | + width := maxInt(20, m.width-2) | |
| 736 | + inner := width - 4 | |
| 737 | + | |
| 738 | + var b strings.Builder | |
| 739 | + b.WriteString(styHeading.Render(req.Tool) + styDim.Render(" wants to "+req.Summary) + "\n") | |
| 740 | + if req.Detail != "" { | |
| 741 | + b.WriteString("\n") | |
| 742 | + lines := strings.Split(strings.TrimRight(req.Detail, "\n"), "\n") | |
| 743 | + for _, line := range clip(lines, 10) { | |
| 744 | + b.WriteString(styText.Render(truncate(line, maxInt(10, inner))) + "\n") | |
| 745 | + } | |
| 746 | + if n := len(lines) - 10; n > 0 { | |
| 747 | + b.WriteString(styFaint.Render("… +"+plural(n, "more line")) + "\n") | |
| 748 | + } | |
| 749 | + } | |
| 750 | + b.WriteString("\n" + styDim.Render("Do you want to proceed?") + "\n") | |
| 751 | + for i, c := range m.choices(req) { | |
| 752 | + b.WriteString(styKey.Render(fmt.Sprint(i+1)+".") + " " + styText.Render(c.label)) | |
| 753 | + if alias := c.keys[0]; alias != "enter" { | |
| 754 | + b.WriteString(styFaint.Render(" (" + alias + ")")) | |
| 755 | + } | |
| 756 | + b.WriteString("\n") | |
| 757 | + } | |
| 758 | + return styModal.Width(width - 2).Render(strings.TrimRight(b.String(), "\n")) | |
| 759 | + } | |
| 760 | + | |
| 761 | + // spread puts left at the left margin and right at the right, and gives up on | |
| 762 | + // the right-hand side rather than overflowing when they cannot both fit. | |
| 763 | + func spread(left, right string, width int) string { | |
| 764 | + width = maxInt(1, width) | |
| 765 | + lw, rw := lipgloss.Width(left), lipgloss.Width(right) | |
| 766 | + if gap := width - lw - rw - 1; gap >= 1 { | |
| 767 | + return " " + left + strings.Repeat(" ", gap) + right | |
| 768 | + } | |
| 769 | + return truncateStyled(" "+left, width) | |
| 770 | + } | |
| 771 | + | |
| 772 | + func username() string { | |
| 773 | + for _, k := range []string{"USER", "LOGNAME", "USERNAME"} { | |
| 774 | + if v := os.Getenv(k); v != "" { | |
| 775 | + return v | |
| 776 | + } | |
| 777 | + } | |
| 778 | + return "you" | |
| 779 | + } | |
| 780 | + | |
| 781 | + func addUsage(a, b llm.Usage) llm.Usage { | |
| 782 | + a.InputTokens += b.InputTokens | |
| 783 | + a.OutputTokens += b.OutputTokens | |
| 784 | + a.CacheReadInputTokens += b.CacheReadInputTokens | |
| 785 | + a.CacheCreationInputTokens += b.CacheCreationInputTokens | |
| 786 | + return a | |
| 787 | + } | |
| @@ -0,0 +1,419 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "strings" | |
| 6 | + "testing" | |
| 7 | + | |
| 8 | + tea "github.com/charmbracelet/bubbletea" | |
| 9 | + | |
| 10 | + "harness/internal/approval" | |
| 11 | + "harness/internal/llm" | |
| 12 | + "harness/internal/skills" | |
| 13 | + ) | |
| 14 | + | |
| 15 | + // newTestModel builds a model far enough to drive Update/View without a | |
| 16 | + // terminal or a live agent. | |
| 17 | + func newTestModel(t *testing.T) *model { | |
| 18 | + t.Helper() | |
| 19 | + m := newModel(context.Background(), Options{ | |
| 20 | + Client: llm.New(llm.WithAPIKey("x")), | |
| 21 | + Root: "/tmp/repo", | |
| 22 | + Policy: approval.New(nil), | |
| 23 | + }) | |
| 24 | + m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) | |
| 25 | + return m | |
| 26 | + } | |
| 27 | + | |
| 28 | + func key(s string) tea.KeyMsg { | |
| 29 | + switch s { | |
| 30 | + case "enter": | |
| 31 | + return tea.KeyMsg{Type: tea.KeyEnter} | |
| 32 | + case "esc": | |
| 33 | + return tea.KeyMsg{Type: tea.KeyEsc} | |
| 34 | + case "ctrl+c": | |
| 35 | + return tea.KeyMsg{Type: tea.KeyCtrlC} | |
| 36 | + case "ctrl+r": | |
| 37 | + return tea.KeyMsg{Type: tea.KeyCtrlR} | |
| 38 | + case "shift+tab": | |
| 39 | + return tea.KeyMsg{Type: tea.KeyShiftTab} | |
| 40 | + } | |
| 41 | + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} | |
| 42 | + } | |
| 43 | + | |
| 44 | + // pend queues an approval the way the bridge would, and returns the channel | |
| 45 | + // the model is expected to answer on. | |
| 46 | + func pend(m *model, req approval.Request) chan approval.Decision { | |
| 47 | + reply := make(chan approval.Decision, 1) | |
| 48 | + m.Update(approvalMsg{req: req, reply: reply}) | |
| 49 | + return reply | |
| 50 | + } | |
| 51 | + | |
| 52 | + func TestApprovalKeysMapToDecisions(t *testing.T) { | |
| 53 | + cases := []struct { | |
| 54 | + key string | |
| 55 | + want approval.Decision | |
| 56 | + }{ | |
| 57 | + {"y", approval.Once}, | |
| 58 | + {"enter", approval.Once}, | |
| 59 | + {"n", approval.Deny}, | |
| 60 | + {"esc", approval.Deny}, | |
| 61 | + {"t", approval.AlwaysThisTool}, | |
| 62 | + {"a", approval.AlwaysThisCommand}, | |
| 63 | + } | |
| 64 | + for _, tc := range cases { | |
| 65 | + t.Run(tc.key, func(t *testing.T) { | |
| 66 | + m := newTestModel(t) | |
| 67 | + reply := pend(m, approval.Request{Tool: "bash", Rememberable: true}) | |
| 68 | + if m.phase != phaseApproving { | |
| 69 | + t.Fatalf("phase = %v, want phaseApproving", m.phase) | |
| 70 | + } | |
| 71 | + | |
| 72 | + m.Update(key(tc.key)) | |
| 73 | + | |
| 74 | + select { | |
| 75 | + case got := <-reply: | |
| 76 | + if got != tc.want { | |
| 77 | + t.Errorf("decision = %v, want %v", got, tc.want) | |
| 78 | + } | |
| 79 | + default: | |
| 80 | + t.Fatal("no decision was sent") | |
| 81 | + } | |
| 82 | + if m.phase != phaseRunning { | |
| 83 | + t.Errorf("phase = %v after deciding, want phaseRunning", m.phase) | |
| 84 | + } | |
| 85 | + }) | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + // "always this exact one" is refused for an opaque command, and crucially the | |
| 90 | + // agent stays blocked rather than being silently approved or denied. | |
| 91 | + func TestAlwaysThisCommandRefusedWhenNotRememberable(t *testing.T) { | |
| 92 | + m := newTestModel(t) | |
| 93 | + reply := pend(m, approval.Request{Tool: "bash", Rememberable: false}) | |
| 94 | + | |
| 95 | + m.Update(key("a")) | |
| 96 | + | |
| 97 | + select { | |
| 98 | + case got := <-reply: | |
| 99 | + t.Fatalf("sent %v for an unrememberable command; want the prompt to stay open", got) | |
| 100 | + default: | |
| 101 | + } | |
| 102 | + if m.phase != phaseApproving { | |
| 103 | + t.Errorf("phase = %v, want the prompt still open", m.phase) | |
| 104 | + } | |
| 105 | + if !strings.Contains(m.tr.render(), "cannot be remembered") { | |
| 106 | + t.Error("no explanation shown for the refusal") | |
| 107 | + } | |
| 108 | + | |
| 109 | + // It must still be answerable. | |
| 110 | + m.Update(key("y")) | |
| 111 | + if got := <-reply; got != approval.Once { | |
| 112 | + t.Errorf("decision = %v, want Once", got) | |
| 113 | + } | |
| 114 | + } | |
| 115 | + | |
| 116 | + func TestUnknownKeyDuringApprovalDoesNothing(t *testing.T) { | |
| 117 | + m := newTestModel(t) | |
| 118 | + reply := pend(m, approval.Request{Tool: "bash", Rememberable: true}) | |
| 119 | + | |
| 120 | + m.Update(key("z")) | |
| 121 | + | |
| 122 | + select { | |
| 123 | + case got := <-reply: | |
| 124 | + t.Fatalf("stray key sent decision %v", got) | |
| 125 | + default: | |
| 126 | + } | |
| 127 | + if m.phase != phaseApproving { | |
| 128 | + t.Error("stray key dismissed the prompt") | |
| 129 | + } | |
| 130 | + } | |
| 131 | + | |
| 132 | + // Queued approvals are answered one at a time, in order. | |
| 133 | + func TestApprovalsAreQueued(t *testing.T) { | |
| 134 | + m := newTestModel(t) | |
| 135 | + first := pend(m, approval.Request{Tool: "bash", Rememberable: true}) | |
| 136 | + second := pend(m, approval.Request{Tool: "edit_file", Rememberable: true}) | |
| 137 | + | |
| 138 | + m.Update(key("y")) | |
| 139 | + if got := <-first; got != approval.Once { | |
| 140 | + t.Errorf("first decision = %v", got) | |
| 141 | + } | |
| 142 | + if m.phase != phaseApproving { | |
| 143 | + t.Fatal("phase left approving while a second request was queued") | |
| 144 | + } | |
| 145 | + | |
| 146 | + m.Update(key("n")) | |
| 147 | + if got := <-second; got != approval.Deny { | |
| 148 | + t.Errorf("second decision = %v", got) | |
| 149 | + } | |
| 150 | + if m.phase != phaseRunning { | |
| 151 | + t.Errorf("phase = %v after the queue drained", m.phase) | |
| 152 | + } | |
| 153 | + } | |
| 154 | + | |
| 155 | + // A turn that ends while an approval is outstanding must release the agent | |
| 156 | + // rather than leave it blocked forever. | |
| 157 | + func TestTurnDoneDeniesOutstandingApprovals(t *testing.T) { | |
| 158 | + m := newTestModel(t) | |
| 159 | + reply := pend(m, approval.Request{Tool: "bash"}) | |
| 160 | + | |
| 161 | + m.opts.SessionDir = t.TempDir() | |
| 162 | + m.Update(turnDone{}) | |
| 163 | + | |
| 164 | + select { | |
| 165 | + case got := <-reply: | |
| 166 | + if got != approval.Deny { | |
| 167 | + t.Errorf("decision = %v, want Deny", got) | |
| 168 | + } | |
| 169 | + default: | |
| 170 | + t.Fatal("an outstanding approval was never answered") | |
| 171 | + } | |
| 172 | + if m.phase != phaseIdle { | |
| 173 | + t.Errorf("phase = %v, want phaseIdle", m.phase) | |
| 174 | + } | |
| 175 | + if len(m.pending) != 0 { | |
| 176 | + t.Error("pending approvals were not cleared") | |
| 177 | + } | |
| 178 | + } | |
| 179 | + | |
| 180 | + func TestCtrlCDuringApprovalDeniesAndInterrupts(t *testing.T) { | |
| 181 | + m := newTestModel(t) | |
| 182 | + cancelled := false | |
| 183 | + m.cancel = func() { cancelled = true } | |
| 184 | + reply := pend(m, approval.Request{Tool: "bash"}) | |
| 185 | + | |
| 186 | + m.Update(key("ctrl+c")) | |
| 187 | + | |
| 188 | + if got := <-reply; got != approval.Deny { | |
| 189 | + t.Errorf("decision = %v, want Deny", got) | |
| 190 | + } | |
| 191 | + if !cancelled { | |
| 192 | + t.Error("ctrl+c during an approval did not cancel the turn") | |
| 193 | + } | |
| 194 | + if !m.interrupting { | |
| 195 | + t.Error("interrupting flag not set") | |
| 196 | + } | |
| 197 | + } | |
| 198 | + | |
| 199 | + func TestCtrlCInterruptsRunningTurnThenQuitsWhenIdle(t *testing.T) { | |
| 200 | + m := newTestModel(t) | |
| 201 | + cancelled := false | |
| 202 | + m.phase = phaseRunning | |
| 203 | + m.cancel = func() { cancelled = true } | |
| 204 | + | |
| 205 | + if _, cmd := m.Update(key("ctrl+c")); cmd != nil && isQuit(cmd) { | |
| 206 | + t.Fatal("ctrl+c quit the program while a turn was running") | |
| 207 | + } | |
| 208 | + if !cancelled { | |
| 209 | + t.Error("running turn was not cancelled") | |
| 210 | + } | |
| 211 | + if m.quitting { | |
| 212 | + t.Error("model marked quitting during a running turn") | |
| 213 | + } | |
| 214 | + | |
| 215 | + m.phase = phaseIdle | |
| 216 | + if _, cmd := m.Update(key("ctrl+c")); cmd == nil || !isQuit(cmd) { | |
| 217 | + t.Error("ctrl+c while idle did not quit") | |
| 218 | + } | |
| 219 | + } | |
| 220 | + | |
| 221 | + func isQuit(cmd tea.Cmd) bool { | |
| 222 | + if cmd == nil { | |
| 223 | + return false | |
| 224 | + } | |
| 225 | + _, ok := cmd().(tea.QuitMsg) | |
| 226 | + return ok | |
| 227 | + } | |
| 228 | + | |
| 229 | + func TestSlashCommands(t *testing.T) { | |
| 230 | + m := newTestModel(t) | |
| 231 | + | |
| 232 | + m.submit("/help") | |
| 233 | + if !strings.Contains(m.tr.render(), "/allowed") { | |
| 234 | + t.Error("/help did not list commands") | |
| 235 | + } | |
| 236 | + | |
| 237 | + m.submit("/allowed") | |
| 238 | + if !strings.Contains(m.tr.render(), "nothing is permanently allowed") { | |
| 239 | + t.Error("/allowed did not report an empty policy") | |
| 240 | + } | |
| 241 | + | |
| 242 | + m.submit("/nope") | |
| 243 | + if !strings.Contains(m.tr.render(), "unknown command") { | |
| 244 | + t.Error("an unknown command was not reported") | |
| 245 | + } | |
| 246 | + | |
| 247 | + m.submit("/clear") | |
| 248 | + if len(m.tr.entries) != 0 { | |
| 249 | + t.Errorf("/clear left %d entries", len(m.tr.entries)) | |
| 250 | + } | |
| 251 | + | |
| 252 | + if _, cmd := m.submit("/exit"); !isQuit(cmd) { | |
| 253 | + t.Error("/exit did not quit") | |
| 254 | + } | |
| 255 | + } | |
| 256 | + | |
| 257 | + // Input is refused while the agent is working: accepting it would imply it had | |
| 258 | + // been queued, and nothing would ever send it. | |
| 259 | + func TestSlashSkillsListsThem(t *testing.T) { | |
| 260 | + m := newTestModel(t) | |
| 261 | + m.opts.Skills = []skills.Skill{ | |
| 262 | + {Name: "commit-helper", Description: "Use when writing commit messages", Source: "personal"}, | |
| 263 | + } | |
| 264 | + m.submit("/skills") | |
| 265 | + out := m.tr.render() | |
| 266 | + if !strings.Contains(out, "commit-helper") || !strings.Contains(out, "Use when writing commit messages") { | |
| 267 | + t.Errorf("/skills did not list the skill:\n%s", out) | |
| 268 | + } | |
| 269 | + } | |
| 270 | + | |
| 271 | + func TestSlashSkillsWithNoneSaysSo(t *testing.T) { | |
| 272 | + m := newTestModel(t) | |
| 273 | + m.submit("/skills") | |
| 274 | + if !strings.Contains(m.tr.render(), "no skills") { | |
| 275 | + t.Errorf("/skills with none found said nothing useful:\n%s", m.tr.render()) | |
| 276 | + } | |
| 277 | + } | |
| 278 | + | |
| 279 | + func TestKnownSkillIsSentAsAUserTurn(t *testing.T) { | |
| 280 | + m := newTestModel(t) | |
| 281 | + m.opts.Skills = []skills.Skill{{Name: "commit-helper", Description: "d"}} | |
| 282 | + m.submit("/commit-helper fix the flaky test") | |
| 283 | + if m.phase != phaseRunning { | |
| 284 | + t.Errorf("phase = %v, want phaseRunning — the line was not sent", m.phase) | |
| 285 | + } | |
| 286 | + if !strings.Contains(m.tr.render(), "/commit-helper fix the flaky test") { | |
| 287 | + t.Errorf("the user turn is not in the transcript:\n%s", m.tr.render()) | |
| 288 | + } | |
| 289 | + } | |
| 290 | + | |
| 291 | + func TestUnknownSlashCommandStillErrors(t *testing.T) { | |
| 292 | + m := newTestModel(t) | |
| 293 | + m.opts.Skills = []skills.Skill{{Name: "commit-helper", Description: "d"}} | |
| 294 | + m.submit("/commit-helpr") | |
| 295 | + if m.phase == phaseRunning { | |
| 296 | + t.Error("a typo was sent to the model instead of being caught") | |
| 297 | + } | |
| 298 | + if !strings.Contains(m.tr.render(), "unknown command") { | |
| 299 | + t.Errorf("no error for the typo:\n%s", m.tr.render()) | |
| 300 | + } | |
| 301 | + } | |
| 302 | + | |
| 303 | + func TestHelpMentionsSkills(t *testing.T) { | |
| 304 | + m := newTestModel(t) | |
| 305 | + m.submit("/help") | |
| 306 | + if !strings.Contains(m.tr.render(), "/skills") { | |
| 307 | + t.Error("/help does not mention /skills") | |
| 308 | + } | |
| 309 | + } | |
| 310 | + | |
| 311 | + func TestEnterIgnoredWhileRunning(t *testing.T) { | |
| 312 | + m := newTestModel(t) | |
| 313 | + m.phase = phaseRunning | |
| 314 | + m.input.SetValue("another thing") | |
| 315 | + | |
| 316 | + m.Update(key("enter")) | |
| 317 | + | |
| 318 | + if m.input.Value() != "another thing" { | |
| 319 | + t.Error("input was consumed while a turn was running") | |
| 320 | + } | |
| 321 | + } | |
| 322 | + | |
| 323 | + func TestUsageAccumulatesAcrossTurns(t *testing.T) { | |
| 324 | + m := newTestModel(t) | |
| 325 | + m.Update(evUsage{usage: llm.Usage{InputTokens: 10, OutputTokens: 3, CacheReadInputTokens: 100}}) | |
| 326 | + m.Update(evUsage{usage: llm.Usage{InputTokens: 5, OutputTokens: 2}}) | |
| 327 | + | |
| 328 | + if m.usage.InputTokens != 15 || m.usage.OutputTokens != 5 || m.usage.CacheReadInputTokens != 100 { | |
| 329 | + t.Errorf("usage = %+v, want in 15 out 5 cached 100", m.usage) | |
| 330 | + } | |
| 331 | + if s := m.status(); !strings.Contains(s, "15") { | |
| 332 | + t.Errorf("status line does not show usage: %q", s) | |
| 333 | + } | |
| 334 | + } | |
| 335 | + | |
| 336 | + // The cost is the last field on the status line and the easiest one to lose to | |
| 337 | + // a width miscount; at a normal width it must survive intact. | |
| 338 | + func TestStatusLineKeepsCostAtNormalWidth(t *testing.T) { | |
| 339 | + m := newTestModel(t) | |
| 340 | + m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) | |
| 341 | + m.Update(evUsage{usage: llm.Usage{InputTokens: 2400, OutputTokens: 190, CacheReadInputTokens: 16000}}) | |
| 342 | + | |
| 343 | + status := m.status() | |
| 344 | + if _, known := m.usage.Cost(m.opts.Client.Model); !known { | |
| 345 | + t.Skip("no pricing for the test model") | |
| 346 | + } | |
| 347 | + if strings.HasSuffix(strings.TrimSpace(status), "…") { | |
| 348 | + t.Errorf("status line was truncated at width 120: %q", status) | |
| 349 | + } | |
| 350 | + if !strings.Contains(status, "$") { | |
| 351 | + t.Errorf("cost missing from the status line: %q", status) | |
| 352 | + } | |
| 353 | + } | |
| 354 | + | |
| 355 | + // Approving a call records the decision on that call, so the transcript reads | |
| 356 | + // in the order things actually happened. | |
| 357 | + func TestApprovalNoteAttachesToTheCall(t *testing.T) { | |
| 358 | + m := newTestModel(t) | |
| 359 | + m.tr.startTool("bash", "command=go build ./...") | |
| 360 | + reply := pend(m, approval.Request{Tool: "bash", Rememberable: true}) | |
| 361 | + m.Update(key("y")) | |
| 362 | + <-reply | |
| 363 | + m.tr.finishTool("bash", "build ok", false) | |
| 364 | + | |
| 365 | + out := m.tr.render() | |
| 366 | + note, result := strings.Index(out, "approved once"), strings.Index(out, "build ok") | |
| 367 | + if note < 0 || result < 0 || note > result { | |
| 368 | + t.Errorf("approval did not render with the call it gated:\n%s", out) | |
| 369 | + } | |
| 370 | + } | |
| 371 | + | |
| 372 | + func TestToggleVerboseRedrawsTranscript(t *testing.T) { | |
| 373 | + m := newTestModel(t) | |
| 374 | + m.tr.startTool("bash", "command=go test") | |
| 375 | + m.tr.finishTool("bash", strings.Repeat("noise\n", 30), false) | |
| 376 | + | |
| 377 | + if !strings.Contains(m.tr.render(), "more lines") { | |
| 378 | + t.Fatal("output was not collapsed to begin with") | |
| 379 | + } | |
| 380 | + m.Update(key("ctrl+r")) | |
| 381 | + if strings.Contains(m.tr.render(), "more lines") { | |
| 382 | + t.Error("ctrl+r did not expand tool output") | |
| 383 | + } | |
| 384 | + } | |
| 385 | + | |
| 386 | + // The view must render at any size without panicking; a resize to something | |
| 387 | + // tiny is the classic crash. | |
| 388 | + func TestViewRendersAtAwkwardSizes(t *testing.T) { | |
| 389 | + for _, size := range [][2]int{{80, 24}, {20, 6}, {200, 60}, {1, 1}} { | |
| 390 | + m := newTestModel(t) | |
| 391 | + m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) | |
| 392 | + m.tr.addUser("hello") | |
| 393 | + m.tr.startTool("edit_file", "path=main.go") | |
| 394 | + m.tr.finishTool("edit_file", "--- a\n+++ b\n@@ -1 +1 @@\n-x\n+y\n", false) | |
| 395 | + m.sync() | |
| 396 | + | |
| 397 | + if got := m.View(); got == "" { | |
| 398 | + t.Errorf("empty view at %dx%d", size[0], size[1]) | |
| 399 | + } | |
| 400 | + pend(m, approval.Request{Tool: "bash", Summary: "run a shell command", Detail: "rm -rf /", Rememberable: false}) | |
| 401 | + if got := m.View(); !strings.Contains(got, "bash") { | |
| 402 | + t.Errorf("approval not shown at %dx%d", size[0], size[1]) | |
| 403 | + } | |
| 404 | + } | |
| 405 | + } | |
| 406 | + | |
| 407 | + func TestViewShowsApprovalOptionsConditionally(t *testing.T) { | |
| 408 | + m := newTestModel(t) | |
| 409 | + pend(m, approval.Request{Tool: "bash", Summary: "run it", Detail: "ls", Rememberable: true}) | |
| 410 | + if got := m.approvalView(); !strings.Contains(got, "always this exact one") { | |
| 411 | + t.Error("rememberable request did not offer the exact-command option") | |
| 412 | + } | |
| 413 | + | |
| 414 | + m2 := newTestModel(t) | |
| 415 | + pend(m2, approval.Request{Tool: "bash", Summary: "run it", Detail: "eval $(x)", Rememberable: false}) | |
| 416 | + if got := m2.approvalView(); strings.Contains(got, "always this exact one") { | |
| 417 | + t.Error("opaque command offered an option that would be a lie") | |
| 418 | + } | |
| 419 | + } | |
| @@ -0,0 +1,64 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "strings" | |
| 5 | + | |
| 6 | + "github.com/charmbracelet/lipgloss" | |
| 7 | + ) | |
| 8 | + | |
| 9 | + // mascot is drawn with half-blocks from the box-drawing range, so it needs no | |
| 10 | + // patched font and degrades to something recognisable in a plain xterm. | |
| 11 | + var mascot = []string{ | |
| 12 | + " ▄▄▄▄▄▄ ", | |
| 13 | + "▐▀█▀█▀█▌", | |
| 14 | + "▐██████▌", | |
| 15 | + " ▀▀ ▀▀ ", | |
| 16 | + } | |
| 17 | + | |
| 18 | + const mascotGap = " " | |
| 19 | + | |
| 20 | + // splash is the welcome block. It goes into the scrollback rather than into | |
| 21 | + // fixed chrome, so it scrolls away once there is real work to look at instead | |
| 22 | + // of permanently taxing four rows of a small terminal. | |
| 23 | + // | |
| 24 | + // On a narrow terminal the mascot is dropped rather than sliced: half a mascot | |
| 25 | + // reads as a rendering bug, where the three text lines alone read as a choice. | |
| 26 | + func splash(o Options, width int) string { | |
| 27 | + name := styHeading.Render("harness") | |
| 28 | + if o.Version != "" { | |
| 29 | + name += " " + styDim.Render(o.Version) | |
| 30 | + } | |
| 31 | + lines := []string{ | |
| 32 | + name, | |
| 33 | + styDim.Render(prettyModel(o.Client.Model) + " · " + endpointLabel(o.Client)), | |
| 34 | + styDim.Render(shortenHome(o.Root)), | |
| 35 | + } | |
| 36 | + | |
| 37 | + // The mascot is dropped only when the terminal cannot hold it beside a | |
| 38 | + // readable amount of text. It is measured against a minimum rather than | |
| 39 | + // against the longest line, or a deeply nested working directory would | |
| 40 | + // silently cost you the whole welcome block. | |
| 41 | + art := mascot | |
| 42 | + if width < artWidth()+minSplashText { | |
| 43 | + art = nil | |
| 44 | + } | |
| 45 | + | |
| 46 | + var b strings.Builder | |
| 47 | + for i := 0; i < maxInt(len(art), len(lines)); i++ { | |
| 48 | + var line string | |
| 49 | + if i < len(art) { | |
| 50 | + line = styCoral.Render(art[i]) + mascotGap | |
| 51 | + } | |
| 52 | + if i < len(lines) { | |
| 53 | + line += lines[i] | |
| 54 | + } | |
| 55 | + b.WriteString(truncateStyled(line, maxInt(1, width)) + "\n") | |
| 56 | + } | |
| 57 | + return b.String() | |
| 58 | + } | |
| 59 | + | |
| 60 | + // minSplashText is how much room the text beside the mascot needs before the | |
| 61 | + // mascot stops being worth its columns. | |
| 62 | + const minSplashText = 24 | |
| 63 | + | |
| 64 | + func artWidth() int { return lipgloss.Width(mascot[1]) + lipgloss.Width(mascotGap) } | |
| @@ -0,0 +1,77 @@ | ||
| 1 | + // Package tui is a full-screen terminal front end built on Bubble Tea. | |
| 2 | + // | |
| 3 | + // It observes the same agent.Event stream the plain renderer does and answers | |
| 4 | + // the same approval.Prompt signature, so the dependency direction is unchanged: | |
| 5 | + // tui imports agent, never the reverse, and the eval runner still drives the | |
| 6 | + // loop with no terminal attached. | |
| 7 | + package tui | |
| 8 | + | |
| 9 | + import "github.com/charmbracelet/lipgloss" | |
| 10 | + | |
| 11 | + // The palette is warm rather than neutral: one coral accent carries the | |
| 12 | + // identity and everything else is a grey at some distance from the text | |
| 13 | + // colour. Every colour is adaptive, because a palette tuned only for a dark | |
| 14 | + // terminal is the usual way a hand-rolled TUI becomes unreadable on a light | |
| 15 | + // one. | |
| 16 | + var ( | |
| 17 | + colCoral = lipgloss.AdaptiveColor{Light: "#b4532f", Dark: "#d97757"} | |
| 18 | + colText = lipgloss.AdaptiveColor{Light: "#1c1b1a", Dark: "#e8e6e3"} | |
| 19 | + colUser = lipgloss.AdaptiveColor{Light: "#4b4945", Dark: "#b3ada4"} | |
| 20 | + colDim = lipgloss.AdaptiveColor{Light: "#6f6a66", Dark: "#8a847d"} | |
| 21 | + colFaint = lipgloss.AdaptiveColor{Light: "#c9c4be", Dark: "#3f3b38"} | |
| 22 | + colOK = lipgloss.AdaptiveColor{Light: "#2f7d46", Dark: "#5dbb7a"} | |
| 23 | + colErr = lipgloss.AdaptiveColor{Light: "#b3261e", Dark: "#eb6f6f"} | |
| 24 | + colWarn = lipgloss.AdaptiveColor{Light: "#8a5a00", Dark: "#e0a458"} | |
| 25 | + colBlue = lipgloss.AdaptiveColor{Light: "#2f5fa8", Dark: "#7aa2e3"} | |
| 26 | + colPillBg = lipgloss.AdaptiveColor{Light: "#ebe7e2", Dark: "#2b2825"} | |
| 27 | + ) | |
| 28 | + | |
| 29 | + var ( | |
| 30 | + styText = lipgloss.NewStyle().Foreground(colText) | |
| 31 | + styDim = lipgloss.NewStyle().Foreground(colDim) | |
| 32 | + styFaint = lipgloss.NewStyle().Foreground(colFaint) | |
| 33 | + styCoral = lipgloss.NewStyle().Foreground(colCoral) | |
| 34 | + styBold = lipgloss.NewStyle().Bold(true) | |
| 35 | + styErr = lipgloss.NewStyle().Foreground(colErr) | |
| 36 | + styWarn = lipgloss.NewStyle().Foreground(colWarn) | |
| 37 | + styOK = lipgloss.NewStyle().Foreground(colOK) | |
| 38 | + | |
| 39 | + // The transcript's grammar: every turn of events opens with a dot, and | |
| 40 | + // anything hanging off it is indented under that dot. | |
| 41 | + styUserMark = lipgloss.NewStyle().Foreground(colDim) | |
| 42 | + styUserText = lipgloss.NewStyle().Foreground(colUser) | |
| 43 | + styToolDot = lipgloss.NewStyle().Foreground(colCoral) | |
| 44 | + styTextDot = lipgloss.NewStyle().Foreground(colText) | |
| 45 | + styErrDot = lipgloss.NewStyle().Foreground(colErr) | |
| 46 | + styToolName = lipgloss.NewStyle().Foreground(colText).Bold(true) | |
| 47 | + styToolArgs = lipgloss.NewStyle().Foreground(colDim) | |
| 48 | + styResult = lipgloss.NewStyle().Foreground(colDim) | |
| 49 | + | |
| 50 | + styDiffAdd = lipgloss.NewStyle().Foreground(colOK) | |
| 51 | + styDiffDel = lipgloss.NewStyle().Foreground(colErr) | |
| 52 | + styDiffHunk = lipgloss.NewStyle().Foreground(colCoral) | |
| 53 | + styDiffHead = lipgloss.NewStyle().Foreground(colDim).Bold(true) | |
| 54 | + | |
| 55 | + styName = lipgloss.NewStyle().Foreground(colBlue).Background(colPillBg).Padding(0, 1) | |
| 56 | + styPill = lipgloss.NewStyle().Foreground(colDim).Background(colPillBg).Padding(0, 1) | |
| 57 | + styKey = lipgloss.NewStyle().Foreground(colCoral).Bold(true) | |
| 58 | + styHeading = lipgloss.NewStyle().Foreground(colCoral).Bold(true) | |
| 59 | + | |
| 60 | + // The input frame is the only always-present chrome, so it carries the | |
| 61 | + // mode: coral when it is yours to type into, faint while the agent has it. | |
| 62 | + styBoxFocus = lipgloss.NewStyle(). | |
| 63 | + Border(lipgloss.RoundedBorder()). | |
| 64 | + BorderForeground(colCoral). | |
| 65 | + Padding(0, 1) | |
| 66 | + styBoxBlur = lipgloss.NewStyle(). | |
| 67 | + Border(lipgloss.RoundedBorder()). | |
| 68 | + BorderForeground(colFaint). | |
| 69 | + Padding(0, 1) | |
| 70 | + | |
| 71 | + // The approval box is the one place the UI is allowed to shout: it is the | |
| 72 | + // only moment a human has to decide something. | |
| 73 | + styModal = lipgloss.NewStyle(). | |
| 74 | + Border(lipgloss.RoundedBorder()). | |
| 75 | + BorderForeground(colCoral). | |
| 76 | + Padding(0, 1) | |
| 77 | + ) | |
| @@ -0,0 +1,104 @@ | ||
| 1 | + // Command mockapi is a fake Messages API for exercising the TUI by hand, | |
| 2 | + // without an API key and without spending anything. | |
| 3 | + // | |
| 4 | + // It lives under testdata so it is invisible to ./... — it is a manual test | |
| 5 | + // fixture, not part of the build. | |
| 6 | + // | |
| 7 | + // go run ./internal/tui/testdata/mockapi & | |
| 8 | + // ./harness -C /tmp/demo -base-url http://127.0.0.1:8899 -api-key mock | |
| 9 | + // | |
| 10 | + // The script is fixed: the first turn streams a sentence and asks to run a | |
| 11 | + // shell command, which makes the TUI raise an approval prompt; once a | |
| 12 | + // tool_result comes back it streams a closing sentence and stops. That covers | |
| 13 | + // streaming, tool rendering, approve, deny, and interrupt. | |
| 14 | + // | |
| 15 | + // Flags: -addr to move it, -fail to make the command exit non-zero, -slow to | |
| 16 | + // stretch the stream out for watching the spinner and ctrl-c. | |
| 17 | + package main | |
| 18 | + | |
| 19 | + import ( | |
| 20 | + "encoding/json" | |
| 21 | + "flag" | |
| 22 | + "fmt" | |
| 23 | + "io" | |
| 24 | + "log" | |
| 25 | + "net/http" | |
| 26 | + "strings" | |
| 27 | + "time" | |
| 28 | + ) | |
| 29 | + | |
| 30 | + var ( | |
| 31 | + addr = flag.String("addr", "127.0.0.1:8899", "listen address") | |
| 32 | + slow = flag.Duration("slow", 0, "pause between streamed words, e.g. 150ms") | |
| 33 | + cmd = flag.String("cmd", "git status --short && go build ./...", "command the model asks to run") | |
| 34 | + ) | |
| 35 | + | |
| 36 | + func main() { | |
| 37 | + flag.Parse() | |
| 38 | + http.HandleFunc("/v1/messages", handle) | |
| 39 | + log.Printf("mock Messages API on http://%s", *addr) | |
| 40 | + log.Fatal(http.ListenAndServe(*addr, nil)) | |
| 41 | + } | |
| 42 | + | |
| 43 | + func handle(w http.ResponseWriter, r *http.Request) { | |
| 44 | + body, _ := io.ReadAll(r.Body) | |
| 45 | + // A tool_result in the history means the harness ran (or refused) our | |
| 46 | + // command and fed the outcome back, so the next turn is the last one. | |
| 47 | + final := strings.Contains(string(body), "tool_result") | |
| 48 | + | |
| 49 | + w.Header().Set("Content-Type", "text/event-stream") | |
| 50 | + w.WriteHeader(http.StatusOK) | |
| 51 | + | |
| 52 | + send(w, "message_start", map[string]any{ | |
| 53 | + "type": "message_start", | |
| 54 | + "message": map[string]any{ | |
| 55 | + "id": "msg_mock", "model": "claude-opus-5", | |
| 56 | + "usage": map[string]any{"input_tokens": 1200, "cache_read_input_tokens": 8000}, | |
| 57 | + }, | |
| 58 | + }) | |
| 59 | + | |
| 60 | + text := "Checking the repository state first." | |
| 61 | + if final { | |
| 62 | + text = "That is everything I can tell from here." | |
| 63 | + } | |
| 64 | + send(w, "content_block_start", map[string]any{ | |
| 65 | + "type": "content_block_start", "index": 0, | |
| 66 | + "content_block": map[string]any{"type": "text", "text": ""}, | |
| 67 | + }) | |
| 68 | + for _, word := range strings.SplitAfter(text, " ") { | |
| 69 | + send(w, "content_block_delta", map[string]any{ | |
| 70 | + "type": "content_block_delta", "index": 0, | |
| 71 | + "delta": map[string]any{"type": "text_delta", "text": word}, | |
| 72 | + }) | |
| 73 | + time.Sleep(*slow) | |
| 74 | + } | |
| 75 | + send(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}) | |
| 76 | + | |
| 77 | + stop := "end_turn" | |
| 78 | + if !final { | |
| 79 | + stop = "tool_use" | |
| 80 | + send(w, "content_block_start", map[string]any{ | |
| 81 | + "type": "content_block_start", "index": 1, | |
| 82 | + "content_block": map[string]any{"type": "tool_use", "id": "toolu_1", "name": "bash"}, | |
| 83 | + }) | |
| 84 | + args, _ := json.Marshal(map[string]string{"command": *cmd}) | |
| 85 | + send(w, "content_block_delta", map[string]any{ | |
| 86 | + "type": "content_block_delta", "index": 1, | |
| 87 | + "delta": map[string]any{"type": "input_json_delta", "partial_json": string(args)}, | |
| 88 | + }) | |
| 89 | + send(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": 1}) | |
| 90 | + } | |
| 91 | + | |
| 92 | + send(w, "message_delta", map[string]any{ | |
| 93 | + "type": "message_delta", | |
| 94 | + "delta": map[string]any{"stop_reason": stop}, | |
| 95 | + "usage": map[string]any{"output_tokens": 95}, | |
| 96 | + }) | |
| 97 | + send(w, "message_stop", map[string]any{"type": "message_stop"}) | |
| 98 | + } | |
| 99 | + | |
| 100 | + func send(w http.ResponseWriter, event string, data any) { | |
| 101 | + b, _ := json.Marshal(data) | |
| 102 | + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b) | |
| 103 | + w.(http.Flusher).Flush() | |
| 104 | + } | |
| @@ -0,0 +1,380 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "encoding/json" | |
| 5 | + "fmt" | |
| 6 | + "sort" | |
| 7 | + "strings" | |
| 8 | + | |
| 9 | + "github.com/charmbracelet/lipgloss" | |
| 10 | + "github.com/charmbracelet/x/ansi" | |
| 11 | + ) | |
| 12 | + | |
| 13 | + type entryKind int | |
| 14 | + | |
| 15 | + const ( | |
| 16 | + entryUser entryKind = iota | |
| 17 | + entryAssistant | |
| 18 | + entryTool | |
| 19 | + entryNotice | |
| 20 | + entryError | |
| 21 | + // entryRaw is already-styled text the transcript passes through | |
| 22 | + // untouched: the welcome block, which owns its own layout. | |
| 23 | + entryRaw | |
| 24 | + ) | |
| 25 | + | |
| 26 | + // entry is one thing that happened, kept structured rather than pre-rendered | |
| 27 | + // so a resize or a verbosity toggle can redraw the whole history correctly. | |
| 28 | + type entry struct { | |
| 29 | + kind entryKind | |
| 30 | + text string | |
| 31 | + tool string | |
| 32 | + input string | |
| 33 | + result string | |
| 34 | + isError bool | |
| 35 | + // note is a decision recorded against this call — an approval or a | |
| 36 | + // denial — kept on the entry so it renders with the call rather than | |
| 37 | + // after the result that came back later. | |
| 38 | + note string | |
| 39 | + // open means more is still arriving: streamed text, or a tool call whose | |
| 40 | + // result has not come back yet. | |
| 41 | + open bool | |
| 42 | + } | |
| 43 | + | |
| 44 | + // collapsedLines is how much of a tool result is shown before it is elided. | |
| 45 | + // Diffs get a larger budget because a diff is the record of what changed. | |
| 46 | + const ( | |
| 47 | + collapsedLines = 6 | |
| 48 | + collapsedDiff = 40 | |
| 49 | + ) | |
| 50 | + | |
| 51 | + // The transcript reads as a column of events hanging off a gutter: a dot | |
| 52 | + // opens each one, and everything belonging to it is indented under the dot. | |
| 53 | + const ( | |
| 54 | + dot = "⏺" | |
| 55 | + branch = "⎿" | |
| 56 | + gutter = " " | |
| 57 | + ) | |
| 58 | + | |
| 59 | + // transcript is the scrollback. It renders to a single string for the | |
| 60 | + // viewport, and caches that string because re-rendering a long session on | |
| 61 | + // every frame is wasteful. | |
| 62 | + type transcript struct { | |
| 63 | + entries []entry | |
| 64 | + width int | |
| 65 | + verbose bool | |
| 66 | + | |
| 67 | + cache string | |
| 68 | + dirty bool | |
| 69 | + } | |
| 70 | + | |
| 71 | + func newTranscript(width int) *transcript { | |
| 72 | + return &transcript{width: width, dirty: true} | |
| 73 | + } | |
| 74 | + | |
| 75 | + func (t *transcript) setWidth(w int) { | |
| 76 | + if w != t.width { | |
| 77 | + t.width, t.dirty = w, true | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + func (t *transcript) setVerbose(v bool) { | |
| 82 | + if v != t.verbose { | |
| 83 | + t.verbose, t.dirty = v, true | |
| 84 | + } | |
| 85 | + } | |
| 86 | + | |
| 87 | + func (t *transcript) clear() { | |
| 88 | + t.entries, t.dirty = nil, true | |
| 89 | + } | |
| 90 | + | |
| 91 | + func (t *transcript) add(e entry) { | |
| 92 | + t.closeOpen() | |
| 93 | + t.entries = append(t.entries, e) | |
| 94 | + t.dirty = true | |
| 95 | + } | |
| 96 | + | |
| 97 | + func (t *transcript) addUser(s string) { t.add(entry{kind: entryUser, text: s}) } | |
| 98 | + func (t *transcript) addNotice(s string) { t.add(entry{kind: entryNotice, text: s}) } | |
| 99 | + func (t *transcript) addError(s string) { t.add(entry{kind: entryError, text: s}) } | |
| 100 | + func (t *transcript) addRaw(s string) { t.add(entry{kind: entryRaw, text: s}) } | |
| 101 | + | |
| 102 | + // closeOpen finalises a streaming assistant entry. A tool call arriving | |
| 103 | + // mid-sentence ends the sentence, which is what the plain renderer does with | |
| 104 | + // its newline bookkeeping. | |
| 105 | + func (t *transcript) closeOpen() { | |
| 106 | + if n := len(t.entries); n > 0 && t.entries[n-1].kind == entryAssistant && t.entries[n-1].open { | |
| 107 | + t.entries[n-1].open = false | |
| 108 | + t.entries[n-1].text = strings.TrimRight(t.entries[n-1].text, "\n") | |
| 109 | + t.dirty = true | |
| 110 | + } | |
| 111 | + } | |
| 112 | + | |
| 113 | + // appendDelta adds streamed text, continuing the current assistant entry | |
| 114 | + // rather than starting a new one per token. | |
| 115 | + func (t *transcript) appendDelta(s string) { | |
| 116 | + if n := len(t.entries); n > 0 && t.entries[n-1].kind == entryAssistant && t.entries[n-1].open { | |
| 117 | + t.entries[n-1].text += s | |
| 118 | + t.dirty = true | |
| 119 | + return | |
| 120 | + } | |
| 121 | + t.entries = append(t.entries, entry{kind: entryAssistant, text: s, open: true}) | |
| 122 | + t.dirty = true | |
| 123 | + } | |
| 124 | + | |
| 125 | + func (t *transcript) startTool(name, input string) { | |
| 126 | + t.closeOpen() | |
| 127 | + t.entries = append(t.entries, entry{kind: entryTool, tool: name, input: input, open: true}) | |
| 128 | + t.dirty = true | |
| 129 | + } | |
| 130 | + | |
| 131 | + // annotateTool records a decision against the most recent unfinished call of | |
| 132 | + // that tool. Approval happens before the tool runs, so appending it as its own | |
| 133 | + // entry would print it after the result it gated. | |
| 134 | + func (t *transcript) annotateTool(name, note string) { | |
| 135 | + for i := len(t.entries) - 1; i >= 0; i-- { | |
| 136 | + if t.entries[i].kind == entryTool && t.entries[i].open && t.entries[i].tool == name { | |
| 137 | + t.entries[i].note = note | |
| 138 | + t.dirty = true | |
| 139 | + return | |
| 140 | + } | |
| 141 | + } | |
| 142 | + t.addNotice(gutter + branch + " " + note) | |
| 143 | + } | |
| 144 | + | |
| 145 | + // finishTool attaches a result to the most recent unfinished call of that | |
| 146 | + // tool. Matching by name from the end is enough: the loop dispatches calls in | |
| 147 | + // order and reports each result as it lands. | |
| 148 | + func (t *transcript) finishTool(name, result string, isErr bool) { | |
| 149 | + for i := len(t.entries) - 1; i >= 0; i-- { | |
| 150 | + if t.entries[i].kind == entryTool && t.entries[i].open && t.entries[i].tool == name { | |
| 151 | + t.entries[i].result = result | |
| 152 | + t.entries[i].isError = isErr | |
| 153 | + t.entries[i].open = false | |
| 154 | + t.dirty = true | |
| 155 | + return | |
| 156 | + } | |
| 157 | + } | |
| 158 | + // No matching call: show it anyway rather than silently dropping evidence. | |
| 159 | + t.entries = append(t.entries, entry{kind: entryTool, tool: name, result: result, isError: isErr}) | |
| 160 | + t.dirty = true | |
| 161 | + } | |
| 162 | + | |
| 163 | + // render produces the full scrollback. Entries are separated by a blank line, | |
| 164 | + // which is most of what makes a wall of tool output readable. | |
| 165 | + func (t *transcript) render() string { | |
| 166 | + if !t.dirty { | |
| 167 | + return t.cache | |
| 168 | + } | |
| 169 | + var b strings.Builder | |
| 170 | + for i, e := range t.entries { | |
| 171 | + if i > 0 { | |
| 172 | + b.WriteByte('\n') | |
| 173 | + } | |
| 174 | + b.WriteString(t.renderEntry(e)) | |
| 175 | + } | |
| 176 | + t.cache, t.dirty = b.String(), false | |
| 177 | + return t.cache | |
| 178 | + } | |
| 179 | + | |
| 180 | + func (t *transcript) renderEntry(e entry) string { | |
| 181 | + width := t.width | |
| 182 | + if width < 20 { | |
| 183 | + width = 20 | |
| 184 | + } | |
| 185 | + switch e.kind { | |
| 186 | + case entryRaw: | |
| 187 | + return e.text | |
| 188 | + | |
| 189 | + case entryUser: | |
| 190 | + return hang(styUserMark.Render(">"), styUserText.Width(width-2).Render(e.text)) | |
| 191 | + | |
| 192 | + case entryAssistant: | |
| 193 | + return hang(styTextDot.Render(dot), styText.Width(width-2).Render(strings.TrimRight(e.text, "\n"))) | |
| 194 | + | |
| 195 | + case entryNotice: | |
| 196 | + return styDim.Width(width).Render(e.text) + "\n" | |
| 197 | + | |
| 198 | + case entryError: | |
| 199 | + return hang(styErrDot.Render(dot), styErr.Width(width-2).Render(e.text)) | |
| 200 | + | |
| 201 | + case entryTool: | |
| 202 | + return t.renderTool(e, width) | |
| 203 | + } | |
| 204 | + return "" | |
| 205 | + } | |
| 206 | + | |
| 207 | + // hang renders a marker and a body so the body's continuation lines line up | |
| 208 | + // under its first line rather than under the marker. | |
| 209 | + func hang(mark, body string) string { | |
| 210 | + var b strings.Builder | |
| 211 | + for i, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") { | |
| 212 | + if i == 0 { | |
| 213 | + b.WriteString(mark + " " + line + "\n") | |
| 214 | + continue | |
| 215 | + } | |
| 216 | + b.WriteString(gutter + line + "\n") | |
| 217 | + } | |
| 218 | + return b.String() | |
| 219 | + } | |
| 220 | + | |
| 221 | + func (t *transcript) renderTool(e entry, width int) string { | |
| 222 | + var b strings.Builder | |
| 223 | + | |
| 224 | + name := camel(e.tool) | |
| 225 | + args := "" | |
| 226 | + if e.input != "" { | |
| 227 | + args = "(" + truncate(e.input, maxInt(10, width-lipgloss.Width(name)-6)) + ")" | |
| 228 | + } | |
| 229 | + b.WriteString(styToolDot.Render(dot) + " " + styToolName.Render(name) + styToolArgs.Render(args) + "\n") | |
| 230 | + | |
| 231 | + if e.note != "" { | |
| 232 | + b.WriteString(styDim.Render(gutter+branch+" "+e.note) + "\n") | |
| 233 | + } | |
| 234 | + if e.open { | |
| 235 | + b.WriteString(styFaint.Render(gutter+branch+" …") + "\n") | |
| 236 | + return b.String() | |
| 237 | + } | |
| 238 | + if strings.TrimSpace(e.result) == "" && !e.isError { | |
| 239 | + return b.String() | |
| 240 | + } | |
| 241 | + | |
| 242 | + // A diff is the one result worth showing in full and in colour. | |
| 243 | + if !e.isError && looksLikeDiff(e.result) { | |
| 244 | + lines := strings.Split(strings.TrimRight(e.result, "\n"), "\n") | |
| 245 | + b.WriteString(styDim.Render(gutter+diffStat(lines)) + "\n") | |
| 246 | + limit := collapsedDiff | |
| 247 | + if t.verbose { | |
| 248 | + limit = len(lines) | |
| 249 | + } | |
| 250 | + for _, line := range clip(lines, limit) { | |
| 251 | + b.WriteString(gutter + colorDiffLine(line, width-len(gutter)) + "\n") | |
| 252 | + } | |
| 253 | + if n := len(lines) - limit; n > 0 { | |
| 254 | + b.WriteString(styFaint.Render(fmt.Sprintf("%s… +%s of diff (ctrl+r)", gutter, plural(n, "more line"))) + "\n") | |
| 255 | + } | |
| 256 | + return b.String() | |
| 257 | + } | |
| 258 | + | |
| 259 | + style, prefix := styResult, gutter+branch+" " | |
| 260 | + if e.isError { | |
| 261 | + style, prefix = styErr, gutter+branch+" error: " | |
| 262 | + } | |
| 263 | + lines := strings.Split(strings.TrimRight(e.result, "\n"), "\n") | |
| 264 | + limit := collapsedLines | |
| 265 | + if t.verbose { | |
| 266 | + limit = len(lines) | |
| 267 | + } | |
| 268 | + cont := strings.Repeat(" ", lipgloss.Width(gutter+branch+" ")) | |
| 269 | + for i, line := range clip(lines, limit) { | |
| 270 | + pad := prefix | |
| 271 | + if i > 0 { | |
| 272 | + pad = cont | |
| 273 | + } | |
| 274 | + b.WriteString(style.Render(pad+truncate(line, maxInt(10, width-lipgloss.Width(pad)))) + "\n") | |
| 275 | + } | |
| 276 | + if n := len(lines) - limit; n > 0 { | |
| 277 | + b.WriteString(styFaint.Render(fmt.Sprintf("%s… +%s (ctrl+r)", cont, plural(n, "more line"))) + "\n") | |
| 278 | + } | |
| 279 | + return b.String() | |
| 280 | + } | |
| 281 | + | |
| 282 | + // diffStat counts what a diff did, so the header says how big a change this | |
| 283 | + // was before the reader has to measure it by eye. The +++/--- file headers are | |
| 284 | + // not edits and must not be counted as any. | |
| 285 | + func diffStat(lines []string) string { | |
| 286 | + add, del := 0, 0 | |
| 287 | + for _, line := range lines { | |
| 288 | + switch { | |
| 289 | + case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"): | |
| 290 | + case strings.HasPrefix(line, "+"): | |
| 291 | + add++ | |
| 292 | + case strings.HasPrefix(line, "-"): | |
| 293 | + del++ | |
| 294 | + } | |
| 295 | + } | |
| 296 | + return fmt.Sprintf("%s, %s", plural(add, "addition"), plural(del, "removal")) | |
| 297 | + } | |
| 298 | + | |
| 299 | + func plural(n int, word string) string { | |
| 300 | + if n == 1 { | |
| 301 | + return fmt.Sprintf("%d %s", n, word) | |
| 302 | + } | |
| 303 | + return fmt.Sprintf("%d %ss", n, word) | |
| 304 | + } | |
| 305 | + | |
| 306 | + func colorDiffLine(line string, width int) string { | |
| 307 | + line = truncate(line, maxInt(10, width)) | |
| 308 | + switch { | |
| 309 | + case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"): | |
| 310 | + return styDiffHead.Render(line) | |
| 311 | + case strings.HasPrefix(line, "@@"): | |
| 312 | + return styDiffHunk.Render(line) | |
| 313 | + case strings.HasPrefix(line, "+"): | |
| 314 | + return styDiffAdd.Render(line) | |
| 315 | + case strings.HasPrefix(line, "-"): | |
| 316 | + return styDiffDel.Render(line) | |
| 317 | + } | |
| 318 | + return styResult.Render(line) | |
| 319 | + } | |
| 320 | + | |
| 321 | + // looksLikeDiff reports whether s is a unified diff, so a tool result can be | |
| 322 | + // rendered appropriately without the tool having to say which it is. | |
| 323 | + func looksLikeDiff(s string) bool { | |
| 324 | + return strings.HasPrefix(s, "--- ") && strings.Contains(s, "\n@@ ") | |
| 325 | + } | |
| 326 | + | |
| 327 | + func clip(lines []string, n int) []string { | |
| 328 | + if n < 0 || n >= len(lines) { | |
| 329 | + return lines | |
| 330 | + } | |
| 331 | + return lines[:n] | |
| 332 | + } | |
| 333 | + | |
| 334 | + // truncate cuts to n display cells, counting runes rather than bytes so a | |
| 335 | + // multi-byte path does not get sliced mid-character. It is for unstyled text. | |
| 336 | + func truncate(s string, n int) string { | |
| 337 | + if n <= 0 { | |
| 338 | + return "" | |
| 339 | + } | |
| 340 | + r := []rune(s) | |
| 341 | + if len(r) <= n { | |
| 342 | + return s | |
| 343 | + } | |
| 344 | + return string(r[:n-1]) + "…" | |
| 345 | + } | |
| 346 | + | |
| 347 | + // truncateStyled cuts a string that already carries ANSI styling. Counting | |
| 348 | + // runes there would measure escape sequences as if they were visible and cut | |
| 349 | + // the line far too early — which is exactly how a status bar loses its last | |
| 350 | + // field to a colour code. | |
| 351 | + func truncateStyled(s string, n int) string { | |
| 352 | + if n <= 0 { | |
| 353 | + return "" | |
| 354 | + } | |
| 355 | + if lipgloss.Width(s) <= n { | |
| 356 | + return s | |
| 357 | + } | |
| 358 | + return ansi.Truncate(s, n, "…") | |
| 359 | + } | |
| 360 | + | |
| 361 | + // summarizeInput renders tool arguments as k=v, sorted so the same call always | |
| 362 | + // prints the same way. | |
| 363 | + func summarizeInput(raw json.RawMessage) string { | |
| 364 | + var m map[string]any | |
| 365 | + if len(raw) == 0 || json.Unmarshal(raw, &m) != nil { | |
| 366 | + return string(raw) | |
| 367 | + } | |
| 368 | + keys := make([]string, 0, len(m)) | |
| 369 | + for k := range m { | |
| 370 | + keys = append(keys, k) | |
| 371 | + } | |
| 372 | + sort.Strings(keys) | |
| 373 | + | |
| 374 | + parts := make([]string, 0, len(keys)) | |
| 375 | + for _, k := range keys { | |
| 376 | + v := strings.ReplaceAll(fmt.Sprintf("%v", m[k]), "\n", "⏎") | |
| 377 | + parts = append(parts, fmt.Sprintf("%s=%s", k, truncate(v, 60))) | |
| 378 | + } | |
| 379 | + return strings.Join(parts, ", ") | |
| 380 | + } | |
| @@ -0,0 +1,256 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "encoding/json" | |
| 5 | + "strings" | |
| 6 | + "testing" | |
| 7 | + | |
| 8 | + "github.com/charmbracelet/lipgloss" | |
| 9 | + ) | |
| 10 | + | |
| 11 | + func TestAppendDeltaCoalesces(t *testing.T) { | |
| 12 | + tr := newTranscript(80) | |
| 13 | + tr.appendDelta("Hel") | |
| 14 | + tr.appendDelta("lo ") | |
| 15 | + tr.appendDelta("world") | |
| 16 | + | |
| 17 | + if len(tr.entries) != 1 { | |
| 18 | + t.Fatalf("streamed text made %d entries, want 1", len(tr.entries)) | |
| 19 | + } | |
| 20 | + if got := tr.entries[0].text; got != "Hello world" { | |
| 21 | + t.Errorf("text = %q, want %q", got, "Hello world") | |
| 22 | + } | |
| 23 | + } | |
| 24 | + | |
| 25 | + // A tool call arriving mid-sentence must close the streaming entry rather than | |
| 26 | + // interleave with it: this is the bug the line renderer's midLine flag exists | |
| 27 | + // to prevent, and the transcript has to handle it structurally. | |
| 28 | + func TestToolCallClosesStreamingText(t *testing.T) { | |
| 29 | + tr := newTranscript(80) | |
| 30 | + tr.appendDelta("let me look\n") | |
| 31 | + tr.startTool("grep", "pattern=foo") | |
| 32 | + tr.appendDelta("found it") | |
| 33 | + | |
| 34 | + if len(tr.entries) != 3 { | |
| 35 | + t.Fatalf("got %d entries, want 3", len(tr.entries)) | |
| 36 | + } | |
| 37 | + if tr.entries[0].open { | |
| 38 | + t.Error("first text entry still open after a tool call") | |
| 39 | + } | |
| 40 | + if tr.entries[0].text != "let me look" { | |
| 41 | + t.Errorf("trailing newline not trimmed: %q", tr.entries[0].text) | |
| 42 | + } | |
| 43 | + if tr.entries[2].kind != entryAssistant { | |
| 44 | + t.Error("text after a tool call should start a new assistant entry") | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + func TestFinishToolMatchesMostRecentOpenCall(t *testing.T) { | |
| 49 | + tr := newTranscript(80) | |
| 50 | + tr.startTool("read_file", "path=a.go") | |
| 51 | + tr.finishTool("read_file", "1: package a", false) | |
| 52 | + tr.startTool("read_file", "path=b.go") | |
| 53 | + | |
| 54 | + tr.finishTool("read_file", "1: package b", false) | |
| 55 | + | |
| 56 | + if got := tr.entries[0].result; got != "1: package a" { | |
| 57 | + t.Errorf("first call result overwritten: %q", got) | |
| 58 | + } | |
| 59 | + if got := tr.entries[1].result; got != "1: package b" { | |
| 60 | + t.Errorf("second call result = %q", got) | |
| 61 | + } | |
| 62 | + for i, e := range tr.entries { | |
| 63 | + if e.open { | |
| 64 | + t.Errorf("entry %d still open", i) | |
| 65 | + } | |
| 66 | + } | |
| 67 | + } | |
| 68 | + | |
| 69 | + // A result with no matching call still has to appear. Dropping it would hide | |
| 70 | + // evidence of what the agent actually did. | |
| 71 | + func TestFinishToolWithoutStartStillRecords(t *testing.T) { | |
| 72 | + tr := newTranscript(80) | |
| 73 | + tr.finishTool("bash", "boom", true) | |
| 74 | + | |
| 75 | + if len(tr.entries) != 1 { | |
| 76 | + t.Fatalf("got %d entries, want 1", len(tr.entries)) | |
| 77 | + } | |
| 78 | + if !tr.entries[0].isError || tr.entries[0].result != "boom" { | |
| 79 | + t.Errorf("orphan result not recorded: %+v", tr.entries[0]) | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + func TestRenderCollapsesLongOutputAndVerboseExpands(t *testing.T) { | |
| 84 | + long := strings.Repeat("line\n", 40) | |
| 85 | + tr := newTranscript(100) | |
| 86 | + tr.startTool("bash", "command=go test ./...") | |
| 87 | + tr.finishTool("bash", long, false) | |
| 88 | + | |
| 89 | + out := tr.render() | |
| 90 | + if !strings.Contains(out, "more lines") { | |
| 91 | + t.Error("long output was not collapsed") | |
| 92 | + } | |
| 93 | + | |
| 94 | + tr.setVerbose(true) | |
| 95 | + if got := tr.render(); strings.Contains(got, "more lines") { | |
| 96 | + t.Error("verbose mode still collapsed the output") | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + func TestRenderCachesUntilDirty(t *testing.T) { | |
| 101 | + tr := newTranscript(80) | |
| 102 | + tr.addUser("hello") | |
| 103 | + first := tr.render() | |
| 104 | + | |
| 105 | + if tr.dirty { | |
| 106 | + t.Fatal("still dirty after render") | |
| 107 | + } | |
| 108 | + tr.addUser("again") | |
| 109 | + if !tr.dirty { | |
| 110 | + t.Fatal("adding an entry did not invalidate the cache") | |
| 111 | + } | |
| 112 | + if second := tr.render(); second == first { | |
| 113 | + t.Error("render returned stale content after a new entry") | |
| 114 | + } | |
| 115 | + } | |
| 116 | + | |
| 117 | + func TestSetWidthInvalidatesCache(t *testing.T) { | |
| 118 | + tr := newTranscript(80) | |
| 119 | + tr.addUser("hello") | |
| 120 | + tr.render() | |
| 121 | + | |
| 122 | + tr.setWidth(80) // unchanged: must not dirty | |
| 123 | + if tr.dirty { | |
| 124 | + t.Error("setting the same width invalidated the cache") | |
| 125 | + } | |
| 126 | + tr.setWidth(40) | |
| 127 | + if !tr.dirty { | |
| 128 | + t.Error("a width change did not invalidate the cache") | |
| 129 | + } | |
| 130 | + } | |
| 131 | + | |
| 132 | + func TestDiffIsRenderedAsDiff(t *testing.T) { | |
| 133 | + diff := "--- a/x.go\n+++ b/x.go\n@@ -1 +1 @@\n-old\n+new\n" | |
| 134 | + if !looksLikeDiff(diff) { | |
| 135 | + t.Fatal("looksLikeDiff rejected a unified diff") | |
| 136 | + } | |
| 137 | + if looksLikeDiff("just a message") { | |
| 138 | + t.Error("looksLikeDiff accepted plain text") | |
| 139 | + } | |
| 140 | + | |
| 141 | + tr := newTranscript(80) | |
| 142 | + tr.startTool("edit_file", "path=x.go") | |
| 143 | + tr.finishTool("edit_file", diff, false) | |
| 144 | + out := tr.render() | |
| 145 | + | |
| 146 | + for _, want := range []string{"-old", "+new", "@@ -1 +1 @@"} { | |
| 147 | + if !strings.Contains(out, want) { | |
| 148 | + t.Errorf("diff line %q missing from render", want) | |
| 149 | + } | |
| 150 | + } | |
| 151 | + // The ⎿ prefix belongs to summarised output, not to a diff. | |
| 152 | + if strings.Contains(out, "⎿") { | |
| 153 | + t.Error("diff was rendered through the summary path") | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 157 | + func TestSummarizeInputIsSorted(t *testing.T) { | |
| 158 | + in := json.RawMessage(`{"path":"x.go","limit":10,"offset":2}`) | |
| 159 | + if got, want := summarizeInput(in), "limit=10, offset=2, path=x.go"; got != want { | |
| 160 | + t.Errorf("summarizeInput = %q, want %q", got, want) | |
| 161 | + } | |
| 162 | + } | |
| 163 | + | |
| 164 | + func TestSummarizeInputFlattensNewlines(t *testing.T) { | |
| 165 | + in := json.RawMessage(`{"command":"go build\ngo test"}`) | |
| 166 | + if got := summarizeInput(in); strings.Contains(got, "\n") { | |
| 167 | + t.Errorf("newline survived into the header line: %q", got) | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 171 | + func TestSummarizeInputHandlesGarbage(t *testing.T) { | |
| 172 | + if got := summarizeInput(json.RawMessage(`not json`)); got != "not json" { | |
| 173 | + t.Errorf("garbage input = %q, want it passed through", got) | |
| 174 | + } | |
| 175 | + if got := summarizeInput(nil); got != "" { | |
| 176 | + t.Errorf("nil input = %q, want empty", got) | |
| 177 | + } | |
| 178 | + } | |
| 179 | + | |
| 180 | + func TestTruncateCountsRunesNotBytes(t *testing.T) { | |
| 181 | + // Slicing this by bytes would produce invalid UTF-8. | |
| 182 | + if got := truncate("日本語テキスト", 4); len([]rune(got)) != 4 { | |
| 183 | + t.Errorf("truncate returned %d runes, want 4 (%q)", len([]rune(got)), got) | |
| 184 | + } | |
| 185 | + if got := truncate("short", 50); got != "short" { | |
| 186 | + t.Errorf("truncate shortened a short string: %q", got) | |
| 187 | + } | |
| 188 | + if got := truncate("abc", 0); got != "" { | |
| 189 | + t.Errorf("truncate(_, 0) = %q, want empty", got) | |
| 190 | + } | |
| 191 | + } | |
| 192 | + | |
| 193 | + // An approval gates a call before it runs, so it must render with the call | |
| 194 | + // rather than after the result that came back later. | |
| 195 | + func TestAnnotateToolAttachesToTheGatedCall(t *testing.T) { | |
| 196 | + tr := newTranscript(80) | |
| 197 | + tr.startTool("bash", "command=go build ./...") | |
| 198 | + tr.annotateTool("bash", "approved once") | |
| 199 | + tr.finishTool("bash", "ok", false) | |
| 200 | + | |
| 201 | + if len(tr.entries) != 1 { | |
| 202 | + t.Fatalf("got %d entries, want the note folded into the call", len(tr.entries)) | |
| 203 | + } | |
| 204 | + if tr.entries[0].note != "approved once" { | |
| 205 | + t.Errorf("note = %q", tr.entries[0].note) | |
| 206 | + } | |
| 207 | + | |
| 208 | + out := tr.render() | |
| 209 | + note, result := strings.Index(out, "approved once"), strings.Index(out, "ok") | |
| 210 | + if note < 0 || result < 0 { | |
| 211 | + t.Fatalf("note or result missing from render:\n%s", out) | |
| 212 | + } | |
| 213 | + if note > result { | |
| 214 | + t.Error("the approval rendered after the result it gated") | |
| 215 | + } | |
| 216 | + } | |
| 217 | + | |
| 218 | + func TestAnnotateToolWithoutOpenCallFallsBackToANotice(t *testing.T) { | |
| 219 | + tr := newTranscript(80) | |
| 220 | + tr.annotateTool("bash", "denied") | |
| 221 | + | |
| 222 | + if len(tr.entries) != 1 || tr.entries[0].kind != entryNotice { | |
| 223 | + t.Fatalf("orphan note not recorded: %+v", tr.entries) | |
| 224 | + } | |
| 225 | + } | |
| 226 | + | |
| 227 | + // Styled text measured by rune count treats every escape byte as a visible | |
| 228 | + // cell, which silently eats the end of a status line. | |
| 229 | + func TestTruncateStyledMeasuresDisplayWidth(t *testing.T) { | |
| 230 | + styled := styDim.Render("ready") + " · " + styCoral.Render("$0.0241") | |
| 231 | + plain := "ready · $0.0241" | |
| 232 | + | |
| 233 | + // Compare display width against display width: len() would count the | |
| 234 | + // two-byte · as two cells and make this assertion wrong rather than strict. | |
| 235 | + if got, want := lipgloss.Width(truncateStyled(styled, 40)), lipgloss.Width(plain); got != want { | |
| 236 | + t.Errorf("width %d, want %d — styled text was cut though it fits", got, want) | |
| 237 | + } | |
| 238 | + if got := truncateStyled(styled, 40); !strings.Contains(got, "$0.0241") { | |
| 239 | + t.Errorf("cost was truncated away though it fits: %q", got) | |
| 240 | + } | |
| 241 | + if got := truncateStyled(styled, 8); lipgloss.Width(got) > 8 { | |
| 242 | + t.Errorf("width %d exceeds the limit of 8", lipgloss.Width(got)) | |
| 243 | + } | |
| 244 | + if got := truncateStyled(styled, 0); got != "" { | |
| 245 | + t.Errorf("zero width = %q, want empty", got) | |
| 246 | + } | |
| 247 | + } | |
| 248 | + | |
| 249 | + func TestRenderNarrowWidthDoesNotPanic(t *testing.T) { | |
| 250 | + tr := newTranscript(1) | |
| 251 | + tr.addUser("a reasonably long line that must wrap somewhere") | |
| 252 | + tr.startTool("bash", "command=echo hi") | |
| 253 | + tr.finishTool("bash", "hi", false) | |
| 254 | + tr.addError("boom") | |
| 255 | + _ = tr.render() | |
| 256 | + } | |