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 StartupContextPaths: []string{
15 "/home/user/AGENTS.md",
16 "/repo/AGENTS.md",
17 },
18 StartupExtensionNames: []string{"todo", "workspaces"},
19 StartupSkillNames: []string{"review", "test"},
20 }
21
22 plain := stripANSI(strings.Join(v.Build(80), "\n"))
23 for _, want := range []string{
24 "[Context]", "/home/user/AGENTS.md, /repo/AGENTS.md",
25 "[Extensions]", "todo, workspaces",
26 "[Skills]", "review, test",
27 } {
28 if !strings.Contains(plain, want) {
29 t.Fatalf("startup resources missing %q:\n%s", want, plain)
30 }
31 }
32 if strings.ContainsAny(plain, boxGlyphs) || strings.Contains(plain, "read ") {
33 t.Fatalf("startup resources rendered as a tool call:\n%s", plain)
34 }
35 if len(v.Messages) != 0 {
36 t.Fatalf("startup resources added %d transcript messages", len(v.Messages))
37 }
38}
39
40func TestLiveToolOverlayRemainsAfterAssistantToolUse(t *testing.T) {
41 args := json.RawMessage(`{"command":"sleep 1"}`)
42 v := View{
43 Theme: Dark,
44 Messages: []provider.Message{
45 {
46 Role: provider.RoleAssistant,
47 Content: []provider.Content{
48 provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: args},
49 },
50 },
51 },
52 ToolCalls: []ToolCallView{
53 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Done: false},
54 },
55 }
56
57 plain := stripANSI(strings.Join(v.Build(80), "\n"))
58 if !strings.Contains(plain, "bash sleep 1") {
59 t.Fatalf("live tool overlay disappeared after assistant tool_use was appended:\n%s", plain)
60 }
61}
62
63func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) {
64 args := json.RawMessage(`{"command":"printf 'start' && sleep 60 && printf 'done with a command long enough to exceed the header truncation limit'"}`)
65 v := View{
66 Theme: Dark,
67 ToolCalls: []ToolCallView{
68 {
69 ID: "toolu_1",
70 Name: "bash",
71 Args: ShortArgs("bash", args),
72 RawJSONBuf: string(args),
73 },
74 },
75 }
76
77 plain := stripANSI(strings.Join(v.BuildLive(100), "\n"))
78 if !strings.Contains(plain, "$ printf 'start' && sleep 60") || !strings.Contains(plain, "header truncation limit") {
79 t.Fatalf("bash command was not visible before result arrived:\n%s", plain)
80 }
81}
82
83func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) {
84 v := View{Theme: Dark}
85 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
86 v.ToolCalls = []ToolCallView{
87 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
88 }
89 tallRows := len(v.Build(80))
90 if tallRows == 0 {
91 t.Fatal("expected rows for the tall command")
92 }
93
94 // Same call id, but the streamed command shrinks (a transient
95 // mid-stream state). The box must keep its high-water height so the
96 // editor/status band below doesn't bounce.
97 short := json.RawMessage(`{"command":"echo hi"}`)
98 v.ToolCalls = []ToolCallView{
99 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)},
100 }
101 if got := len(v.Build(80)); got < tallRows {
102 t.Fatalf("live box shrank mid-stream from %d to %d rows", tallRows, got)
103 }
104}
105
106func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) {
107 args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`)
108 v := View{
109 Theme: Dark,
110 ToolCalls: []ToolCallView{{
111 ID: "toolu_1",
112 Name: "edit",
113 RawJSONBuf: string(args),
114 LivePath: "sample.go",
115 Preview: "-old value\n+new value\n",
116 }},
117 }
118
119 rendered := strings.Join(v.Build(80), "\n")
120 plain := stripANSI(rendered)
121 for _, want := range []string{"- 1 old value", "+ 1 new value"} {
122 if !strings.Contains(plain, want) {
123 t.Fatalf("confirmation preview missing numbered diff row %q:\n%s", want, plain)
124 }
125 }
126 for _, want := range []string{
127 Dark.FG256(Dark.Error, "- 1 "),
128 Dark.FG256(Dark.Tool, "+ 1 "),
129 } {
130 if !strings.Contains(rendered, want) {
131 t.Fatalf("confirmation preview missing diff color %q:\n%s", want, rendered)
132 }
133 }
134 if strings.Contains(plain, "edit 1 (streaming)") {
135 t.Fatalf("confirmation still showed the newText streaming view:\n%s", plain)
136 }
137}
138
139func TestLiveToolReservationResetsWhenExpansionChanges(t *testing.T) {
140 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"}`)
141 v := View{
142 Theme: Dark,
143 ToolCalls: []ToolCallView{
144 {ID: "toolu_1", Name: "write", RawJSONBuf: string(args), LivePath: "sample.ts"},
145 },
146 }
147
148 collapsedRows := len(v.Build(80))
149 v.ExpandAll = true
150 expandedRows := len(v.Build(80))
151 if expandedRows <= collapsedRows {
152 t.Fatalf("expanded live box did not grow: collapsed=%d expanded=%d", collapsedRows, expandedRows)
153 }
154
155 v.ExpandAll = false
156 if got := len(v.Build(80)); got != collapsedRows {
157 t.Fatalf("collapsed live box kept expanded height: got %d rows, want %d", got, collapsedRows)
158 }
159}
160
161func TestLiveToolReservationDoesNotLeakToNextCall(t *testing.T) {
162 v := View{Theme: Dark}
163 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
164 v.ToolCalls = []ToolCallView{
165 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
166 }
167 tallRows := len(v.Build(80))
168
169 // A *different* tool call that is short must NOT inherit the tall
170 // call's reservation; it renders at its own natural height. (This
171 // is the regression that produced a giant empty box.)
172 short := json.RawMessage(`{"command":"echo hi"}`)
173 v.ToolCalls = []ToolCallView{
174 {ID: "toolu_2", Name: "bash", Args: ShortArgs("bash", short), RawJSONBuf: string(short)},
175 }
176 if got := len(v.Build(80)); got >= tallRows {
177 t.Fatalf("short call inherited prior call's height: %d rows (tall was %d)", got, tallRows)
178 }
179}
180
181func TestLiveToolReservationDropsOnResult(t *testing.T) {
182 v := View{Theme: Dark}
183 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
184 v.ToolCalls = []ToolCallView{
185 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall)},
186 }
187 tallRows := len(v.Build(80))
188
189 // Same id, now finalised with a tiny error result. The box must
190 // collapse to the natural result height instead of staying padded.
191 v.ToolCalls = []ToolCallView{
192 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", tall), RawJSONBuf: string(tall), Done: true, Error: true, Result: "boom"},
193 }
194 if got := len(v.Build(80)); got >= tallRows {
195 t.Fatalf("finalised box kept live reservation: %d rows (live was %d)", got, tallRows)
196 }
197}
198
199func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) {
200 args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`)
201 v := View{
202 Theme: Dark,
203 Messages: []provider.Message{
204 {
205 Role: provider.RoleAssistant,
206 Content: []provider.Content{
207 provider.ToolCallBlock{ID: "toolu_1", Name: "write", Arguments: args},
208 },
209 },
210 },
211 ToolCalls: []ToolCallView{
212 {
213 ID: "toolu_1",
214 Name: "write",
215 Args: ShortArgs("write", args),
216 Streaming: false,
217 RawJSONBuf: string(args),
218 LivePath: "/tmp/sample.ts",
219 },
220 },
221 }
222
223 plain := stripANSI(strings.Join(v.Build(80), "\n"))
224 if !strings.Contains(plain, "export const n = 1") {
225 t.Fatalf("write preview collapsed after tool args ended but before tool_result arrived:\n%s", plain)
226 }
227}
228
229func TestLiveToolOverlayHidesAfterToolResult(t *testing.T) {
230 args := json.RawMessage(`{"command":"sleep 1"}`)
231 v := View{
232 Theme: Dark,
233 Messages: []provider.Message{
234 {
235 Role: provider.RoleAssistant,
236 Content: []provider.Content{
237 provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: args},
238 },
239 },
240 {
241 Role: provider.RoleTool,
242 Content: []provider.Content{
243 provider.ToolResultBlock{
244 CallID: "toolu_1",
245 Content: []provider.Content{provider.TextBlock{Text: "done"}},
246 },
247 },
248 },
249 },
250 ToolCalls: []ToolCallView{
251 {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "done", Done: true},
252 },
253 }
254
255 plain := stripANSI(strings.Join(v.BuildLive(80), "\n"))
256 if strings.Contains(plain, "bash sleep 1") {
257 t.Fatalf("live tool overlay still rendered after tool_result was appended:\n%s", plain)
258 }
259}