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