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