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 TestASTSearchResultIsNotForcedIntoDiffRendering(t *testing.T) {
222 args := json.RawMessage(`{"pattern":"old($A)","language":"go","path":"sample.go"}`)
223 v := View{
224 Theme: Dark,
225 Messages: []provider.Message{
226 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "toolu_ast", Name: "ast", Arguments: args}}},
227 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
228 CallID: "toolu_ast",
229 Content: []provider.Content{provider.TextBlock{Text: "sample.go:1: old(value)"}},
230 }}},
231 },
232 }
233
234 plain := stripANSI(strings.Join(v.Build(80), "\n"))
235 if !strings.Contains(plain, "sample.go:1: old(value)") {
236 t.Fatalf("AST search result was hidden or rewritten:\n%s", plain)
237 }
238}
239
240func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) {
241 args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`)
242 v := View{
243 Theme: Dark,
244 ToolCalls: []ToolCallView{{
245 ID: "toolu_1",
246 Name: "edit",
247 RawJSONBuf: string(args),
248 LivePath: "sample.go",
249 Preview: "-old value\n+new value\n",
250 }},
251 }
252
253 rendered := strings.Join(v.Build(80), "\n")
254 plain := stripANSI(rendered)
255 for _, want := range []string{"- 1 old value", "+ 1 new value"} {
256 if !strings.Contains(plain, want) {
257 t.Fatalf("confirmation preview missing numbered diff row %q:\n%s", want, plain)
258 }
259 }
260 for _, want := range []string{
261 Dark.FGColor(Dark.Error, "- 1 "),
262 Dark.FGColor(Dark.Tool, "+ 1 "),
263 } {
264 if !strings.Contains(rendered, want) {
265 t.Fatalf("confirmation preview missing diff color %q:\n%s", want, rendered)
266 }
267 }
268 if strings.Contains(plain, "edit 1 (streaming)") {
269 t.Fatalf("confirmation still showed the newText streaming view:\n%s", plain)
270 }
271}
272
273func TestLiveToolReservationResetsWhenExpansionChanges(t *testing.T) {
274 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"}`)
275 v := View{
276 Theme: Dark,
277 ToolCalls: []ToolCallView{
278 {ID: "toolu_1", Name: "write", RawJSONBuf: string(args), LivePath: "sample.ts"},
279 },
280 }
281
282 collapsedRows := len(v.Build(80))
283 v.ExpandAll = true
284 expandedRows := len(v.Build(80))
285 if expandedRows <= collapsedRows {
286 t.Fatalf("expanded live box did not grow: collapsed=%d expanded=%d", collapsedRows, expandedRows)
287 }
288
289 v.ExpandAll = false
290 if got := len(v.Build(80)); got != collapsedRows {
291 t.Fatalf("collapsed live box kept expanded height: got %d rows, want %d", got, collapsedRows)
292 }
293}
294
295func TestLiveToolReservationDoesNotLeakToNextCall(t *testing.T) {
296 v := View{Theme: Dark}
297 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
298 v.ToolCalls = []ToolCallView{
299 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
300 }
301 tallRows := len(v.Build(80))
302
303 // A *different* tool call that is short must NOT inherit the tall
304 // call's reservation; it renders at its own natural height. (This
305 // is the regression that produced a giant empty box.)
306 short := json.RawMessage(`{"command":"echo hi"}`)
307 v.ToolCalls = []ToolCallView{
308 {ID: "toolu_2", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)},
309 }
310 if got := len(v.Build(80)); got >= tallRows {
311 t.Fatalf("short call inherited prior call's height: %d rows (tall was %d)", got, tallRows)
312 }
313}
314
315func TestLiveToolReservationDropsOnResult(t *testing.T) {
316 v := View{Theme: Dark}
317 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
318 v.ToolCalls = []ToolCallView{
319 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
320 }
321 tallRows := len(v.Build(80))
322
323 // Same id, now finalised with a tiny error result. The box must
324 // collapse to the natural result height instead of staying padded.
325 v.ToolCalls = []ToolCallView{
326 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall), Done: true, Error: true, Result: "boom"},
327 }
328 if got := len(v.Build(80)); got >= tallRows {
329 t.Fatalf("finalised box kept live reservation: %d rows (live was %d)", got, tallRows)
330 }
331}
332
333func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) {
334 args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`)
335 v := View{
336 Theme: Dark,
337 Messages: []provider.Message{
338 {
339 Role: provider.RoleAssistant,
340 Content: []provider.Content{
341 provider.ToolCallBlock{ID: "toolu_1", Name: "write", Arguments: args},
342 },
343 },
344 },
345 ToolCalls: []ToolCallView{
346 {
347 ID: "toolu_1",
348 Name: "write",
349 Args: ShortArgs("write", args),
350 Streaming: false,
351 RawJSONBuf: string(args),
352 LivePath: "/tmp/sample.ts",
353 },
354 },
355 }
356
357 plain := stripANSI(strings.Join(v.Build(80), "\n"))
358 if !strings.Contains(plain, "export const n = 1") {
359 t.Fatalf("write preview collapsed after tool args ended but before tool_result arrived:\n%s", plain)
360 }
361}
362
363func TestLiveToolOverlayHidesAfterToolResult(t *testing.T) {
364 args := json.RawMessage(`{"command":"sleep 1"}`)
365 v := View{
366 Theme: Dark,
367 Messages: []provider.Message{
368 {
369 Role: provider.RoleAssistant,
370 Content: []provider.Content{
371 provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: args},
372 },
373 },
374 {
375 Role: provider.RoleTool,
376 Content: []provider.Content{
377 provider.ToolResultBlock{
378 CallID: "toolu_1",
379 Content: []provider.Content{provider.TextBlock{Text: "done"}},
380 },
381 },
382 },
383 },
384 ToolCalls: []ToolCallView{
385 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "done", Done: true},
386 },
387 }
388
389 plain := stripANSI(strings.Join(v.BuildLive(80), "\n"))
390 if strings.Contains(plain, "bash sleep 1") {
391 t.Fatalf("live tool overlay still rendered after tool_result was appended:\n%s", plain)
392 }
393}