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