1package modes
2
3import (
4 "fmt"
5 "strings"
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
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.subagentsDialog != nil && i.subagentsDialog.Active() ||
83 i.jumpDialog != nil && i.jumpDialog.Active() ||
84 i.btwDialog != nil && i.btwDialog.Active() ||
85 i.skillsDialog != nil && i.skillsDialog.Active() ||
86 i.changelogDialog != nil && i.changelogDialog.Active() ||
87 i.logoutDialog != nil && i.logoutDialog.Active() ||
88 i.telegramDialog != nil && i.telegramDialog.Active() ||
89 i.settingsDialog != nil && i.settingsDialog.Active() ||
90 i.sessionOpsDialog != nil && i.sessionOpsDialog.Active() ||
91 i.sessionTreeDialog != nil && i.sessionTreeDialog.Active() ||
92 i.extPanel != nil && i.extPanel.Active() ||
93 i.confirmDialog != nil && i.confirmDialog.Active() {
94 return true
95 }
96 if i.suggest != nil && i.suggest.Active(i.ed.Value()) ||
97 i.fileSuggest != nil && i.fileSuggest.Active(i.ed.Value()) {
98 return true
99 }
100 i.mu.Lock()
101 blocked := len(i.helpBlock) > 0 || len(i.extNotes) > 0
102 i.mu.Unlock()
103 return blocked
104}
105
106// canArmSessionTreeEscape checks only the cheap, local eligibility rules for
107// the first tap. The complete family read belongs to canOpenSessionTree on
108// the second tap; otherwise a malformed descendant would prevent arming and
109// the user would never receive the gate's status error.
110func (i *Interactive) canArmSessionTreeEscape() bool {
111 if i == nil || i.ed == nil || i.cfg.CWD == "" || i.sessionsRoot() == "" ||
112 i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
113 return false
114 }
115 if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 || i.sessionTreeEscapeBlocked() {
116 return false
117 }
118 i.mu.Lock()
119 busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
120 i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
121 queued := len(i.queued) != 0
122 ag := i.agent
123 pendingFork := i.pendingFork
124 i.mu.Unlock()
125 if busy || queued || pendingFork || ag == nil || ag.QueuedMessageCount() != 0 {
126 return false
127 }
128 return i.currentSessionPath() != ""
129}
130
131// canOpenSessionTree is the shared, fail-closed precondition for both the
132// /session tree command and the bare-Escape gesture. It does not flush or
133// mutate session state. In particular, callers must not flush until this
134// check has returned true.
135func (i *Interactive) canOpenSessionTree() bool {
136 if i == nil || i.ed == nil || i.cfg.CWD == "" || i.sessionsRoot() == "" ||
137 i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
138 return false
139 }
140 if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 || i.sessionTreeEscapeBlocked() {
141 return false
142 }
143
144 i.mu.Lock()
145 busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
146 i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
147 queued := len(i.queued) != 0
148 ag := i.agent
149 pendingFork := i.pendingFork
150 i.mu.Unlock()
151 if busy || queued || pendingFork || ag == nil {
152 return false
153 }
154 if ag.QueuedMessageCount() != 0 {
155 return false
156 }
157
158 current := i.currentSessionPath()
159 if current == "" {
160 return false
161 }
162 roots, err := core.BuildSessionTreeFamilyStrict(i.sessionsRoot(), i.cfg.CWD, current)
163 return err == nil && len(roots) != 0
164}
165
166// canCommitSessionTreeSelection is the open-gate subset used after the tree
167// already owns the keyboard. It deliberately permits the active tree dialog;
168// reusing canOpenSessionTree here would reject every Enter selection because
169// the dialog is itself an overlay.
170func (i *Interactive) canCommitSessionTreeSelection() bool {
171 if i == nil || i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil || i.sessionsRoot() == "" || i.cfg.CWD == "" {
172 return false
173 }
174 i.mu.Lock()
175 busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
176 i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
177 queued := len(i.queued) != 0
178 ag := i.agent
179 i.mu.Unlock()
180 if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
181 return false
182 }
183 return i.currentSessionPath() != ""
184}
185
186func (i *Interactive) setSessionTreeError(message string) {
187 if i.sessionTreeDialog != nil {
188 i.sessionTreeDialog.Close()
189 }
190 i.mu.Lock()
191 i.statusErr = message
192 i.statusOK = ""
193 i.mu.Unlock()
194 i.invalidate()
195}
196
197// openSessionTree is the only path that activates the session-tree overlay.
198// The gate runs before FlushSession, and a failed family read leaves the
199// dialog closed instead of falling back to a stale in-memory transcript.
200func (i *Interactive) openSessionTree() bool {
201 if !i.canOpenSessionTree() {
202 i.setSessionTreeError("tree: session tree is unavailable")
203 return false
204 }
205 current := i.currentSessionPath()
206 if current == "" {
207 i.setSessionTreeError("tree: no session is active")
208 return false
209 }
210 if i.cfg.FlushSession != nil {
211 i.cfg.FlushSession()
212 }
213 if i.sessionTreeDialog == nil || !i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) {
214 i.setSessionTreeError("tree: no readable session family")
215 return false
216 }
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}