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