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 TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) {
183 args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`)
184 v := View{
185 Theme: Dark,
186 ToolCalls: []ToolCallView{{
187 ID: "toolu_1",
188 Name: "edit",
189 RawJSONBuf: string(args),
190 LivePath: "sample.go",
191 Preview: "-old value\n+new value\n",
192 }},
193 }
194
195 rendered := strings.Join(v.Build(80), "\n")
196 plain := stripANSI(rendered)
197 for _, want := range []string{"- 1 old value", "+ 1 new value"} {
198 if !strings.Contains(plain, want) {
199 t.Fatalf("confirmation preview missing numbered diff row %q:\n%s", want, plain)
200 }
201 }
202 for _, want := range []string{
203 Dark.FGColor(Dark.Error, "- 1 "),
204 Dark.FGColor(Dark.Tool, "+ 1 "),
205 } {
206 if !strings.Contains(rendered, want) {
207 t.Fatalf("confirmation preview missing diff color %q:\n%s", want, rendered)
208 }
209 }
210 if strings.Contains(plain, "edit 1 (streaming)") {
211 t.Fatalf("confirmation still showed the newText streaming view:\n%s", plain)
212 }
213}
214
215func TestLiveToolReservationResetsWhenExpansionChanges(t *testing.T) {
216 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"}`)
217 v := View{
218 Theme: Dark,
219 ToolCalls: []ToolCallView{
220 {ID: "toolu_1", Name: "write", RawJSONBuf: string(args), LivePath: "sample.ts"},
221 },
222 }
223
224 collapsedRows := len(v.Build(80))
225 v.ExpandAll = true
226 expandedRows := len(v.Build(80))
227 if expandedRows <= collapsedRows {
228 t.Fatalf("expanded live box did not grow: collapsed=%d expanded=%d", collapsedRows, expandedRows)
229 }
230
231 v.ExpandAll = false
232 if got := len(v.Build(80)); got != collapsedRows {
233 t.Fatalf("collapsed live box kept expanded height: got %d rows, want %d", got, collapsedRows)
234 }
235}
236
237func TestLiveToolReservationDoesNotLeakToNextCall(t *testing.T) {
238 v := View{Theme: Dark}
239 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
240 v.ToolCalls = []ToolCallView{
241 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
242 }
243 tallRows := len(v.Build(80))
244
245 // A *different* tool call that is short must NOT inherit the tall
246 // call's reservation; it renders at its own natural height. (This
247 // is the regression that produced a giant empty box.)
248 short := json.RawMessage(`{"command":"echo hi"}`)
249 v.ToolCalls = []ToolCallView{
250 {ID: "toolu_2", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)},
251 }
252 if got := len(v.Build(80)); got >= tallRows {
253 t.Fatalf("short call inherited prior call's height: %d rows (tall was %d)", got, tallRows)
254 }
255}
256
257func TestLiveToolReservationDropsOnResult(t *testing.T) {
258 v := View{Theme: Dark}
259 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
260 v.ToolCalls = []ToolCallView{
261 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
262 }
263 tallRows := len(v.Build(80))
264
265 // Same id, now finalised with a tiny error result. The box must
266 // collapse to the natural result height instead of staying padded.
267 v.ToolCalls = []ToolCallView{
268 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall), Done: true, Error: true, Result: "boom"},
269 }
270 if got := len(v.Build(80)); got >= tallRows {
271 t.Fatalf("finalised box kept live reservation: %d rows (live was %d)", got, tallRows)
272 }
273}
274
275func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) {
276 args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`)
277 v := View{
278 Theme: Dark,
279 Messages: []provider.Message{
280 {
281 Role: provider.RoleAssistant,
282 Content: []provider.Content{
283 provider.ToolCallBlock{ID: "toolu_1", Name: "write", Arguments: args},
284 },
285 },
286 },
287 ToolCalls: []ToolCallView{
288 {
289 ID: "toolu_1",
290 Name: "write",
291 Args: ShortArgs("write", args),
292 Streaming: false,
293 RawJSONBuf: string(args),
294 LivePath: "/tmp/sample.ts",
295 },
296 },
297 }
298
299 plain := stripANSI(strings.Join(v.Build(80), "\n"))
300 if !strings.Contains(plain, "export const n = 1") {
301 t.Fatalf("write preview collapsed after tool args ended but before tool_result arrived:\n%s", plain)
302 }
303}
304
305func TestLiveToolOverlayHidesAfterToolResult(t *testing.T) {
306 args := json.RawMessage(`{"command":"sleep 1"}`)
307 v := View{
308 Theme: Dark,
309 Messages: []provider.Message{
310 {
311 Role: provider.RoleAssistant,
312 Content: []provider.Content{
313 provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: args},
314 },
315 },
316 {
317 Role: provider.RoleTool,
318 Content: []provider.Content{
319 provider.ToolResultBlock{
320 CallID: "toolu_1",
321 Content: []provider.Content{provider.TextBlock{Text: "done"}},
322 },
323 },
324 },
325 },
326 ToolCalls: []ToolCallView{
327 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "done", Done: true},
328 },
329 }
330
331 plain := stripANSI(strings.Join(v.BuildLive(80), "\n"))
332 if strings.Contains(plain, "bash sleep 1") {
333 t.Fatalf("live tool overlay still rendered after tool_result was appended:\n%s", plain)
334 }
335}