1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "time"
9
10 "github.com/bnema/zut/packages/core"
11 "github.com/bnema/zut/packages/provider"
12 "github.com/bnema/zut/packages/tui"
13)
14
15func (i *Interactive) armCtrlCExit() {
16 i.mu.Lock()
17 i.lastCtrlC = time.Now()
18 i.mu.Unlock()
19}
20func (i *Interactive) ctrlCExitArmed() bool {
21 i.mu.Lock()
22 t := i.lastCtrlC
23 i.mu.Unlock()
24 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
25}
26func (i *Interactive) clearFileSuggestQuery() {
27 val := i.ed.Value()
28 if idx := strings.LastIndex(val, "@"); idx >= 0 {
29 i.ed.SetValue(val[:idx+1])
30 }
31}
32func (i *Interactive) setToolExpansion(expanded bool) {
33 i.mu.Lock()
34 i.view.ExpandAll = expanded
35 i.requestRendererClear()
36 i.mu.Unlock()
37 i.invalidate()
38}
39func (i *Interactive) toggleToolExpansion() {
40 i.mu.Lock()
41 expanded := !i.view.ExpandAll
42 i.mu.Unlock()
43 i.setToolExpansion(expanded)
44}
45func (i *Interactive) toggleBtwToolExpansion() {
46 i.btwDialog.ToggleToolExpansion()
47 i.mu.Lock()
48 i.requestRendererClear()
49 i.mu.Unlock()
50 i.invalidate()
51}
52func (i *Interactive) confirmChildActive() bool {
53 i.mu.Lock()
54 defer i.mu.Unlock()
55 return i.confirmChildActiveLocked()
56}
57func (i *Interactive) confirmChildActiveLocked() bool {
58 return len(i.helpBlock) > 0 ||
59 len(i.extNotes) > 0 ||
60 i.dialog.Active() ||
61 i.modelDialog.Active() ||
62 i.llamaDialog.Active() ||
63 i.rescueDialog.Active() ||
64 i.sessionDialog.Active() ||
65 i.residentSubagentsDialog.Active() ||
66 i.residentChildSession != nil ||
67 i.jumpDialog.Active() ||
68 i.btwDialog.Active() ||
69 i.skillsDialog.Active() ||
70 i.changelogDialog.Active() ||
71 i.logoutDialog.Active() ||
72 i.telegramDialog.Active() ||
73 i.settingsDialog.Active() ||
74 i.sessionOpsDialog.Active() ||
75 i.sessionTreeDialog.Active() ||
76 i.extPanel.Active()
77}
78func (i *Interactive) restoreConfirmFocus() {
79 if !i.confirmDialog.Active() || i.confirmDialog.Focused() {
80 return
81 }
82 if !i.ed.IsEmpty() || i.confirmChildActive() {
83 return
84 }
85 i.confirmDialog.Focus()
86 i.invalidate()
87}
88func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
89 defer i.restoreConfirmFocus()
90
91 var ok bool
92 if k, ok = i.prepareInputKey(k); !ok {
93 return false
94 }
95
96 // Dialogs consume keys while open (except ctrl+c, which always closes them).
97
98 // Confirmation owns input by default. Pressing / deliberately moves
99 // focus to the main editor without answering the pending request, so
100 // slash commands and the dialogs they open can run first.
101 if i.confirmDialog.Focused() {
102 if k.Kind == tui.KeyRune && k.Rune == '/' {
103 i.confirmDialog.Blur()
104 i.ed.Insert("/")
105 i.suggest.Reset()
106 i.invalidate()
107 return false
108 }
109 if k.Kind == tui.KeyCtrlO {
110 if i.btwDialog != nil && i.btwDialog.Active() {
111 i.toggleBtwToolExpansion()
112 } else {
113 i.toggleToolExpansion()
114 }
115 return false
116 }
117 i.confirmDialog.HandleKey(k)
118 i.invalidate()
119 return false
120 }
121 if i.confirmDialog.Active() && !i.confirmChildActive() && k.Kind == tui.KeyEsc {
122 i.ed.Clear()
123 i.suggest.Reset()
124 i.fileSuggest.Reset()
125 i.confirmDialog.Focus()
126 i.invalidate()
127 return false
128 }
129 if i.dialog.Active() {
130 if k.Kind == tui.KeyCtrlC {
131 i.dialog.Close()
132 if i.cfg.AuthManager != nil {
133 i.cfg.AuthManager.CancelOAuth()
134 }
135 return false
136 }
137 act := i.dialog.HandleKey(k)
138 if act.StartAPIKey {
139 i.startAPIKeyFlow(act.Provider)
140 }
141 if act.StartOAuth {
142 i.startOAuthFlow(act.Provider)
143 }
144 if act.StartManual {
145 i.startManualOAuthFlow(act.Provider)
146 }
147 if act.SubmitCode != "" {
148 i.submitManualOAuthCode(act.SubmitCode)
149 }
150 if act.SaveLlama {
151 i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey)
152 }
153 return false
154 }
155 if i.modelDialog.Active() {
156 if k.Kind == tui.KeyCtrlC {
157 i.modelDialog.Close()
158 i.quickModelAssign = 0
159 return false
160 }
161 act := i.modelDialog.HandleKey(k)
162 if act.Close {
163 i.quickModelAssign = 0
164 }
165 if act.ReasoningChanged {
166 i.applyReasoningSetting(act.Reasoning)
167 }
168 if act.Select {
169 if i.quickModelAssign > 0 {
170 i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model)
171 i.quickModelAssign = 0
172 } else {
173 i.applyModelSelection(act.Provider, act.Model)
174 }
175 }
176 return false
177 }
178 if i.llamaDialog.Active() {
179 i.llamaDialog.HandleKey(k)
180 i.invalidate()
181 return false
182 }
183 if i.rescueDialog.Active() {
184 if k.Kind == tui.KeyCtrlC {
185 i.rescueDialog.Close()
186 i.invalidate()
187 return false
188 }
189 act := i.rescueDialog.HandleKey(k)
190 if act.Select {
191 i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
192 }
193 i.invalidate()
194 return false
195 }
196 i.mu.Lock()
197 sessionDialogActive := i.sessionDialog.Active()
198 i.mu.Unlock()
199 if sessionDialogActive {
200 if k.Kind == tui.KeyCtrlC {
201 i.mu.Lock()
202 i.sessionDialog.Close()
203 i.sessionLoads = nil
204 i.sessionSearches = nil
205 i.mu.Unlock()
206 i.invalidate()
207 return false
208 }
209 manualRenamePath := ""
210 renameActive := false
211 hasSelection := false
212 i.mu.Lock()
213 renameActive = i.sessionDialog.renaming
214 hasSelection = i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions)
215 if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && hasSelection && i.cfg.CurrentSessionPath != nil {
216 manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path
217 }
218 i.mu.Unlock()
219 if k.Kind == tui.KeyEnter && !renameActive && hasSelection && !i.canResumeSessionSelection() {
220 i.mu.Lock()
221 i.statusErr = "wait for the active work to finish before resuming a session"
222 i.statusOK = ""
223 i.mu.Unlock()
224 i.invalidate()
225 return false
226 }
227 manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath
228 if manualRenameCurrent {
229 i.markSessionTitleSwitching()
230 }
231 i.mu.Lock()
232 act := i.sessionDialog.HandleKey(k)
233 if act.Select || act.Close {
234 i.sessionLoads = nil
235 i.sessionSearches = nil
236 }
237 if act.ToggleScope {
238 i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD, !i.sessionDialog.allScope)
239 i.sessionSearches = nil
240 }
241 if act.StartSearch {
242 i.sessionSearches = i.sessionDialog.StartSearch(ctx)
243 }
244 i.mu.Unlock()
245 if act.Select {
246 i.applySessionSelection(act.Path)
247 }
248 if act.Err != nil {
249 if manualRenameCurrent {
250 i.restoreFailedSessionTitle()
251 }
252 i.mu.Lock()
253 i.statusErr = "rename: " + act.Err.Error()
254 i.statusOK = ""
255 i.mu.Unlock()
256 } else if act.Renamed && act.Path != "" && i.cfg.CurrentSessionPath != nil && i.cfg.CurrentSessionPath() == act.Path {
257 i.setManualSessionTitle(act.RenameTitle)
258 }
259 // Always request a redraw after handling a key here: when esc
260 // closes the picker, the overlay-close detection in the render
261 // pass needs to run so the tall dialog rows get repainted from
262 // the chat (otherwise VS Code's retained scrollback leaves a
263 // duplicate frame on screen).
264 i.invalidate()
265 return false
266 }
267 i.mu.Lock()
268 residentDialog := i.residentSubagentsDialog
269 residentDialogActive := residentDialog != nil && residentDialog.Active()
270 residentSession := i.residentChildSession
271 i.mu.Unlock()
272 if residentDialogActive {
273 if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
274 i.mu.Lock()
275 if i.residentSubagentsDialog == residentDialog {
276 residentDialog.Close()
277 }
278 i.mu.Unlock()
279 i.invalidate()
280 return false
281 }
282 i.mu.Lock()
283 childID := residentDialog.HandleKey(k)
284 i.mu.Unlock()
285 if childID != "" {
286 i.openResidentChildSession(childID)
287 }
288 i.invalidate()
289 return false
290 }
291 if residentSession != nil {
292 switch k.Kind {
293 case tui.KeyCtrlC, tui.KeyEsc:
294 i.mu.Lock()
295 if i.residentChildSession == residentSession {
296 i.residentChildSession = nil
297 }
298 i.mu.Unlock()
299 residentSession.Close()
300 i.setResidentChildMouseReporting(false)
301 case tui.KeyPageUp:
302 residentSession.Scroll(10)
303 case tui.KeyPageDown:
304 residentSession.Scroll(-10)
305 case tui.KeyUp:
306 if !residentSession.HandleNavigation(k) {
307 residentSession.Scroll(1)
308 }
309 case tui.KeyDown:
310 if !residentSession.HandleNavigation(k) {
311 residentSession.Scroll(-1)
312 }
313 case tui.KeyMouseWheelUp:
314 residentSession.Scroll(1)
315 case tui.KeyMouseWheelDown:
316 residentSession.Scroll(-1)
317 default:
318 session := residentSession
319 if prompt, submit := session.HandleKey(k); submit {
320 go func() {
321 err := i.cfg.ResidentManager.Resume(ctx, session.childID, prompt)
322 session.FinishSubmission(err)
323 i.invalidate()
324 }()
325 }
326 }
327 i.invalidate()
328 return false
329 }
330 if i.logoutDialog.Active() {
331 if k.Kind == tui.KeyCtrlC {
332 i.logoutDialog.Close()
333 i.invalidate()
334 return false
335 }
336 act := i.logoutDialog.HandleKey(k)
337 if act.Select {
338 i.doLogout(act.Target)
339 }
340 i.invalidate()
341 return false
342 }
343 if i.telegramDialog.Active() {
344 if k.Kind == tui.KeyCtrlC {
345 i.telegramDialog.Close()
346 i.invalidate()
347 return false
348 }
349 act := i.telegramDialog.HandleKey(k)
350 if act.Select {
351 i.doTelegram(act.Action)
352 }
353 i.invalidate()
354 return false
355 }
356 if i.settingsDialog.Active() {
357 if k.Kind == tui.KeyCtrlC {
358 i.settingsDialog.Close()
359 i.invalidate()
360 return false
361 }
362 act := i.settingsDialog.HandleKey(k)
363 if act.ModelShortcutSlot > 0 {
364 i.openQuickModelPicker(act.ModelShortcutSlot)
365 }
366 if act.Toggle {
367 i.applySettingChange(act)
368 }
369 i.invalidate()
370 return false
371 }
372 if i.sessionOpsDialog.Active() {
373 if k.Kind == tui.KeyCtrlC {
374 i.sessionOpsDialog.Close()
375 i.invalidate()
376 return false
377 }
378 act := i.sessionOpsDialog.HandleKey(k)
379 if act.Select {
380 i.doSessionOp(act.Action, "")
381 }
382 i.invalidate()
383 return false
384 }
385 if i.sessionTreeDialog.Active() {
386 if k.Kind == tui.KeyCtrlC {
387 i.sessionTreeDialog.Close()
388 i.invalidate()
389 return false
390 }
391 act := i.sessionTreeDialog.HandleKey(k)
392 if act.Select {
393 if act.Target.Role != "" || act.Target.SourcePath != "" || act.Target.IsBoundary() || act.Target.UserDraft != "" {
394 i.applySessionTreeTarget(act.Target, act.TurnNo)
395 } else {
396 // Keep package embedders that still return the scalar action
397 // source-compatible while the dialog migrates to Target.
398 i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
399 }
400 }
401 i.invalidate()
402 return false
403 }
404 if i.extPanel.Active() {
405 if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
406 if i.cfg.Extensions != nil {
407 _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
408 }
409 i.extPanel.Close()
410 i.invalidate()
411 return false
412 }
413 if i.cfg.Extensions != nil {
414 _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
415 }
416 return false
417 }
418 if i.jumpDialog.Active() {
419 if k.Kind == tui.KeyCtrlC {
420 i.jumpDialog.Close()
421 i.pendingFork = false
422 return false
423 }
424 act := i.jumpDialog.HandleKey(k)
425 if act.Select {
426 if i.pendingFork {
427 i.applyForkSelection(act.MessageIdx)
428 } else {
429 i.applyJumpSelection(act.MessageIdx, act.TurnNo)
430 }
431 }
432 // If the user dismissed the dialog without selecting, also
433 // clear the pending-fork flag so a later plain /jump isn't
434 // hijacked.
435 if act.Close {
436 i.pendingFork = false
437 }
438 return false
439 }
440 if i.btwDialog.Active() {
441 if k.Kind == tui.KeyCtrlC {
442 i.btwDialog.Close()
443 i.invalidate()
444 return false
445 }
446 if k.Kind == tui.KeyCtrlO {
447 i.toggleBtwToolExpansion()
448 return false
449 }
450 i.btwDialog.HandleKey(k, i.invalidate)
451 return false
452 }
453 if i.skillsDialog.Active() {
454 if k.Kind == tui.KeyCtrlC {
455 i.skillsDialog.Close()
456 i.invalidate()
457 return false
458 }
459 i.skillsDialog.HandleKey(k)
460 i.invalidate()
461 return false
462 }
463 if i.changelogDialog.Active() {
464 if closed := i.changelogDialog.HandleKey(k); closed {
465 // User dismissed; let the parent persist the
466 // LastChangelogShown marker via the close callback.
467 if i.cfg.OnChangelogDismiss != nil {
468 i.cfg.OnChangelogDismiss()
469 }
470 }
471 i.invalidate()
472 return false
473 }
474
475 if slot := quickModelShortcutSlot(k); slot > 0 {
476 i.applyQuickModelShortcut(slot)
477 return false
478 }
479
480 // The first eligible bare Escape is deliberately left to the existing
481 // Escape path (which clears idle input or cancels a turn). A second tap
482 // within the gesture window only gets first refusal while the cheap,
483 // local eligibility rules still hold. If a turn started, a message was
484 // queued, or the input became ineligible between taps, reset the detector
485 // and fall through so Escape retains its normal cancellation behavior.
486 // Once those rules still hold, the full tree gate owns the second tap:
487 // family/read failures are fail-closed but are nevertheless consumed so
488 // they cannot fall through into ordinary editor handling.
489 if isUnmodifiedEscape(k) {
490 now := i.sessionTreeEscapeNow()
491 i.mu.Lock()
492 consumed := i.doubleEscape.Consume(now)
493 i.mu.Unlock()
494 if consumed {
495 if i.canArmSessionTreeEscape() {
496 i.openSessionTree()
497 return false
498 }
499 i.mu.Lock()
500 i.doubleEscape.Reset()
501 i.mu.Unlock()
502 } else if i.canArmSessionTreeEscape() {
503 i.mu.Lock()
504 i.doubleEscape.Arm(now)
505 i.mu.Unlock()
506 }
507 }
508
509 // Global keys.
510 switch k.Kind {
511 case tui.KeyCtrlC:
512 i.mu.Lock()
513 loadingSession := i.sessionLoading
514 i.mu.Unlock()
515 if loadingSession {
516 i.markCtrlCExit()
517 return true
518 }
519 // While busy: do NOT cancel the turn. ctrl+c during a
520 // running turn is almost always reflex muscle memory
521 // ("be quiet" in a shell) rather than a deliberate
522 // decision to kill a multi-minute model call that's
523 // already cost tokens. Use esc to interrupt a turn; use
524 // a deliberate double-ctrl+c to exit zut entirely. First
525 // press arms the exit hint, second press within
526 // ctrlCExitWindow quits.
527 if i.busy {
528 if i.ctrlCExitArmed() {
529 i.markCtrlCExit()
530 return true
531 }
532 i.mu.Lock()
533 i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
534 i.statusErr = ""
535 i.mu.Unlock()
536 i.armCtrlCExit()
537 return false
538 }
539 // Idle: first press clears the editor (and any queued
540 // follow-up messages); a second press within ctrlCExitWindow
541 // exits. With both an empty editor and no queue the first
542 // press still just arms — require a deliberate double-tap.
543 ag := i.agent
544 pending := 0
545 if ag != nil {
546 pending = ag.QueuedMessageCount()
547 }
548 hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
549 if hadInput {
550 i.ed.Clear()
551 i.clipboardImages = nil
552 i.suggest.Reset()
553 if ag != nil {
554 ag.DrainQueuedMessages()
555 }
556 i.mu.Lock()
557 i.queued = nil
558 i.statusOK = "input cleared"
559 i.statusErr = ""
560 i.mu.Unlock()
561 i.armCtrlCExit()
562 return false
563 }
564 if i.ctrlCExitArmed() {
565 i.markCtrlCExit()
566 return true
567 }
568 i.mu.Lock()
569 i.statusOK = "press ctrl+c again to exit"
570 i.statusErr = ""
571 i.mu.Unlock()
572 i.armCtrlCExit()
573 return false
574 case tui.KeyEsc:
575 // Esc interrupts a running turn — but only when nothing
576 // else on screen wants to consume the key first. The slash
577 // popup has its own Esc behaviour (close + clear editor),
578 // and transient overlays like the /help block and extension
579 // notes should dismiss on Esc before we even consider the
580 // turn. Without these guards, a casual Esc press after
581 // running /help on a busy turn rips the turn away.
582 if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
583 break
584 }
585 i.mu.Lock()
586 hadHelp := len(i.helpBlock) > 0
587 hadNotes := len(i.extNotes) > 0
588 if hadHelp {
589 i.helpBlock = nil
590 }
591 if hadNotes {
592 i.extNotes = nil
593 }
594 i.mu.Unlock()
595 if hadHelp || hadNotes {
596 i.invalidate()
597 return false
598 }
599 i.mu.Lock()
600 busyCancel := i.busy && i.cancelTurn != nil
601 cancelTurn := i.cancelTurn
602 var restoreQueued core.QueuedMessage
603 var hasRestoreQueued bool
604 var handoff json.RawMessage
605 var persistHandoff bool
606 if busyCancel {
607 // Keep the most recently queued follow-up as an editable draft
608 // instead of losing it with the cancelled turn's stale queue.
609 if i.agent != nil {
610 restoreQueued, hasRestoreQueued = i.agent.PopQueuedMessage()
611 }
612 if !hasRestoreQueued && len(i.queued) > 0 {
613 n := len(i.queued) - 1
614 restoreQueued = i.queued[n]
615 i.queued = i.queued[:n]
616 hasRestoreQueued = true
617 }
618 handoff, persistHandoff = i.resetCompactContinuationLocked()
619 }
620 i.mu.Unlock()
621 if busyCancel {
622 if hasRestoreQueued {
623 i.restoreQueuedMessageToEditor(restoreQueued)
624 }
625 i.updateActiveGoal(core.GoalPaused, "interrupted by user")
626 cancelTurn()
627 // If a confirm dialog is pending, refuse it so the agent
628 // goroutine unblocks and the context cancellation can
629 // actually take effect.
630 i.confirmDialog.CancelAll("turn cancelled")
631 if persistHandoff {
632 i.persistCompactHandoff(handoff)
633 }
634 return false
635 }
636 case tui.KeyCtrlD:
637 if i.ed.IsEmpty() && !i.busy {
638 return true
639 }
640 case tui.KeyCtrlB:
641 i.mu.Lock()
642 i.rightBarHidden = !i.rightBarHidden
643 i.mu.Unlock()
644 i.requestRendererInvalidate()
645 i.invalidate()
646 return false
647 case tui.KeyCtrlL:
648 i.requestRendererClear()
649 i.invalidate()
650 return false
651 case tui.KeyPasteClipboard:
652 i.pasteClipboard(ctx)
653 return false
654 case tui.KeyCtrlO:
655 i.toggleToolExpansion()
656 return false
657 case tui.KeyPageUp:
658 i.scrollBy(+i.chatPage())
659 return false
660 case tui.KeyPageDown:
661 i.scrollBy(-i.chatPage())
662 return false
663 case tui.KeyUp:
664 // Alt/Option+Up: pop the most recently queued ("sliding in")
665 // message back into the editor so the user can edit and
666 // resend it. Repeated presses keep peeling messages off the
667 // tail of the queue; each press *replaces* the editor
668 // contents (we don't append/push). When the queue is empty
669 // the keypress falls through to the normal scroll behavior.
670 if k.Alt {
671 i.mu.Lock()
672 var message core.QueuedMessage
673 var ok bool
674 if i.agent != nil {
675 message, ok = i.agent.PopQueuedMessage()
676 }
677 if !ok {
678 if n := len(i.queued); n > 0 {
679 message = i.queued[n-1]
680 i.queued = i.queued[:n-1]
681 ok = true
682 }
683 }
684 i.mu.Unlock()
685 if ok && i.restoreQueuedMessageToEditor(message) {
686 i.invalidate()
687 return false
688 }
689 }
690 // In multi-line / wrapped input, Up first moves inside the editor.
691 // At the editor's top edge, plain Up can browse input history when
692 // history browsing is safe/active; otherwise it falls back to chat
693 // scrolling, preserving the old single-line scroll behavior.
694 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
695 if i.ed.MoveVertical(-1) {
696 i.invalidate()
697 return false
698 }
699 if !k.Alt && i.handleInputHistoryKey(k) {
700 return false
701 }
702 i.scrollBy(+3)
703 return false
704 }
705 case tui.KeyDown:
706 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
707 if i.ed.MoveVertical(+1) {
708 i.invalidate()
709 return false
710 }
711 if !k.Alt && i.inputHistoryIndex < 0 && i.cfg.ResidentManager != nil {
712 if _, total := i.cfg.ResidentManager.RecentSnapshotPage(0, 1); total > 0 {
713 i.runResidentSubagents(ctx, nil)
714 return false
715 }
716 }
717 if !k.Alt && i.handleInputHistoryKey(k) {
718 return false
719 }
720 if i.scrollOffset > 0 {
721 i.scrollBy(-3)
722 }
723 return false
724 }
725 }
726
727 // Note: we intentionally do NOT gate the editor on i.busy here.
728 // Typing while the agent is working is supported — submitted
729 // messages are queued and delivered as follow-up turns when the
730 // current turn ends. See the submit handler below.
731
732 if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) {
733 i.ed.HandleKey(k)
734 return false
735 }
736
737 // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
738 if i.suggest.Active(i.ed.Value()) {
739 switch k.Kind {
740 case tui.KeyUp:
741 i.suggest.Up()
742 return false
743 case tui.KeyDown:
744 i.suggest.Down()
745 return false
746 case tui.KeyPageUp:
747 i.suggest.PageUp()
748 return false
749 case tui.KeyPageDown:
750 i.suggest.PageDown()
751 return false
752 case tui.KeyTab:
753 if name := i.suggest.Selection(i.ed.Value()); name != "" {
754 i.ed.SetValue(name)
755 i.suggest.Reset()
756 }
757 return false
758 case tui.KeyEnter:
759 // Enter on an ambiguous or partial slash prefix: complete to the
760 // currently highlighted command and run it. That way typing
761 // "/lo" + enter picks whichever of /login or /logout is selected
762 // in the popup instead of submitting "/lo" as unknown. Also
763 // clear the editor so the command doesn't linger after the
764 // dialog opens/closes.
765 if name := i.suggest.Selection(i.ed.Value()); name != "" {
766 i.ed.Clear()
767 i.suggest.Reset()
768 return i.runSlash(ctx, name)
769 }
770 case tui.KeyEsc:
771 i.ed.Clear()
772 i.suggest.Reset()
773 return false
774 }
775 }
776
777 // File suggestions: intercept up/down/tab/enter when the @-popup is visible.
778 if i.fileSuggest.Active(i.ed.Value()) {
779 switch k.Kind {
780 case tui.KeyUp:
781 i.fileSuggest.Up()
782 return false
783 case tui.KeyDown:
784 i.fileSuggest.Down()
785 return false
786 case tui.KeyRight:
787 // Open selected directory. The filter the user typed picked
788 // that directory at the current level; once we descend it no
789 // longer applies to the directory's contents, so clear it.
790 // Otherwise typing "@eda" then right would re-filter inside
791 // eda/ by "eda" and show nothing.
792 if i.fileSuggest.Right() {
793 i.clearFileSuggestQuery()
794 }
795 return false
796 case tui.KeyLeft:
797 // Go back to parent directory. Clear the filter for the same
798 // reason as Right: it was scoped to the level we just left.
799 if i.fileSuggest.Left() {
800 i.clearFileSuggestQuery()
801 }
802 return false
803 case tui.KeyEnter:
804 if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
805 var chip string
806 if entry.isDir {
807 chip = "[dir:" + entry.rel + "/]"
808 } else {
809 chip = "[file:" + entry.rel + "]"
810 }
811 val := i.ed.Value()
812 if idx := strings.LastIndex(val, "@"); idx >= 0 {
813 val = val[:idx]
814 }
815 i.ed.SetValue(val + chip + " ")
816 i.fileSuggest.Reset()
817 }
818 return false
819 case tui.KeyEsc:
820 val := i.ed.Value()
821 if idx := strings.LastIndex(val, "@"); idx >= 0 {
822 i.ed.SetValue(val[:idx])
823 }
824 i.fileSuggest.Reset()
825 return false
826 }
827 }
828
829 // Tab-complete a path token in the editor when no popup is open.
830 // Recognises tokens that look like paths (start with ~, /, ./, ../
831 // or contain a slash); shell-style completion expands ~, lists the
832 // parent dir, and completes the basename to the longest common
833 // prefix. Single match: full replace and trailing / for dirs.
834 // No match: no-op. Plain bare words (no slash, no tilde) fall
835 // through so Tab keeps its current no-op behaviour outside paths.
836 if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
837 if i.tryPathTabComplete() {
838 return false
839 }
840 }
841
842 if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
843 i.inputHistoryIndex = -1
844 }
845
846 if k.Kind == tui.KeyEsc {
847 i.clipboardImages = nil
848 }
849 if submit := i.ed.HandleKey(k); submit {
850 // SubmitValue() expands any [pasted text #N +L lines]
851 // placeholders back into their bodies; the raw Value()
852 // is only what the user sees on screen.
853 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
854 // Expand [file:name] and [dir:name/] chips to full paths.
855 text = expandFileChips(text, i.cfg.CWD)
856 text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
857 if text == "" && len(images) == 0 {
858 return false
859 }
860 clearSubmittedInput := func() {
861 i.clipboardImages = nil
862 i.ed.Clear()
863 i.inputHistoryIndex = -1
864 i.suggest.Reset()
865 i.fileSuggest.Reset()
866 }
867
868 // Shell escapes and slash commands are text-only. An image-bearing
869 // draft remains a normal provider prompt instead of silently dropping
870 // its attachments.
871 if len(images) == 0 {
872 if cmd, ok := shellEscapeCommand(text); ok {
873 clearSubmittedInput()
874 i.startShellEscape(ctx, cmd)
875 return false
876 }
877 }
878
879 if len(images) == 0 && looksLikeSlashCommand(text) {
880 clearSubmittedInput()
881 head := text
882 rest := ""
883 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
884 head = text[:idx]
885 rest = strings.TrimSpace(text[idx:])
886 }
887 if !isKnownSlashCommand(text) {
888 // Try extensions before giving up. Extensions register
889 // commands by bare name (no leading slash); strip it here.
890 extName := strings.TrimPrefix(head, "/")
891 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
892 go i.invokeExtensionCommand(ctx, extName, rest)
893 return false
894 }
895 i.mu.Lock()
896 i.statusErr = "unknown command " + head + " — type /help to see the list"
897 i.statusOK = ""
898 i.mu.Unlock()
899 return false
900 }
901 // Slash commands run regardless of busy state. Commands that
902 // would mutate the transcript or replace the agent (/clear,
903 // /compact, /logout, /login, /model, /fork, /session fork)
904 // cancel the active turn
905 // first and wait for the goroutine to wind down so they don't
906 // race with a streaming response. Safe commands (/help,
907 // /jump, /sessions, /jail, /unjail, /exit) run immediately
908 // without disturbing the active turn.
909 if slashCommandCancelsTurn(text) {
910 i.cancelAndWaitForIdle()
911 }
912 return i.runSlash(ctx, text)
913 }
914
915 if i.agent == nil {
916 i.mu.Lock()
917 i.statusErr = "not logged in. type /login first."
918 i.mu.Unlock()
919 return false
920 }
921 // Mirror the user's typed prompt into the paired Telegram
922 // chat (when the bridge is active) so the Telegram thread
923 // stays a complete record of the session, not just the half
924 // that originated on the phone. On a goroutine so the
925 // network write doesn't delay the local turn.
926 if i.telegramBridge != nil && i.telegramBridge.Active() {
927 go i.telegramBridge.OnUserTyped(text)
928 }
929 i.maybeStartSessionTitle(ctx, text)
930 // If a turn is already in flight, queue this prompt inside the
931 // agent loop so it is delivered at the next safe model-call
932 // boundary instead of waiting for the whole run to finish.
933 i.mu.Lock()
934 busy := i.busy
935 compacting := i.compacting
936 ag := i.agent
937 i.mu.Unlock()
938 if busy {
939 clearSubmittedInput()
940 var handoff json.RawMessage
941 var persistHandoff bool
942 if ag != nil && !compacting {
943 i.mu.Lock()
944 handoff, persistHandoff = i.resetCompactContinuationLocked()
945 ag.QueueMessage(text, images)
946 i.mu.Unlock()
947 } else {
948 i.mu.Lock()
949 handoff, persistHandoff = i.resetCompactContinuationLocked()
950 i.queued = append(i.queued, core.QueuedMessage{Text: text, Images: images})
951 i.mu.Unlock()
952 }
953 if persistHandoff {
954 i.persistCompactHandoff(handoff)
955 }
956 i.invalidate()
957 return false
958 }
959 clearSubmittedInput()
960 i.startTurnWithImages(ctx, text, images)
961 }
962 return false
963}
964func (i *Interactive) pasteClipboard(ctx context.Context) {
965 image, ok, _ := tui.ReadClipboardImage(ctx)
966 if ok {
967 i.mu.Lock()
968 marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
969 i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
970 Marker: marker,
971 Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data},
972 })
973 i.ed.Insert(marker + " ")
974 i.statusOK = ""
975 i.statusErr = ""
976 i.mu.Unlock()
977 return
978 }
979 key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText)
980 i.mu.Lock()
981 defer i.mu.Unlock()
982 if err != nil {
983 i.statusErr = "clipboard paste failed: " + err.Error()
984 i.statusOK = ""
985 return
986 }
987 if pastedText {
988 i.ed.HandleKey(key)
989 i.statusErr = ""
990 i.statusOK = ""
991 return
992 }
993 i.statusErr = "clipboard does not contain text or an image"
994 i.statusOK = ""
995}
996func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
997 if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
998 return false
999 }
1000 // Do not steal normal vertical cursor movement. History browsing can only
1001 // start from an empty editor; once active, Up/Down keep walking
1002 // the ring so repeated presses work even though the editor now
1003 // contains the selected historical prompt.
1004 if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
1005 return false
1006 }
1007 hist := i.inputHistory()
1008 if len(hist) == 0 {
1009 return false
1010 }
1011
1012 if i.inputHistoryIndex < 0 {
1013 // Start just after the newest item so Up lands on the most
1014 // recent user prompt. A lone Down from an empty editor is not
1015 // history navigation; let the caller fall through to normal UI
1016 // behavior instead.
1017 if k.Kind != tui.KeyUp {
1018 return false
1019 }
1020 i.inputHistoryIndex = len(hist)
1021 }
1022
1023 switch k.Kind {
1024 case tui.KeyUp:
1025 if i.inputHistoryIndex > 0 {
1026 i.inputHistoryIndex--
1027 }
1028 case tui.KeyDown:
1029 if i.inputHistoryIndex < len(hist) {
1030 i.inputHistoryIndex++
1031 }
1032 }
1033
1034 if i.inputHistoryIndex >= len(hist) {
1035 i.ed.Clear()
1036 } else {
1037 i.ed.SetValue(hist[i.inputHistoryIndex])
1038 }
1039 return true
1040}
1041func (i *Interactive) inputHistory() []string {
1042 if i.agent == nil {
1043 return nil
1044 }
1045 msgs := i.agent.Messages()
1046 hist := make([]string, 0, len(msgs))
1047 for _, m := range msgs {
1048 if !isForkableUserMessage(m) {
1049 continue
1050 }
1051 text := userMessageText(m)
1052 if strings.TrimSpace(text) == "" {
1053 continue
1054 }
1055 hist = append(hist, text)
1056 }
1057 return hist
1058}
1059func userMessageText(m provider.Message) string {
1060 var sb strings.Builder
1061 for _, c := range m.Content {
1062 if tb, ok := c.(provider.TextBlock); ok {
1063 if sb.Len() > 0 {
1064 sb.WriteByte('\n')
1065 }
1066 sb.WriteString(tb.Text)
1067 }
1068 }
1069 return sb.String()
1070}