v0.41.0 6084eb1

double_escape.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"time"
8
9	"github.com/bnema/zut/packages/provider"
10	"github.com/bnema/zut/packages/tui"
11)
12
13const sessionTreeEscapeWindow = 500 * time.Millisecond
14
15type doubleEscapeTracker struct {
16	last  time.Time
17	armed bool
18}
19
20func (d *doubleEscapeTracker) Arm(now time.Time) {
21	d.last = now
22	d.armed = true
23}
24
25// Consume reports whether now is the second tap of a valid gesture. A clock
26// that moves backwards is treated as a reset rather than as a valid tap.
27func (d *doubleEscapeTracker) Consume(now time.Time) bool {
28	last := d.last
29	armed := d.armed
30	d.last = time.Time{}
31	d.armed = false
32	if !armed {
33		return false
34	}
35	elapsed := now.Sub(last)
36	return elapsed >= 0 && elapsed <= sessionTreeEscapeWindow
37}
38
39func (d *doubleEscapeTracker) Reset() {
40	d.last = time.Time{}
41	d.armed = false
42}
43
44func isUnmodifiedEscape(k tui.Key) bool {
45	return k.Kind == tui.KeyEsc && !k.Ctrl && !k.Alt && !k.Shift && !k.Super
46}
47
48func (i *Interactive) sessionTreeEscapeNow() time.Time {
49	if i.clock != nil {
50		return i.clock()
51	}
52	return time.Now()
53}
54
55// currentSessionPath is the nil-safe session callback shared by slash
56// commands and tree operations. Running without persistence (for example
57// --no-session) is a supported mode, so callers must treat a missing
58// callback exactly like an empty path rather than invoking it blindly.
59func (i *Interactive) currentSessionPath() string {
60	if i == nil || i.cfg.CurrentSessionPath == nil {
61		return ""
62	}
63	return i.cfg.CurrentSessionPath()
64}
65
66// sessionTreeEscapeBlocked reports interactions that must retain precedence
67// over the gesture. It is intentionally broader than the overlay switch in
68// handleKey: notes and suggestions also own Escape even though they are not
69// all rendered as dialogs.
70func (i *Interactive) sessionTreeEscapeBlocked() bool {
71	if i == nil {
72		return true
73	}
74	if i.ed == nil {
75		return true
76	}
77	if i.dialog != nil && i.dialog.Active() ||
78		i.modelDialog != nil && i.modelDialog.Active() ||
79		i.llamaDialog != nil && i.llamaDialog.Active() ||
80		i.rescueDialog != nil && i.rescueDialog.Active() ||
81		i.sessionDialog != nil && i.sessionDialog.Active() ||
82		i.residentSubagentsDialog != nil && i.residentSubagentsDialog.Active() ||
83		i.residentChildSession != nil ||
84		i.jumpDialog != nil && i.jumpDialog.Active() ||
85		i.btwDialog != nil && i.btwDialog.Active() ||
86		i.skillsDialog != nil && i.skillsDialog.Active() ||
87		i.changelogDialog != nil && i.changelogDialog.Active() ||
88		i.logoutDialog != nil && i.logoutDialog.Active() ||
89		i.telegramDialog != nil && i.telegramDialog.Active() ||
90		i.settingsDialog != nil && i.settingsDialog.Active() ||
91		i.sessionOpsDialog != nil && i.sessionOpsDialog.Active() ||
92		i.sessionTreeDialog != nil && i.sessionTreeDialog.Active() ||
93		i.extPanel != nil && i.extPanel.Active() ||
94		i.confirmDialog != nil && i.confirmDialog.Active() {
95		return true
96	}
97	if i.suggest != nil && i.suggest.Active(i.ed.Value()) ||
98		i.fileSuggest != nil && i.fileSuggest.Active(i.ed.Value()) {
99		return true
100	}
101	i.mu.Lock()
102	blocked := len(i.helpBlock) > 0 || len(i.extNotes) > 0
103	i.mu.Unlock()
104	return blocked
105}
106
107// canArmSessionTreeEscape checks only the cheap, local eligibility rules for
108// the first tap. The complete family read belongs to canOpenSessionTree on
109// the second tap; otherwise a malformed descendant would prevent arming and
110// the user would never receive the gate's status error.
111func (i *Interactive) canArmSessionTreeEscape() bool {
112	if i == nil || i.ed == nil || i.cfg.CWD == "" || i.sessionsRoot() == "" ||
113		i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
114		return false
115	}
116	if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 || i.sessionTreeEscapeBlocked() {
117		return false
118	}
119	i.mu.Lock()
120	busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
121		i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
122	queued := len(i.queued) != 0
123	ag := i.agent
124	pendingFork := i.pendingFork
125	i.mu.Unlock()
126	if busy || queued || pendingFork || ag == nil || ag.QueuedMessageCount() != 0 {
127		return false
128	}
129	return i.currentSessionPath() != ""
130}
131
132// canOpenSessionTree is the shared, fail-closed precondition for both the
133// /session tree command and the bare-Escape gesture. It intentionally avoids
134// filesystem work so a slow or large session family cannot stall input; the
135// asynchronous dialog loader validates the family after the overlay opens.
136func (i *Interactive) canOpenSessionTree() bool {
137	if i == nil || i.ed == nil || i.cfg.CWD == "" || i.sessionsRoot() == "" ||
138		i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
139		return false
140	}
141	if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 || i.sessionTreeEscapeBlocked() {
142		return false
143	}
144
145	i.mu.Lock()
146	busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
147		i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
148	queued := len(i.queued) != 0
149	ag := i.agent
150	pendingFork := i.pendingFork
151	i.mu.Unlock()
152	if busy || queued || pendingFork || ag == nil {
153		return false
154	}
155	if ag.QueuedMessageCount() != 0 {
156		return false
157	}
158
159	return i.currentSessionPath() != ""
160}
161
162// canCommitSessionTreeSelection is the open-gate subset used after the tree
163// already owns the keyboard. It deliberately permits the active tree dialog;
164// reusing canOpenSessionTree here would reject every Enter selection because
165// the dialog is itself an overlay.
166func (i *Interactive) canCommitSessionTreeSelection() bool {
167	if i == nil || i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil || i.sessionsRoot() == "" || i.cfg.CWD == "" {
168		return false
169	}
170	i.mu.Lock()
171	busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
172		i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
173	queued := len(i.queued) != 0
174	ag := i.agent
175	i.mu.Unlock()
176	if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
177		return false
178	}
179	return i.currentSessionPath() != ""
180}
181
182func (i *Interactive) setSessionTreeError(message string) {
183	if i.sessionTreeDialog != nil {
184		i.sessionTreeDialog.Close()
185	}
186	i.mu.Lock()
187	i.statusErr = message
188	i.statusOK = ""
189	i.mu.Unlock()
190	i.invalidate()
191}
192
193// openSessionTree is the only path that activates the session-tree overlay.
194// It queues the family read rather than performing it on the input goroutine;
195// a failed asynchronous read closes the overlay and reports the usual error.
196func (i *Interactive) openSessionTree() bool {
197	if !i.canOpenSessionTree() {
198		i.setSessionTreeError("tree: session tree is unavailable")
199		return false
200	}
201	current := i.currentSessionPath()
202	if current == "" {
203		i.setSessionTreeError("tree: no session is active")
204		return false
205	}
206	if i.sessionTreeDialog == nil {
207		i.setSessionTreeError("tree: session tree is unavailable")
208		return false
209	}
210	i.mu.Lock()
211	loadCtx := i.runCtx
212	i.mu.Unlock()
213	if loadCtx == nil {
214		loadCtx = context.Background()
215	}
216	i.sessionTreeLoads = i.sessionTreeDialog.OpenSessionFamilyAsync(loadCtx, i.sessionsRoot(), i.cfg.CWD, current, i.cfg.FlushSession)
217	i.mu.Lock()
218	i.statusErr = ""
219	i.statusOK = ""
220	i.mu.Unlock()
221	i.invalidate()
222	return true
223}
224
225type sessionTreeSelectionResult struct {
226	upTo         int
227	restoreDraft bool
228	draftText    string
229	images       []clipboardImageAttachment
230}
231
232// sessionTreeSelection turns a structured dialog target into a branch
233// boundary. User rows are boundaries before that message and restore the
234// full editable draft; assistant/tool rows include the selected completed
235// exchange. Empty and detached rows are already safe message-count
236// boundaries. A tool call and its result rows are never split.
237func sessionTreeSelection(msgs []provider.Message, target sessionTreeTarget) (sessionTreeSelectionResult, error) {
238	if target.IsBoundary() {
239		upTo := target.SelectionBoundary
240		if upTo < 0 || upTo > len(msgs) || !safeSessionToolBoundary(msgs[:upTo]) {
241			return sessionTreeSelectionResult{}, fmt.Errorf("selection splits a tool exchange")
242		}
243		return sessionTreeSelectionResult{upTo: upTo}, nil
244	}
245	msgIdx := target.EffectiveIndex
246	if msgIdx < 0 || msgIdx >= len(msgs) {
247		return sessionTreeSelectionResult{}, fmt.Errorf("selection is outside the session")
248	}
249	selected := msgs[msgIdx]
250	if selected.Role == provider.RoleUser {
251		upTo := target.SelectionBoundary
252		if upTo < 0 || upTo > len(msgs) || !safeSessionToolBoundary(msgs[:upTo]) {
253			return sessionTreeSelectionResult{}, fmt.Errorf("selection splits a tool exchange")
254		}
255		text, images := sessionTreeDraft(selected, target.UserDraft)
256		return sessionTreeSelectionResult{
257			upTo:         upTo,
258			restoreDraft: true,
259			draftText:    text,
260			images:       images,
261		}, nil
262	}
263
264	upTo := target.SelectionBoundary
265	if upTo < msgIdx+1 || upTo > len(msgs) {
266		return sessionTreeSelectionResult{}, fmt.Errorf("selection is outside the session")
267	}
268	if selected.Role == provider.RoleAssistant && messageHasToolCall(selected) || selected.Role == provider.RoleTool {
269		// A provider tool exchange may occupy one assistant row followed by
270		// one or more tool rows. Extend a selected row to the end of that
271		// contiguous exchange before checking the boundary.
272		for upTo < len(msgs) && msgs[upTo].Role == provider.RoleTool {
273			upTo++
274		}
275	}
276	if !safeSessionToolBoundary(msgs[:upTo]) {
277		return sessionTreeSelectionResult{}, fmt.Errorf("selection splits a tool exchange")
278	}
279	return sessionTreeSelectionResult{upTo: upTo}, nil
280}
281
282func messageHasToolCall(msg provider.Message) bool {
283	for _, content := range msg.Content {
284		if _, ok := content.(provider.ToolCallBlock); ok {
285			return true
286		}
287	}
288	return false
289}
290
291// safeSessionToolBoundary checks the provider's structural invariant at a
292// message cut: every tool call in the prefix has its matching result in the
293// immediately following tool row, and no result is orphaned.
294func safeSessionToolBoundary(msgs []provider.Message) bool {
295	pending := map[string]bool{}
296	for _, msg := range msgs {
297		if len(pending) > 0 && msg.Role != provider.RoleTool {
298			return false
299		}
300		switch msg.Role {
301		case provider.RoleAssistant:
302			if len(pending) != 0 {
303				return false
304			}
305			for _, content := range msg.Content {
306				if call, ok := content.(provider.ToolCallBlock); ok {
307					if call.ID == "" || pending[call.ID] {
308						return false
309					}
310					pending[call.ID] = true
311				}
312			}
313		case provider.RoleTool:
314			if len(pending) == 0 {
315				if len(msg.AddedToolNames) > 0 {
316					// Deferred-tool activation rows intentionally carry
317					// provider-local state without a preceding tool call.
318					continue
319				}
320				return false
321			}
322			for _, content := range msg.Content {
323				result, ok := content.(provider.ToolResultBlock)
324				if !ok || !pending[result.CallID] {
325					return false
326				}
327				delete(pending, result.CallID)
328			}
329		}
330	}
331	return len(pending) == 0
332}
333
334func sessionTreeDraft(msg provider.Message, fallback string) (string, []clipboardImageAttachment) {
335	// Agent.Prompt stores the text first and appends image blocks after it,
336	// even when an older transcript contains several text blocks. Rebuild
337	// that same editor representation: join every text block, then place
338	// image markers after the complete text so submitting the restored draft
339	// recreates the original text/images pair without losing any text.
340	var textBlocks []string
341	var imageBlocks []provider.ImageBlock
342	for _, content := range msg.Content {
343		switch block := content.(type) {
344		case provider.TextBlock:
345			textBlocks = append(textBlocks, block.Text)
346		case provider.ImageBlock:
347			imageBlocks = append(imageBlocks, block)
348		}
349	}
350	var text strings.Builder
351	if len(textBlocks) > 0 {
352		text.WriteString(strings.Join(textBlocks, "\n"))
353	} else {
354		text.WriteString(fallback)
355	}
356	images := make([]clipboardImageAttachment, 0, len(imageBlocks))
357	for _, block := range imageBlocks {
358		if text.Len() > 0 {
359			last := text.String()
360			if len(last) > 0 && last[len(last)-1] != ' ' && last[len(last)-1] != '\n' && last[len(last)-1] != '\t' {
361				text.WriteByte(' ')
362			}
363		}
364		marker := fmt.Sprintf("[clipboard image #%d]", len(images)+1)
365		text.WriteString(marker)
366		text.WriteByte(' ')
367		images = append(images, clipboardImageAttachment{
368			Marker: marker,
369			Image: provider.ImageBlock{
370				MimeType:         block.MimeType,
371				Data:             append([]byte(nil), block.Data...),
372				ThoughtSignature: block.ThoughtSignature,
373			},
374		})
375	}
376	return text.String(), images
377}

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