v0.24.0 3dbf466

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

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