1package tui
2
3import (
4 "encoding/json"
5 "strings"
6 "testing"
7
8 "github.com/patriceckhart/zot/packages/provider"
9)
10
11func TestStartupResourcesRenderAsCompactSections(t *testing.T) {
12 v := View{
13 Theme: Dark,
14 StartupAgentName: "zot-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]", "zot-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 TestLiveToolOverlayShowsPowerShellCommand(t *testing.T) {
86 args := json.RawMessage(`{"command":"Get-ChildItem -Force"}`)
87 v := View{Theme: Dark, ToolCalls: []ToolCallView{{
88 ID: "shell_1", Name: "powershell", RawJSONBuf: string(args),
89 }}}
90 plain := stripANSI(strings.Join(v.BuildLive(80), "\n"))
91 if !strings.Contains(plain, "PS> Get-ChildItem -Force") {
92 t.Fatalf("PowerShell command missing from live preview: %s", plain)
93 }
94}
95
96func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) {
97 v := View{Theme: Dark}
98 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
99 v.ToolCalls = []ToolCallView{
100 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
101 }
102 tallRows := len(v.Build(80))
103 if tallRows == 0 {
104 t.Fatal("expected rows for the tall command")
105 }
106
107 // Same call id, but the streamed command shrinks (a transient
108 // mid-stream state). The box must keep its high-water height so the
109 // editor/status band below doesn't bounce.
110 short := json.RawMessage(`{"command":"echo hi"}`)
111 v.ToolCalls = []ToolCallView{
112 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)},
113 }
114 if got := len(v.Build(80)); got < tallRows {
115 t.Fatalf("live box shrank mid-stream from %d to %d rows", tallRows, got)
116 }
117}
118
119func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) {
120 args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`)
121 v := View{
122 Theme: Dark,
123 ToolCalls: []ToolCallView{{
124 ID: "toolu_1",
125 Name: "edit",
126 RawJSONBuf: string(args),
127 LivePath: "sample.go",
128 Preview: "-old value\n+new value\n",
129 }},
130 }
131
132 rendered := strings.Join(v.Build(80), "\n")
133 plain := stripANSI(rendered)
134 for _, want := range []string{"- 1 old value", "+ 1 new value"} {
135 if !strings.Contains(plain, want) {
136 t.Fatalf("confirmation preview missing numbered diff row %q:\n%s", want, plain)
137 }
138 }
139 for _, want := range []string{
140 Dark.FG256(Dark.Error, "- 1 "),
141 Dark.FG256(Dark.Tool, "+ 1 "),
142 } {
143 if !strings.Contains(rendered, want) {
144 t.Fatalf("confirmation preview missing diff color %q:\n%s", want, rendered)
145 }
146 }
147 if strings.Contains(plain, "edit 1 (streaming)") {
148 t.Fatalf("confirmation still showed the newText streaming view:\n%s", plain)
149 }
150}
151
152func TestLiveToolReservationResetsWhenExpansionChanges(t *testing.T) {
153 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"}`)
154 v := View{
155 Theme: Dark,
156 ToolCalls: []ToolCallView{
157 {ID: "toolu_1", Name: "write", RawJSONBuf: string(args), LivePath: "sample.ts"},
158 },
159 }
160
161 collapsedRows := len(v.Build(80))
162 v.ExpandAll = true
163 expandedRows := len(v.Build(80))
164 if expandedRows <= collapsedRows {
165 t.Fatalf("expanded live box did not grow: collapsed=%d expanded=%d", collapsedRows, expandedRows)
166 }
167
168 v.ExpandAll = false
169 if got := len(v.Build(80)); got != collapsedRows {
170 t.Fatalf("collapsed live box kept expanded height: got %d rows, want %d", got, collapsedRows)
171 }
172}
173
174func TestLiveToolReservationDoesNotLeakToNextCall(t *testing.T) {
175 v := View{Theme: Dark}
176 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
177 v.ToolCalls = []ToolCallView{
178 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
179 }
180 tallRows := len(v.Build(80))
181
182 // A *different* tool call that is short must NOT inherit the tall
183 // call's reservation; it renders at its own natural height. (This
184 // is the regression that produced a giant empty box.)
185 short := json.RawMessage(`{"command":"echo hi"}`)
186 v.ToolCalls = []ToolCallView{
187 {ID: "toolu_2", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)},
188 }
189 if got := len(v.Build(80)); got >= tallRows {
190 t.Fatalf("short call inherited prior call's height: %d rows (tall was %d)", got, tallRows)
191 }
192}
193
194func TestLiveToolReservationDropsOnResult(t *testing.T) {
195 v := View{Theme: Dark}
196 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
197 v.ToolCalls = []ToolCallView{
198 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
199 }
200 tallRows := len(v.Build(80))
201
202 // Same id, now finalised with a tiny error result. The box must
203 // collapse to the natural result height instead of staying padded.
204 v.ToolCalls = []ToolCallView{
205 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall), Done: true, Error: true, Result: "boom"},
206 }
207 if got := len(v.Build(80)); got >= tallRows {
208 t.Fatalf("finalised box kept live reservation: %d rows (live was %d)", got, tallRows)
209 }
210}
211
212func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) {
213 args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`)
214 v := View{
215 Theme: Dark,
216 Messages: []provider.Message{
217 {
218 Role: provider.RoleAssistant,
219 Content: []provider.Content{
220 provider.ToolCallBlock{ID: "toolu_1", Name: "write", Arguments: args},
221 },
222 },
223 },
224 ToolCalls: []ToolCallView{
225 {
226 ID: "toolu_1",
227 Name: "write",
228 Args: ShortArgs("write", args),
229 Streaming: false,
230 RawJSONBuf: string(args),
231 LivePath: "/tmp/sample.ts",
232 },
233 },
234 }
235
236 plain := stripANSI(strings.Join(v.Build(80), "\n"))
237 if !strings.Contains(plain, "export const n = 1") {
238 t.Fatalf("write preview collapsed after tool args ended but before tool_result arrived:\n%s", plain)
239 }
240}
241
242func TestLiveToolOverlayHidesAfterToolResult(t *testing.T) {
243 args := json.RawMessage(`{"command":"sleep 1"}`)
244 v := View{
245 Theme: Dark,
246 Messages: []provider.Message{
247 {
248 Role: provider.RoleAssistant,
249 Content: []provider.Content{
250 provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: args},
251 },
252 },
253 {
254 Role: provider.RoleTool,
255 Content: []provider.Content{
256 provider.ToolResultBlock{
257 CallID: "toolu_1",
258 Content: []provider.Content{provider.TextBlock{Text: "done"}},
259 },
260 },
261 },
262 },
263 ToolCalls: []ToolCallView{
264 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "done", Done: true},
265 },
266 }
267
268 plain := stripANSI(strings.Join(v.BuildLive(80), "\n"))
269 if strings.Contains(plain, "bash sleep 1") {
270 t.Fatalf("live tool overlay still rendered after tool_result was appended:\n%s", plain)
271 }
272}