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