Vectrum
Sign In

tui: /skills, and /<slug> as a user turn

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>
Jjonicommitted 1 parent ab6fdfacommit62ffa3e

Showing 12 changed files with 2922 additions and 0 deletions.

+100−0internal/tui/bridge.goaddedView File
@@ -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+ }
+179−0internal/tui/bridge_test.goaddedView File
@@ -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+ )
+106−0internal/tui/colour_test.goaddedView File
@@ -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+ }
+139−0internal/tui/format.goaddedView File
@@ -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+ }
+311−0internal/tui/look_test.goaddedView File
@@ -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+ }
+787−0internal/tui/model.goaddedView File
@@ -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+ }
+419−0internal/tui/model_test.goaddedView File
@@ -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+ }
+64−0internal/tui/splash.goaddedView File
@@ -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) }
+77−0internal/tui/styles.goaddedView File
@@ -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+ )
+104−0internal/tui/testdata/mockapi/main.goaddedView File
@@ -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+ }
+380−0internal/tui/transcript.goaddedView File
@@ -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+ }
+256−0internal/tui/transcript_test.goaddedView File
@@ -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+ }