v0.4.4 e5506c1

confirm_dialog_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"runtime"
7	"strings"
8	"testing"
9	"time"
10
11	"github.com/patriceckhart/zot/packages/agent/extproto"
12	"github.com/patriceckhart/zot/packages/agent/tools"
13	"github.com/patriceckhart/zot/packages/core"
14	"github.com/patriceckhart/zot/packages/tui"
15)
16
17func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
18	dialog := newConfirmDialog()
19	btw := &btwDialog{
20		active:   true,
21		toolView: &tui.View{Theme: tui.Dark},
22		turns: []btwTurn{{Tools: []tui.ToolCallView{{
23			ID: "call-1", Name: "edit",
24		}}}},
25		editor: tui.NewEditor(""),
26	}
27	i := &Interactive{
28		toolCalls: map[string]*tui.ToolCallView{
29			"call-1": {ID: "call-1", Name: "edit"},
30		},
31		btwDialog:     btw,
32		confirmDialog: dialog,
33		dirty:         make(chan struct{}, 1),
34	}
35	decision := make(chan core.ConfirmDecision, 1)
36	go func() {
37		decision <- i.ConfirmToolCall(core.ToolCallConfirmation{
38			ID:      "call-1",
39			Name:    "edit",
40			Summary: "sample.go",
41			Content: "-old\n+new\n",
42		})
43	}()
44
45	deadline := time.Now().Add(time.Second)
46	for !dialog.Active() && time.Now().Before(deadline) {
47		runtime.Gosched()
48	}
49	if !dialog.Active() {
50		t.Fatal("confirmation dialog did not open")
51	}
52	i.mu.Lock()
53	preview := i.toolCalls["call-1"].Preview
54	i.mu.Unlock()
55	if preview != "-old\n+new\n" {
56		t.Fatalf("tool preview = %q", preview)
57	}
58	btw.mu.Lock()
59	btwPreview := btw.turns[0].Tools[0].Preview
60	btw.mu.Unlock()
61	if btwPreview != "-old\n+new\n" {
62		t.Fatalf("/btw tool preview = %q", btwPreview)
63	}
64	combined := strings.Join(renderBtwConfirmation(tui.Dark, 80, btw, dialog), "\n")
65	for _, want := range []string{"btw - side chat", "old", "new", "confirm tool call"} {
66		if !strings.Contains(combined, want) {
67			t.Fatalf("combined /btw confirmation missing %q:\n%s", want, combined)
68		}
69	}
70
71	dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
72	select {
73	case got := <-decision:
74		if !got.Allow {
75			t.Fatalf("confirmation decision = %+v", got)
76		}
77	case <-time.After(time.Second):
78		t.Fatal("confirmation did not return")
79	}
80}
81
82func newConfirmationInputInteractive() *Interactive {
83	agent := core.NewAgent(nil, "test-model", "test system", nil)
84	return NewInteractive(InteractiveConfig{
85		Theme:    tui.Dark,
86		Terminal: tui.NewProcTerm(),
87		Agent:    agent,
88		Sandbox:  tools.NewSandbox("."),
89	})
90}
91
92func enqueueConfirmation(i *Interactive) <-chan core.ConfirmDecision {
93	resp := make(chan core.ConfirmDecision, 1)
94	i.confirmDialog.Enqueue(&confirmRequest{toolName: "bash", preview: "pwd", resp: resp})
95	return resp
96}
97
98func TestConfirmDialogSlashOpensBtwBeforeDecision(t *testing.T) {
99	i := newConfirmationInputInteractive()
100	resp := enqueueConfirmation(i)
101
102	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '/'})
103	if got := i.ed.Value(); got != "/" {
104		t.Fatalf("editor = %q, want slash command input", got)
105	}
106	if i.confirmDialog.Focused() {
107		t.Fatal("confirmation retained focus after slash")
108	}
109
110	i.ed.SetValue("/btw")
111	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
112	if !i.btwDialog.Active() {
113		t.Fatal("/btw did not open while confirmation was pending")
114	}
115	if i.confirmDialog.Focused() {
116		t.Fatal("confirmation stole focus from /btw")
117	}
118	select {
119	case decision := <-resp:
120		t.Fatalf("/btw unexpectedly answered confirmation: %+v", decision)
121	default:
122	}
123
124	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'x'})
125	i.btwDialog.mu.Lock()
126	btwInput := i.btwDialog.editor.Value()
127	i.btwDialog.mu.Unlock()
128	if btwInput != "x" {
129		t.Fatalf("/btw editor = %q, want %q", btwInput, "x")
130	}
131
132	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
133	if i.btwDialog.Active() {
134		t.Fatal("esc did not close /btw")
135	}
136	if !i.confirmDialog.Focused() {
137		t.Fatal("closing /btw did not restore confirmation focus")
138	}
139}
140
141func TestConfirmDialogPrioritizesSideChatToolAndReturnsToSideChat(t *testing.T) {
142	dialog := newConfirmDialog()
143	mainResp := make(chan core.ConfirmDecision, 1)
144	sideResp := make(chan core.ConfirmDecision, 1)
145	dialog.Enqueue(&confirmRequest{toolName: "main", resp: mainResp})
146	dialog.Blur()
147	dialog.Enqueue(&confirmRequest{toolName: "side", resp: sideResp, returnToChild: true})
148
149	dialog.mu.Lock()
150	first := dialog.pending[0].toolName
151	dialog.mu.Unlock()
152	if first != "side" {
153		t.Fatalf("first confirmation = %q, want side-chat tool", first)
154	}
155	if !dialog.Focused() {
156		t.Fatal("side-chat tool confirmation did not take focus")
157	}
158
159	dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
160	select {
161	case decision := <-sideResp:
162		if !decision.Allow {
163			t.Fatalf("side-chat decision = %+v", decision)
164		}
165	default:
166		t.Fatal("side-chat confirmation was not resolved")
167	}
168	if !dialog.Active() {
169		t.Fatal("main confirmation was lost")
170	}
171	if dialog.Focused() {
172		t.Fatal("main confirmation stole focus after side-chat tool decision")
173	}
174	select {
175	case decision := <-mainResp:
176		t.Fatalf("main confirmation was unexpectedly resolved: %+v", decision)
177	default:
178	}
179}
180
181func TestConfirmDialogEscCancelsSlashInputOnly(t *testing.T) {
182	i := newConfirmationInputInteractive()
183	resp := enqueueConfirmation(i)
184
185	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '/'})
186	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'h'})
187	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
188
189	if !i.ed.IsEmpty() {
190		t.Fatalf("editor was not cleared: %q", i.ed.Value())
191	}
192	if !i.confirmDialog.Focused() {
193		t.Fatal("esc did not return focus to confirmation")
194	}
195	select {
196	case decision := <-resp:
197		t.Fatalf("esc from slash input answered confirmation: %+v", decision)
198	default:
199	}
200}
201
202func TestConfirmDialogHelpEscDismissesHelpOnly(t *testing.T) {
203	i := newConfirmationInputInteractive()
204	resp := enqueueConfirmation(i)
205
206	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '/'})
207	i.ed.SetValue("/help")
208	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
209
210	if len(i.helpBlock) == 0 {
211		t.Fatal("/help did not open while confirmation was pending")
212	}
213	if i.confirmDialog.Focused() {
214		t.Fatal("confirmation stole focus from /help")
215	}
216
217	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
218
219	if len(i.helpBlock) != 0 {
220		t.Fatal("esc did not dismiss /help")
221	}
222	if !i.confirmDialog.Focused() {
223		t.Fatal("dismissing /help did not restore confirmation focus")
224	}
225	select {
226	case decision := <-resp:
227		t.Fatalf("esc from /help answered confirmation: %+v", decision)
228	default:
229	}
230}
231
232func TestConfirmDialogExtensionNoteEscDismissesNoteOnly(t *testing.T) {
233	i := newConfirmationInputInteractive()
234	resp := enqueueConfirmation(i)
235
236	i.Display("example", "command output")
237	if len(i.extNotes) == 0 {
238		t.Fatal("extension note was not displayed")
239	}
240	if i.confirmDialog.Focused() {
241		t.Fatal("confirmation retained focus over extension note")
242	}
243
244	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
245
246	if len(i.extNotes) != 0 {
247		t.Fatal("esc did not dismiss extension note")
248	}
249	if !i.confirmDialog.Focused() {
250		t.Fatal("dismissing extension note did not restore confirmation focus")
251	}
252	select {
253	case decision := <-resp:
254		t.Fatalf("esc from extension note answered confirmation: %+v", decision)
255	default:
256	}
257}
258
259func TestConfirmDialogExtensionPanelTakesAndReturnsFocus(t *testing.T) {
260	i := newConfirmationInputInteractive()
261	resp := enqueueConfirmation(i)
262
263	i.OpenPanel("todo", extproto.PanelSpec{ID: "todos", Title: "Todos", Lines: []string{"one"}})
264	if !i.extPanel.Active() {
265		t.Fatal("extension panel did not open")
266	}
267	if i.confirmDialog.Focused() {
268		t.Fatal("confirmation retained focus over extension panel")
269	}
270
271	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
272	if i.extPanel.Active() {
273		t.Fatal("esc did not close extension panel")
274	}
275	if !i.confirmDialog.Focused() {
276		t.Fatal("closing extension panel did not restore confirmation focus")
277	}
278	select {
279	case decision := <-resp:
280		t.Fatalf("extension panel answered confirmation: %+v", decision)
281	default:
282	}
283}
284
285func TestCancelAndWaitDrainsPendingConfirmation(t *testing.T) {
286	i := newConfirmationInputInteractive()
287	resp := enqueueConfirmation(i)
288	i.busy = true
289	i.cancelTurn = func() {
290		i.mu.Lock()
291		i.busy = false
292		i.mu.Unlock()
293	}
294
295	i.cancelAndWaitForIdle()
296
297	if i.confirmDialog.Active() {
298		t.Fatal("confirmation remained active after turn cancellation")
299	}
300	select {
301	case decision := <-resp:
302		if decision.Allow || decision.Reason != "turn cancelled" {
303			t.Fatalf("cancellation decision = %+v", decision)
304		}
305	default:
306		t.Fatal("pending confirmation was not resolved")
307	}
308}
309
310func TestConfirmDialogAllowsToolExpansion(t *testing.T) {
311	resp := make(chan core.ConfirmDecision, 1)
312	dialog := newConfirmDialog()
313	dialog.Enqueue(&confirmRequest{
314		toolName: "edit",
315		preview:  "large edit",
316		resp:     resp,
317	})
318	args, err := json.Marshal(map[string]any{
319		"path": "sample.ts",
320		"edits": []map[string]string{{
321			"oldText": "old",
322			"newText": strings.Repeat("const value = 1\n", 20),
323		}},
324	})
325	if err != nil {
326		t.Fatal(err)
327	}
328	view := &tui.View{
329		ToolCalls: []tui.ToolCallView{{
330			ID:         "call-1",
331			Name:       "edit",
332			RawJSONBuf: string(args),
333			LivePath:   "sample.ts",
334		}},
335	}
336	i := &Interactive{
337		view:          view,
338		confirmDialog: dialog,
339		dirty:         make(chan struct{}, 1),
340	}
341	if rendered := strings.Join(view.Build(80), "\n"); !strings.Contains(rendered, "ctrl+o to expand") {
342		t.Fatalf("test preview was not initially collapsed:\n%s", rendered)
343	}
344
345	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlO})
346
347	if !i.view.ExpandAll {
348		t.Fatal("ctrl+o did not expand tool previews while confirmation was active")
349	}
350	if rendered := strings.Join(view.Build(80), "\n"); strings.Contains(rendered, "ctrl+o to expand") {
351		t.Fatalf("live edit preview remained collapsed after ctrl+o:\n%s", rendered)
352	}
353	if !dialog.Active() {
354		t.Fatal("ctrl+o closed the confirmation dialog")
355	}
356	select {
357	case decision := <-resp:
358		t.Fatalf("ctrl+o unexpectedly answered confirmation: %+v", decision)
359	default:
360	}
361
362	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlO})
363	if i.view.ExpandAll {
364		t.Fatal("second ctrl+o did not collapse tool previews during confirmation")
365	}
366	if rendered := strings.Join(view.Build(80), "\n"); !strings.Contains(rendered, "ctrl+o to expand") {
367		t.Fatalf("live edit preview remained expanded after second ctrl+o:\n%s", rendered)
368	}
369	if !dialog.Active() {
370		t.Fatal("second ctrl+o closed the confirmation dialog")
371	}
372}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close