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
85// A streamed bash command can carry tabs (heredocs of Go code) and
86// raw escapes. They must not reach the terminal: a tab advances the
87// cursor without erasing, so the box edge and stale cells glitch.
88func TestLiveBashCommandWithTabsKeepsBoxRowsAligned(t *testing.T) {
89 args, _ := json.Marshal(map[string]string{
90 "command": "python3 - <<'EOF'\nold=\"\"\"\t\t\tdrainAllFrames(sends)\n\tif tt.saturated {\x1b[2J\r\nEOF",
91 })
92 const width = 100
93 v := View{
94 Theme: Dark,
95 ToolCalls: []ToolCallView{
96 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), RawJSONBuf: string(args)},
97 },
98 }
99 assertTerminalSafeRows(t, v.BuildLive(width), width)
100}
101
102func TestLiveToolCacheSeparatesCallsAtTheSameRevision(t *testing.T) {
103 v := View{Theme: Dark, ToolCalls: []ToolCallView{
104 {
105 ID: "toolu_alpha",
106 Name: "edit",
107 Revision: 7,
108 RawJSONBuf: `{"path":"alpha.go","edits":[{"oldText":"old","newText":"alpha content"}]}`,
109 LivePath: "alpha.go",
110 },
111 {
112 ID: "toolu_beta",
113 Name: "edit",
114 Revision: 7,
115 RawJSONBuf: `{"path":"beta.go","edits":[{"oldText":"old","newText":"beta content"}]}`,
116 LivePath: "beta.go",
117 },
118 }}
119
120 plain := stripANSI(strings.Join(v.BuildLive(80), "\n"))
121 for _, want := range []string{"alpha.go", "alpha content", "beta.go", "beta content"} {
122 if !strings.Contains(plain, want) {
123 t.Fatalf("live tool cache aliased calls at the same revision; missing %q:\n%s", want, plain)
124 }
125 }
126}
127
128func TestLiveToolCacheDropsSupersededAndInactiveCalls(t *testing.T) {
129 v := View{Theme: Dark, ToolCalls: []ToolCallView{{
130 ID: "toolu_1",
131 Name: "edit",
132 Revision: 1,
133 RawJSONBuf: `{"path":"sample.go","edits":[{"oldText":"old","newText":"first"}]}`,
134 LivePath: "sample.go",
135 }}}
136 v.BuildLive(80)
137
138 v.ToolCalls[0].Revision = 2
139 v.ToolCalls[0].RawJSONBuf = `{"path":"sample.go","edits":[{"oldText":"old","newText":"second"}]}`
140 v.BuildLive(80)
141 for key := range v.liveRenderCache {
142 if key.callID != "toolu_1" || key.revision != 2 {
143 t.Fatalf("live cache retained superseded entry: %+v", key)
144 }
145 }
146
147 v.ToolCalls = nil
148 v.BuildLive(80)
149 if len(v.liveRenderCache) != 0 {
150 t.Fatalf("live cache retained %d entries after all calls disappeared", len(v.liveRenderCache))
151 }
152 if len(v.liveBodyHigh) != 0 {
153 t.Fatalf("live height reservations retained %d inactive calls", len(v.liveBodyHigh))
154 }
155}
156
157func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) {
158 v := View{Theme: Dark}
159 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
160 v.ToolCalls = []ToolCallView{
161 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
162 }
163 tallRows := len(v.Build(80))
164 if tallRows == 0 {
165 t.Fatal("expected rows for the tall command")
166 }
167
168 // Same call id, but the streamed command shrinks (a transient
169 // mid-stream state). The box must keep its high-water height so the
170 // editor/status band below doesn't bounce.
171 short := json.RawMessage(`{"command":"echo hi"}`)
172 v.ToolCalls = []ToolCallView{
173 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)},
174 }
175 if got := len(v.Build(80)); got < tallRows {
176 t.Fatalf("live box shrank mid-stream from %d to %d rows", tallRows, got)
177 }
178}
179
180func TestLiveEditOverlayKeepsStableHeightWhileStreaming(t *testing.T) {
181 v := View{Theme: Dark, ToolCalls: []ToolCallView{{
182 ID: "toolu_1",
183 Name: "edit",
184 Revision: 1,
185 RawJSONBuf: `{"path":"sample.go","edits":[{"oldText":"old","newText":"line 1"}]}`,
186 LivePath: "sample.go",
187 }}}
188 shortRows := len(v.BuildLive(80))
189
190 v.ToolCalls[0].Revision = 2
191 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"}]}`
192 longRows := len(v.BuildLive(80))
193
194 if longRows != shortRows {
195 t.Fatalf("live edit changed height while streaming: short=%d rows, long=%d rows", shortRows, longRows)
196 }
197}
198
199func TestCompletedASTRewriteRendersPersistentDiff(t *testing.T) {
200 args := json.RawMessage(`{"pattern":"old($A)","language":"go","path":"sample.go","rewrite":"new($A)"}`)
201 v := View{
202 Theme: Dark,
203 Messages: []provider.Message{
204 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "toolu_ast", Name: "ast", Arguments: args}}},
205 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
206 CallID: "toolu_ast",
207 Content: []provider.Content{provider.TextBlock{Text: " old(value)\n-old(value)\n+new(value)\n"}},
208 }}},
209 },
210 }
211
212 rendered := strings.Join(v.Build(80), "\n")
213 plain := stripANSI(rendered)
214 for _, want := range []string{"ast sample.go", "- 2 old(value)", "+ 2 new(value)"} {
215 if !strings.Contains(plain, want) {
216 t.Fatalf("completed AST rewrite missing %q:\n%s", want, plain)
217 }
218 }
219}
220
221func TestMultiFileRewriteLabelShowsChangedFileCount(t *testing.T) {
222 args := json.RawMessage(`{"pattern":"old($A)","language":"go","path":"src","rewrite":"new($A)"}`)
223 for _, tc := range []struct {
224 name string
225 mutates []string
226 want bool
227 }{
228 {"several files", []string{"src/a.go", "src/b.go", "src/c.go"}, true},
229 {"single file", []string{"src/a.go"}, false},
230 } {
231 t.Run(tc.name, func(t *testing.T) {
232 for _, flat := range []bool{false, true} {
233 v := View{
234 Theme: Dark,
235 FlatTools: flat,
236 Messages: []provider.Message{
237 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "toolu_ast", Name: "ast", Arguments: args}}},
238 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
239 CallID: "toolu_ast",
240 Content: []provider.Content{provider.TextBlock{Text: "-old(value)\n+new(value)\n"}},
241 Context: provider.ToolContext{Mutates: tc.mutates},
242 }}},
243 },
244 }
245 plain := stripANSI(strings.Join(v.Build(80), "\n"))
246 if got := strings.Contains(plain, "ast src · 3 files changed"); got != tc.want {
247 t.Fatalf("flat=%v: file count shown = %v, want %v:\n%s", flat, got, tc.want, plain)
248 }
249 if strings.Contains(plain, "files changed") != tc.want {
250 t.Fatalf("flat=%v: unexpected file count label:\n%s", flat, plain)
251 }
252 }
253 })
254 }
255}
256
257func TestASTSearchResultIsNotForcedIntoDiffRendering(t *testing.T) {
258 args := json.RawMessage(`{"pattern":"old($A)","language":"go","path":"sample.go"}`)
259 v := View{
260 Theme: Dark,
261 Messages: []provider.Message{
262 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "toolu_ast", Name: "ast", Arguments: args}}},
263 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
264 CallID: "toolu_ast",
265 Content: []provider.Content{provider.TextBlock{Text: "sample.go:1: old(value)"}},
266 }}},
267 },
268 }
269
270 plain := stripANSI(strings.Join(v.Build(80), "\n"))
271 if !strings.Contains(plain, "sample.go:1: old(value)") {
272 t.Fatalf("AST search result was hidden or rewritten:\n%s", plain)
273 }
274}
275
276func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) {
277 args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`)
278 v := View{
279 Theme: Dark,
280 ToolCalls: []ToolCallView{{
281 ID: "toolu_1",
282 Name: "edit",
283 RawJSONBuf: string(args),
284 LivePath: "sample.go",
285 Preview: "-old value\n+new value\n",
286 }},
287 }
288
289 rendered := strings.Join(v.Build(80), "\n")
290 plain := stripANSI(rendered)
291 for _, want := range []string{"- 1 old value", "+ 1 new value"} {
292 if !strings.Contains(plain, want) {
293 t.Fatalf("confirmation preview missing numbered diff row %q:\n%s", want, plain)
294 }
295 }
296 for _, want := range []string{
297 Dark.FGColor(Dark.Error, "- 1 "),
298 Dark.FGColor(Dark.Tool, "+ 1 "),
299 } {
300 if !strings.Contains(rendered, want) {
301 t.Fatalf("confirmation preview missing diff color %q:\n%s", want, rendered)
302 }
303 }
304 if strings.Contains(plain, "edit 1 (streaming)") {
305 t.Fatalf("confirmation still showed the newText streaming view:\n%s", plain)
306 }
307}
308
309func TestLiveToolReservationResetsWhenExpansionChanges(t *testing.T) {
310 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"}`)
311 v := View{
312 Theme: Dark,
313 ToolCalls: []ToolCallView{
314 {ID: "toolu_1", Name: "write", RawJSONBuf: string(args), LivePath: "sample.ts"},
315 },
316 }
317
318 collapsedRows := len(v.Build(80))
319 v.ExpandAll = true
320 expandedRows := len(v.Build(80))
321 if expandedRows <= collapsedRows {
322 t.Fatalf("expanded live box did not grow: collapsed=%d expanded=%d", collapsedRows, expandedRows)
323 }
324
325 v.ExpandAll = false
326 if got := len(v.Build(80)); got != collapsedRows {
327 t.Fatalf("collapsed live box kept expanded height: got %d rows, want %d", got, collapsedRows)
328 }
329}
330
331func TestLiveToolReservationDoesNotLeakToNextCall(t *testing.T) {
332 v := View{Theme: Dark}
333 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
334 v.ToolCalls = []ToolCallView{
335 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
336 }
337 tallRows := len(v.Build(80))
338
339 // A *different* tool call that is short must NOT inherit the tall
340 // call's reservation; it renders at its own natural height. (This
341 // is the regression that produced a giant empty box.)
342 short := json.RawMessage(`{"command":"echo hi"}`)
343 v.ToolCalls = []ToolCallView{
344 {ID: "toolu_2", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)},
345 }
346 if got := len(v.Build(80)); got >= tallRows {
347 t.Fatalf("short call inherited prior call's height: %d rows (tall was %d)", got, tallRows)
348 }
349}
350
351func TestLiveToolReservationDropsOnResult(t *testing.T) {
352 v := View{Theme: Dark}
353 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
354 v.ToolCalls = []ToolCallView{
355 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
356 }
357 tallRows := len(v.Build(80))
358
359 // Same id, now finalised with a tiny error result. The box must
360 // collapse to the natural result height instead of staying padded.
361 v.ToolCalls = []ToolCallView{
362 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall), Done: true, Error: true, Result: "boom"},
363 }
364 if got := len(v.Build(80)); got >= tallRows {
365 t.Fatalf("finalised box kept live reservation: %d rows (live was %d)", got, tallRows)
366 }
367}
368
369func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) {
370 args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`)
371 v := View{
372 Theme: Dark,
373 Messages: []provider.Message{
374 {
375 Role: provider.RoleAssistant,
376 Content: []provider.Content{
377 provider.ToolCallBlock{ID: "toolu_1", Name: "write", Arguments: args},
378 },
379 },
380 },
381 ToolCalls: []ToolCallView{
382 {
383 ID: "toolu_1",
384 Name: "write",
385 Args: ShortArgs("write", args),
386 Streaming: false,
387 RawJSONBuf: string(args),
388 LivePath: "/tmp/sample.ts",
389 },
390 },
391 }
392
393 plain := stripANSI(strings.Join(v.Build(80), "\n"))
394 if !strings.Contains(plain, "export const n = 1") {
395 t.Fatalf("write preview collapsed after tool args ended but before tool_result arrived:\n%s", plain)
396 }
397}
398
399func TestLiveToolOverlayHidesAfterToolResult(t *testing.T) {
400 args := json.RawMessage(`{"command":"sleep 1"}`)
401 v := View{
402 Theme: Dark,
403 Messages: []provider.Message{
404 {
405 Role: provider.RoleAssistant,
406 Content: []provider.Content{
407 provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: args},
408 },
409 },
410 {
411 Role: provider.RoleTool,
412 Content: []provider.Content{
413 provider.ToolResultBlock{
414 CallID: "toolu_1",
415 Content: []provider.Content{provider.TextBlock{Text: "done"}},
416 },
417 },
418 },
419 },
420 ToolCalls: []ToolCallView{
421 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "done", Done: true},
422 },
423 }
424
425 plain := stripANSI(strings.Join(v.BuildLive(80), "\n"))
426 if strings.Contains(plain, "bash sleep 1") {
427 t.Fatalf("live tool overlay still rendered after tool_result was appended:\n%s", plain)
428 }
429}