v0.13.0 befd698

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

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