v0.34.0 a939a2a

double_escape_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"testing"
6	"time"
7
8	"github.com/bnema/zut/packages/core"
9	"github.com/bnema/zut/packages/provider"
10	"github.com/bnema/zut/packages/tui"
11)
12
13func applySessionTreeLoads(d *sessionTreeDialog, events <-chan sessionTreeLoadEvent) error {
14	var loadErr error
15	for event := range events {
16		if err := d.ApplyLoad(event); err != nil {
17			loadErr = err
18		}
19	}
20	d.ApplyLoadClosed()
21	return loadErr
22}
23
24func TestInteractiveCtrlCExitMarker(t *testing.T) {
25	i := NewInteractive(InteractiveConfig{})
26	key := tui.Key{Kind: tui.KeyCtrlC}
27	if i.handleKey(context.Background(), key) {
28		t.Fatal("first Ctrl+C exited interactive mode")
29	}
30	if i.ExitedViaCtrlC() {
31		t.Fatal("first Ctrl+C marked an exit")
32	}
33	if !i.handleKey(context.Background(), key) {
34		t.Fatal("second Ctrl+C did not exit interactive mode")
35	}
36	if !i.ExitedViaCtrlC() {
37		t.Fatal("Ctrl+C exit was not marked")
38	}
39}
40
41func TestDoubleEscapeTrackerWindowAndReset(t *testing.T) {
42	base := time.Unix(100, 0)
43	var tracker doubleEscapeTracker
44
45	tracker.Arm(base)
46	if !tracker.Consume(base.Add(sessionTreeEscapeWindow)) {
47		t.Fatal("tap at the 500ms boundary should count")
48	}
49	if tracker.Consume(base.Add(sessionTreeEscapeWindow)) {
50		t.Fatal("a consumed gesture must not trigger again")
51	}
52
53	tracker.Arm(base)
54	if tracker.Consume(base.Add(sessionTreeEscapeWindow + time.Nanosecond)) {
55		t.Fatal("tap after the gesture window should not count")
56	}
57	tracker.Arm(base)
58	if tracker.Consume(base.Add(-time.Nanosecond)) {
59		t.Fatal("a clock moving backwards must reset the gesture")
60	}
61
62	// Zero is a valid value for a deterministic test clock; it must not be
63	// confused with the tracker's unarmed sentinel.
64	tracker.Arm(time.Time{})
65	if !tracker.Consume(time.Time{}.Add(100 * time.Millisecond)) {
66		t.Fatal("zero-valued test timestamp should still arm the gesture")
67	}
68}
69
70func TestUnmodifiedEscapeRequiresBareParsedKey(t *testing.T) {
71	bare := tui.Key{Kind: tui.KeyEsc}
72	for _, key := range []tui.Key{
73		{Kind: tui.KeyEsc, Alt: true},
74		{Kind: tui.KeyEsc, Ctrl: true},
75		{Kind: tui.KeyEsc, Shift: true},
76		{Kind: tui.KeyEsc, Super: true},
77	} {
78		if isUnmodifiedEscape(key) {
79			t.Fatalf("modified key %#v was treated as a bare Escape", key)
80		}
81	}
82	if !isUnmodifiedEscape(bare) {
83		t.Fatal("bare parsed Escape was not recognized")
84	}
85}
86
87func TestSessionTreeSelectionRestoresCompleteUserDraft(t *testing.T) {
88	image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png"), ThoughtSignature: "sig"}
89	msgs := []provider.Message{
90		{
91			Role: provider.RoleUser,
92			Content: []provider.Content{
93				provider.TextBlock{Text: "before"},
94				image,
95				provider.TextBlock{Text: "after"},
96			},
97		},
98	}
99	target := sessionTreeTarget{
100		EffectiveIndex:    0,
101		SelectionBoundary: 0,
102		Role:              provider.RoleUser,
103		UserDraft:         "fallback must not replace message text",
104	}
105
106	got, err := sessionTreeSelection(msgs, target)
107	if err != nil {
108		t.Fatalf("sessionTreeSelection: %v", err)
109	}
110	if got.upTo != 0 || !got.restoreDraft {
111		t.Fatalf("selection = %#v, want a pre-user draft boundary", got)
112	}
113	if got.draftText != "before\nafter [clipboard image #1] " {
114		t.Fatalf("draft text = %q", got.draftText)
115	}
116	if len(got.images) != 1 || got.images[0].Marker != "[clipboard image #1]" {
117		t.Fatalf("restored images = %#v", got.images)
118	}
119	if string(got.images[0].Image.Data) != "png" || got.images[0].Image.MimeType != image.MimeType || got.images[0].Image.ThoughtSignature != image.ThoughtSignature {
120		t.Fatalf("restored image = %#v", got.images[0].Image)
121	}
122}
123
124func TestSessionTreeSelectionKeepsToolExchangeAtomic(t *testing.T) {
125	msgs := []provider.Message{
126		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "ask"}}},
127		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}},
128		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1"}}},
129		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}},
130	}
131
132	got, err := sessionTreeSelection(msgs, sessionTreeTarget{
133		EffectiveIndex:    1,
134		SelectionBoundary: 2,
135		Role:              provider.RoleAssistant,
136	})
137	if err != nil {
138		t.Fatalf("tool-call selection: %v", err)
139	}
140	if got.upTo != 3 {
141		t.Fatalf("tool-call boundary = %d, want 3", got.upTo)
142	}
143
144	got, err = sessionTreeSelection(msgs, sessionTreeTarget{
145		EffectiveIndex:    2,
146		SelectionBoundary: 3,
147		Role:              provider.RoleTool,
148	})
149	if err != nil {
150		t.Fatalf("tool-result selection: %v", err)
151	}
152	if got.upTo != 3 {
153		t.Fatalf("tool-result boundary = %d, want 3", got.upTo)
154	}
155}
156
157func TestSessionTreeSelectionAllowsDeferredToolActivation(t *testing.T) {
158	msgs := []provider.Message{
159		{
160			Role:           provider.RoleTool,
161			AddedToolNames: []string{"lookup_weather"},
162			Content:        []provider.Content{provider.ToolResultBlock{CallID: "call-1"}},
163		},
164		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "next"}}},
165	}
166	got, err := sessionTreeSelection(msgs, sessionTreeTarget{
167		EffectiveIndex:    0,
168		SelectionBoundary: 1,
169		Role:              provider.RoleTool,
170	})
171	if err != nil {
172		t.Fatalf("deferred-tool selection: %v", err)
173	}
174	if got.upTo != 1 {
175		t.Fatalf("deferred-tool boundary = %d, want 1", got.upTo)
176	}
177}
178
179func TestSessionTreeGateFailureDoesNotFlushOrOpenOverlay(t *testing.T) {
180	i := NewInteractive(InteractiveConfig{})
181	flushes := 0
182	i.cfg.FlushSession = func() { flushes++ }
183
184	i.doSessionTree()
185	if flushes != 0 {
186		t.Fatalf("FlushSession called %d times on a failed gate", flushes)
187	}
188	if i.sessionTreeDialog.Active() {
189		t.Fatal("session tree overlay opened after a failed gate")
190	}
191}
192
193func TestSessionTreeGateRejectsModelRefreshInFlight(t *testing.T) {
194	root := t.TempDir()
195	cwd := "/workspace/model-refresh-gate"
196	session, err := core.NewSession(root, cwd, "test", "model", "test")
197	if err != nil {
198		t.Fatal(err)
199	}
200	message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "message"}}}
201	if err := session.AppendMessage(message); err != nil {
202		t.Fatal(err)
203	}
204	path := session.Path
205	if err := session.Close(); err != nil {
206		t.Fatal(err)
207	}
208	i := NewInteractive(InteractiveConfig{
209		Agent:              core.NewAgent(nil, "model", "", nil),
210		CWD:                cwd,
211		SessionsRoot:       root,
212		CurrentSessionPath: func() string { return path },
213		LoadSession:        func(string) error { return nil },
214	})
215	i.agent.SetMessages([]provider.Message{message})
216	i.mu.Lock()
217	i.modelRefreshing = true
218	i.mu.Unlock()
219	if i.canOpenSessionTree() {
220		t.Fatal("tree gate opened while model refresh was in flight")
221	}
222}
223
224func TestSessionTreeSelectionGateAllowsActiveTree(t *testing.T) {
225	root := t.TempDir()
226	cwd := "/workspace/selection-gate"
227	session, err := core.NewSession(root, cwd, "test", "model", "test")
228	if err != nil {
229		t.Fatal(err)
230	}
231	message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "message"}}}
232	if err := session.AppendMessage(message); err != nil {
233		t.Fatal(err)
234	}
235	path := session.Path
236	if err := session.Close(); err != nil {
237		t.Fatal(err)
238	}
239	ag := core.NewAgent(nil, "model", "", nil)
240	ag.SetMessages([]provider.Message{message})
241	i := NewInteractive(InteractiveConfig{
242		Agent:        ag,
243		CWD:          cwd,
244		SessionsRoot: root,
245		CurrentSessionPath: func() string {
246			return path
247		},
248		LoadSession: func(string) error { return nil },
249	})
250	i.sessionTreeDialog.active = true
251	if i.canOpenSessionTree() {
252		t.Fatal("open gate accepted an already-active tree")
253	}
254	if !i.canCommitSessionTreeSelection() {
255		t.Fatal("selection gate rejected the active tree dialog")
256	}
257}
258
259func TestDoubleEscapeOpensReadableTreeThroughSharedGate(t *testing.T) {
260	root := t.TempDir()
261	cwd := "/workspace/double-escape"
262	session, err := core.NewSession(root, cwd, "test", "model", "test")
263	if err != nil {
264		t.Fatal(err)
265	}
266	message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}}
267	if err := session.AppendMessage(message); err != nil {
268		t.Fatal(err)
269	}
270	path := session.Path
271	if err := session.Close(); err != nil {
272		t.Fatal(err)
273	}
274
275	ag := core.NewAgent(nil, "model", "", nil)
276	ag.SetMessages([]provider.Message{message})
277	now := time.Unix(200, 0)
278	flushes := 0
279	i := NewInteractive(InteractiveConfig{
280		Agent:        ag,
281		CWD:          cwd,
282		SessionsRoot: root,
283		CurrentSessionPath: func() string {
284			return path
285		},
286		LoadSession: func(string) error { return nil },
287		FlushSession: func() {
288			flushes++
289		},
290	})
291	i.clock = func() time.Time { return now }
292
293	if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
294		t.Fatal("first Escape exited")
295	}
296	if i.sessionTreeDialog.Active() || flushes != 0 {
297		t.Fatalf("first Escape opened/flushed: active=%v flushes=%d", i.sessionTreeDialog.Active(), flushes)
298	}
299	now = now.Add(499 * time.Millisecond)
300	if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
301		t.Fatal("second Escape exited")
302	}
303	if !i.sessionTreeDialog.Active() {
304		t.Fatal("session tree did not open before its background load")
305	}
306	if err := applySessionTreeLoads(i.sessionTreeDialog, i.sessionTreeLoads); err != nil {
307		t.Fatalf("background session tree load: %v", err)
308	}
309	if flushes != 1 {
310		t.Fatalf("FlushSession called %d times, want once in the background", flushes)
311	}
312	if got := len(ag.Messages()); got != 1 {
313		t.Fatalf("double Escape changed transcript with a provider turn: %d messages", got)
314	}
315}
316
317func TestSecondEscapeFallsThroughToCancellationWhenTurnBecomesBusy(t *testing.T) {
318	root := t.TempDir()
319	cwd := "/workspace/double-escape-busy"
320	session, err := core.NewSession(root, cwd, "test", "model", "test")
321	if err != nil {
322		t.Fatal(err)
323	}
324	message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}}
325	if err := session.AppendMessage(message); err != nil {
326		t.Fatal(err)
327	}
328	path := session.Path
329	if err := session.Close(); err != nil {
330		t.Fatal(err)
331	}
332
333	ag := core.NewAgent(nil, "model", "", nil)
334	ag.SetMessages([]provider.Message{message})
335	now := time.Unix(300, 0)
336	cancelled := 0
337	i := NewInteractive(InteractiveConfig{
338		Agent:              ag,
339		CWD:                cwd,
340		SessionsRoot:       root,
341		CurrentSessionPath: func() string { return path },
342		LoadSession:        func(string) error { return nil },
343	})
344	i.clock = func() time.Time { return now }
345
346	if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
347		t.Fatal("first Escape exited")
348	}
349	i.mu.Lock()
350	i.busy = true
351	i.cancelTurn = func() { cancelled++ }
352	i.mu.Unlock()
353	now = now.Add(100 * time.Millisecond)
354	if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
355		t.Fatal("second Escape exited")
356	}
357	if cancelled != 1 {
358		t.Fatalf("second Escape did not fall through to cancellation: got %d cancels", cancelled)
359	}
360	if i.sessionTreeDialog.Active() {
361		t.Fatal("session tree opened after the turn became busy")
362	}
363	if i.doubleEscape.armed {
364		t.Fatal("stale double-Escape gesture remained armed after falling through")
365	}
366}
367
368func TestSecondEscapeStillConsumesFailClosedTreeGate(t *testing.T) {
369	root := t.TempDir()
370	cwd := "/workspace/double-escape-gate"
371	session, err := core.NewSession(root, cwd, "test", "model", "test")
372	if err != nil {
373		t.Fatal(err)
374	}
375	message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}}
376	if err := session.AppendMessage(message); err != nil {
377		t.Fatal(err)
378	}
379	path := session.Path
380	if err := session.Close(); err != nil {
381		t.Fatal(err)
382	}
383
384	ag := core.NewAgent(nil, "model", "", nil)
385	ag.SetMessages([]provider.Message{message})
386	now := time.Unix(400, 0)
387	pathCalls := 0
388	flushes := 0
389	i := NewInteractive(InteractiveConfig{
390		Agent:        ag,
391		CWD:          cwd,
392		SessionsRoot: root,
393		CurrentSessionPath: func() string {
394			pathCalls++
395			if pathCalls == 1 {
396				return path
397			}
398			// Keep the cheap gesture eligibility true while making the
399			// asynchronous family read fail after the overlay opens.
400			return path + ".missing"
401		},
402		LoadSession: func(string) error { return nil },
403		FlushSession: func() {
404			flushes++
405		},
406	})
407	i.clock = func() time.Time { return now }
408
409	if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
410		t.Fatal("first Escape exited")
411	}
412	now = now.Add(100 * time.Millisecond)
413	if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
414		t.Fatal("second Escape exited")
415	}
416	if !i.sessionTreeDialog.Active() {
417		t.Fatal("tree did not open before the asynchronous family validation")
418	}
419	if err := applySessionTreeLoads(i.sessionTreeDialog, i.sessionTreeLoads); err == nil {
420		t.Fatal("unreadable family did not fail in the background")
421	}
422	if i.sessionTreeDialog.Active() {
423		t.Fatal("unreadable family left the tree overlay active")
424	}
425	if flushes != 1 {
426		t.Fatalf("FlushSession called %d times, want one asynchronous flush", flushes)
427	}
428}
429
430func TestForkCommandsWithoutSessionPathReportError(t *testing.T) {
431	for _, command := range []string{"/fork", "/session fork"} {
432		i := NewInteractive(InteractiveConfig{})
433		i.runSlash(context.Background(), command)
434		if i.statusErr != "fork: no session is active (running with --no-session?)" {
435			t.Fatalf("%s status = %q", command, i.statusErr)
436		}
437	}
438}

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