1package tui
2
3import (
4 "encoding/json"
5 "strings"
6 "testing"
7
8 "github.com/bnema/zut/packages/provider"
9)
10
11func TestStartupResourcesRenderAsCompactSections(t *testing.T) {
12 v := View{
13 Theme: Dark,
14 StartupAgentName: "zut-maintenance",
15 StartupContextPaths: []string{
16 "/home/user/AGENTS.md",
17 "/repo/AGENTS.md",
18 },
19 StartupExtensionNames: []string{"todo", "workspaces"},
20 StartupSkillNames: []string{"review", "test"},
21 }
22
23 plain := stripANSI(strings.Join(v.Build(80), "\n"))
24 for _, want := range []string{
25 "[Agent]", "zut-maintenance",
26 "[Context]", "/home/user/AGENTS.md, /repo/AGENTS.md",
27 "[Extensions]", "todo, workspaces",
28 "[Skills]", "review, test",
29 } {
30 if !strings.Contains(plain, want) {
31 t.Fatalf("startup resources missing %q:\n%s", want, plain)
32 }
33 }
34 if strings.ContainsAny(plain, boxGlyphs) || strings.Contains(plain, "read ") {
35 t.Fatalf("startup resources rendered as a tool call:\n%s", plain)
36 }
37 if len(v.Messages) != 0 {
38 t.Fatalf("startup resources added %d transcript messages", len(v.Messages))
39 }
40}
41
42func TestLiveToolOverlayRemainsAfterAssistantToolUse(t *testing.T) {
43 args := json.RawMessage(`{"command":"sleep 1"}`)
44 v := View{
45 Theme: Dark,
46 Messages: []provider.Message{
47 {
48 Role: provider.RoleAssistant,
49 Content: []provider.Content{
50 provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: args},
51 },
52 },
53 },
54 ToolCalls: []ToolCallView{
55 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Done: false},
56 },
57 }
58
59 plain := stripANSI(strings.Join(v.Build(80), "\n"))
60 if !strings.Contains(plain, "bash sleep 1") {
61 t.Fatalf("live tool overlay disappeared after assistant tool_use was appended:\n%s", plain)
62 }
63}
64
65func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) {
66 args := json.RawMessage(`{"command":"printf 'start' && sleep 60 && printf 'done with a command long enough to exceed the header truncation limit'"}`)
67 v := View{
68 Theme: Dark,
69 ToolCalls: []ToolCallView{
70 {
71 ID: "toolu_1",
72 Name: "bash",
73 Args: ShortArgs("bash", args),
74 RawJSONBuf: string(args),
75 },
76 },
77 }
78
79 plain := stripANSI(strings.Join(v.BuildLive(100), "\n"))
80 if !strings.Contains(plain, "$ printf 'start' && sleep 60") || !strings.Contains(plain, "header truncation limit") {
81 t.Fatalf("bash command was not visible before result arrived:\n%s", plain)
82 }
83}
84
85func TestLiveToolCacheSeparatesCallsAtTheSameRevision(t *testing.T) {
86 v := View{Theme: Dark, ToolCalls: []ToolCallView{
87 {
88 ID: "toolu_alpha",
89 Name: "edit",
90 Revision: 7,
91 RawJSONBuf: `{"path":"alpha.go","edits":[{"oldText":"old","newText":"alpha content"}]}`,
92 LivePath: "alpha.go",
93 },
94 {
95 ID: "toolu_beta",
96 Name: "edit",
97 Revision: 7,
98 RawJSONBuf: `{"path":"beta.go","edits":[{"oldText":"old","newText":"beta content"}]}`,
99 LivePath: "beta.go",
100 },
101 }}
102
103 plain := stripANSI(strings.Join(v.BuildLive(80), "\n"))
104 for _, want := range []string{"alpha.go", "alpha content", "beta.go", "beta content"} {
105 if !strings.Contains(plain, want) {
106 t.Fatalf("live tool cache aliased calls at the same revision; missing %q:\n%s", want, plain)
107 }
108 }
109}
110
111func TestLiveToolCacheDropsSupersededAndInactiveCalls(t *testing.T) {
112 v := View{Theme: Dark, ToolCalls: []ToolCallView{{
113 ID: "toolu_1",
114 Name: "edit",
115 Revision: 1,
116 RawJSONBuf: `{"path":"sample.go","edits":[{"oldText":"old","newText":"first"}]}`,
117 LivePath: "sample.go",
118 }}}
119 v.BuildLive(80)
120
121 v.ToolCalls[0].Revision = 2
122 v.ToolCalls[0].RawJSONBuf = `{"path":"sample.go","edits":[{"oldText":"old","newText":"second"}]}`
123 v.BuildLive(80)
124 for key := range v.liveRenderCache {
125 if key.callID != "toolu_1" || key.revision != 2 {
126 t.Fatalf("live cache retained superseded entry: %+v", key)
127 }
128 }
129
130 v.ToolCalls = nil
131 v.BuildLive(80)
132 if len(v.liveRenderCache) != 0 {
133 t.Fatalf("live cache retained %d entries after all calls disappeared", len(v.liveRenderCache))
134 }
135 if len(v.liveBodyHigh) != 0 {
136 t.Fatalf("live height reservations retained %d inactive calls", len(v.liveBodyHigh))
137 }
138}
139
140func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) {
141 v := View{Theme: Dark}
142 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
143 v.ToolCalls = []ToolCallView{
144 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
145 }
146 tallRows := len(v.Build(80))
147 if tallRows == 0 {
148 t.Fatal("expected rows for the tall command")
149 }
150
151 // Same call id, but the streamed command shrinks (a transient
152 // mid-stream state). The box must keep its high-water height so the
153 // editor/status band below doesn't bounce.
154 short := json.RawMessage(`{"command":"echo hi"}`)
155 v.ToolCalls = []ToolCallView{
156 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)},
157 }
158 if got := len(v.Build(80)); got < tallRows {
159 t.Fatalf("live box shrank mid-stream from %d to %d rows", tallRows, got)
160 }
161}
162
163func TestLiveEditOverlayKeepsStableHeightWhileStreaming(t *testing.T) {
164 v := View{Theme: Dark, ToolCalls: []ToolCallView{{
165 ID: "toolu_1",
166 Name: "edit",
167 Revision: 1,
168 RawJSONBuf: `{"path":"sample.go","edits":[{"oldText":"old","newText":"line 1"}]}`,
169 LivePath: "sample.go",
170 }}}
171 shortRows := len(v.BuildLive(80))
172
173 v.ToolCalls[0].Revision = 2
174 v.ToolCalls[0].RawJSONBuf = `{"path":"sample.go","edits":[{"oldText":"old","newText":"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14"}]}`
175 longRows := len(v.BuildLive(80))
176
177 if longRows != shortRows {
178 t.Fatalf("live edit changed height while streaming: short=%d rows, long=%d rows", shortRows, longRows)
179 }
180}
181
182func TestCompletedASTRewriteRendersPersistentDiff(t *testing.T) {
183 args := json.RawMessage(`{"pattern":"old($A)","language":"go","path":"sample.go","rewrite":"new($A)"}`)
184 v := View{
185 Theme: Dark,
186 Messages: []provider.Message{
187 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "toolu_ast", Name: "ast", Arguments: args}}},
188 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
189 CallID: "toolu_ast",
190 Content: []provider.Content{provider.TextBlock{Text: " old(value)\n-old(value)\n+new(value)\n"}},
191 }}},
192 },
193 }
194
195 rendered := strings.Join(v.Build(80), "\n")
196 plain := stripANSI(rendered)
197 for _, want := range []string{"ast sample.go", "- 2 old(value)", "+ 2 new(value)"} {
198 if !strings.Contains(plain, want) {
199 t.Fatalf("completed AST rewrite missing %q:\n%s", want, plain)
200 }
201 }
202}
203
204func TestASTSearchResultIsNotForcedIntoDiffRendering(t *testing.T) {
205 args := json.RawMessage(`{"pattern":"old($A)","language":"go","path":"sample.go"}`)
206 v := View{
207 Theme: Dark,
208 Messages: []provider.Message{
209 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "toolu_ast", Name: "ast", Arguments: args}}},
210 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
211 CallID: "toolu_ast",
212 Content: []provider.Content{provider.TextBlock{Text: "sample.go:1: old(value)"}},
213 }}},
214 },
215 }
216
217 plain := stripANSI(strings.Join(v.Build(80), "\n"))
218 if !strings.Contains(plain, "sample.go:1: old(value)") {
219 t.Fatalf("AST search result was hidden or rewritten:\n%s", plain)
220 }
221}
222
223func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) {
224 args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`)
225 v := View{
226 Theme: Dark,
227 ToolCalls: []ToolCallView{{
228 ID: "toolu_1",
229 Name: "edit",
230 RawJSONBuf: string(args),
231 LivePath: "sample.go",
232 Preview: "-old value\n+new value\n",
233 }},
234 }
235
236 rendered := strings.Join(v.Build(80), "\n")
237 plain := stripANSI(rendered)
238 for _, want := range []string{"- 1 old value", "+ 1 new value"} {
239 if !strings.Contains(plain, want) {
240 t.Fatalf("confirmation preview missing numbered diff row %q:\n%s", want, plain)
241 }
242 }
243 for _, want := range []string{
244 Dark.FGColor(Dark.Error, "- 1 "),
245 Dark.FGColor(Dark.Tool, "+ 1 "),
246 } {
247 if !strings.Contains(rendered, want) {
248 t.Fatalf("confirmation preview missing diff color %q:\n%s", want, rendered)
249 }
250 }
251 if strings.Contains(plain, "edit 1 (streaming)") {
252 t.Fatalf("confirmation still showed the newText streaming view:\n%s", plain)
253 }
254}
255
256func TestLiveToolReservationResetsWhenExpansionChanges(t *testing.T) {
257 args := json.RawMessage(`{"path":"sample.ts","content":"line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12\nline13\nline14\nline15\nline16\nline17\nline18\nline19\nline20"}`)
258 v := View{
259 Theme: Dark,
260 ToolCalls: []ToolCallView{
261 {ID: "toolu_1", Name: "write", RawJSONBuf: string(args), LivePath: "sample.ts"},
262 },
263 }
264
265 collapsedRows := len(v.Build(80))
266 v.ExpandAll = true
267 expandedRows := len(v.Build(80))
268 if expandedRows <= collapsedRows {
269 t.Fatalf("expanded live box did not grow: collapsed=%d expanded=%d", collapsedRows, expandedRows)
270 }
271
272 v.ExpandAll = false
273 if got := len(v.Build(80)); got != collapsedRows {
274 t.Fatalf("collapsed live box kept expanded height: got %d rows, want %d", got, collapsedRows)
275 }
276}
277
278func TestLiveToolReservationDoesNotLeakToNextCall(t *testing.T) {
279 v := View{Theme: Dark}
280 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
281 v.ToolCalls = []ToolCallView{
282 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
283 }
284 tallRows := len(v.Build(80))
285
286 // A *different* tool call that is short must NOT inherit the tall
287 // call's reservation; it renders at its own natural height. (This
288 // is the regression that produced a giant empty box.)
289 short := json.RawMessage(`{"command":"echo hi"}`)
290 v.ToolCalls = []ToolCallView{
291 {ID: "toolu_2", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)},
292 }
293 if got := len(v.Build(80)); got >= tallRows {
294 t.Fatalf("short call inherited prior call's height: %d rows (tall was %d)", got, tallRows)
295 }
296}
297
298func TestLiveToolReservationDropsOnResult(t *testing.T) {
299 v := View{Theme: Dark}
300 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
301 v.ToolCalls = []ToolCallView{
302 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
303 }
304 tallRows := len(v.Build(80))
305
306 // Same id, now finalised with a tiny error result. The box must
307 // collapse to the natural result height instead of staying padded.
308 v.ToolCalls = []ToolCallView{
309 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall), Done: true, Error: true, Result: "boom"},
310 }
311 if got := len(v.Build(80)); got >= tallRows {
312 t.Fatalf("finalised box kept live reservation: %d rows (live was %d)", got, tallRows)
313 }
314}
315
316func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) {
317 args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`)
318 v := View{
319 Theme: Dark,
320 Messages: []provider.Message{
321 {
322 Role: provider.RoleAssistant,
323 Content: []provider.Content{
324 provider.ToolCallBlock{ID: "toolu_1", Name: "write", Arguments: args},
325 },
326 },
327 },
328 ToolCalls: []ToolCallView{
329 {
330 ID: "toolu_1",
331 Name: "write",
332 Args: ShortArgs("write", args),
333 Streaming: false,
334 RawJSONBuf: string(args),
335 LivePath: "/tmp/sample.ts",
336 },
337 },
338 }
339
340 plain := stripANSI(strings.Join(v.Build(80), "\n"))
341 if !strings.Contains(plain, "export const n = 1") {
342 t.Fatalf("write preview collapsed after tool args ended but before tool_result arrived:\n%s", plain)
343 }
344}
345
346func TestLiveToolOverlayHidesAfterToolResult(t *testing.T) {
347 args := json.RawMessage(`{"command":"sleep 1"}`)
348 v := View{
349 Theme: Dark,
350 Messages: []provider.Message{
351 {
352 Role: provider.RoleAssistant,
353 Content: []provider.Content{
354 provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: args},
355 },
356 },
357 {
358 Role: provider.RoleTool,
359 Content: []provider.Content{
360 provider.ToolResultBlock{
361 CallID: "toolu_1",
362 Content: []provider.Content{provider.TextBlock{Text: "done"}},
363 },
364 },
365 },
366 },
367 ToolCalls: []ToolCallView{
368 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "done", Done: true},
369 },
370 }
371
372 plain := stripANSI(strings.Join(v.BuildLive(80), "\n"))
373 if strings.Contains(plain, "bash sleep 1") {
374 t.Fatalf("live tool overlay still rendered after tool_result was appended:\n%s", plain)
375 }
376}