v0.9.2 49bea57

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

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