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