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