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