v0.25.1 025a25b

interactive_input_routing.go RawHistoryFolder

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.KeyCtrlB:
641		i.mu.Lock()
642		i.rightBarHidden = !i.rightBarHidden
643		i.mu.Unlock()
644		i.requestRendererInvalidate()
645		i.invalidate()
646		return false
647	case tui.KeyCtrlL:
648		i.requestRendererClear()
649		i.invalidate()
650		return false
651	case tui.KeyPasteClipboard:
652		i.pasteClipboard(ctx)
653		return false
654	case tui.KeyCtrlO:
655		i.toggleToolExpansion()
656		return false
657	case tui.KeyPageUp:
658		i.scrollBy(+i.chatPage())
659		return false
660	case tui.KeyPageDown:
661		i.scrollBy(-i.chatPage())
662		return false
663	case tui.KeyUp:
664		// Alt/Option+Up: pop the most recently queued ("sliding in")
665		// message back into the editor so the user can edit and
666		// resend it. Repeated presses keep peeling messages off the
667		// tail of the queue; each press *replaces* the editor
668		// contents (we don't append/push). When the queue is empty
669		// the keypress falls through to the normal scroll behavior.
670		if k.Alt {
671			i.mu.Lock()
672			var message core.QueuedMessage
673			var ok bool
674			if i.agent != nil {
675				message, ok = i.agent.PopQueuedMessage()
676			}
677			if !ok {
678				if n := len(i.queued); n > 0 {
679					message = i.queued[n-1]
680					i.queued = i.queued[:n-1]
681					ok = true
682				}
683			}
684			i.mu.Unlock()
685			if ok && i.restoreQueuedMessageToEditor(message) {
686				i.invalidate()
687				return false
688			}
689		}
690		// In multi-line / wrapped input, Up first moves inside the editor.
691		// At the editor's top edge, plain Up can browse input history when
692		// history browsing is safe/active; otherwise it falls back to chat
693		// scrolling, preserving the old single-line scroll behavior.
694		if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
695			if i.ed.MoveVertical(-1) {
696				i.invalidate()
697				return false
698			}
699			if !k.Alt && i.handleInputHistoryKey(k) {
700				return false
701			}
702			i.scrollBy(+3)
703			return false
704		}
705	case tui.KeyDown:
706		if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
707			if i.ed.MoveVertical(+1) {
708				i.invalidate()
709				return false
710			}
711			if !k.Alt && i.inputHistoryIndex < 0 && i.cfg.ResidentManager != nil {
712				if _, total := i.cfg.ResidentManager.RecentSnapshotPage(0, 1); total > 0 {
713					i.runResidentSubagents(ctx, nil)
714					return false
715				}
716			}
717			if !k.Alt && i.handleInputHistoryKey(k) {
718				return false
719			}
720			if i.scrollOffset > 0 {
721				i.scrollBy(-3)
722			}
723			return false
724		}
725	}
726
727	// Note: we intentionally do NOT gate the editor on i.busy here.
728	// Typing while the agent is working is supported — submitted
729	// messages are queued and delivered as follow-up turns when the
730	// current turn ends. See the submit handler below.
731
732	if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) {
733		i.ed.HandleKey(k)
734		return false
735	}
736
737	// Slash suggestions: intercept up/down/tab/enter when the popup is visible.
738	if i.suggest.Active(i.ed.Value()) {
739		switch k.Kind {
740		case tui.KeyUp:
741			i.suggest.Up()
742			return false
743		case tui.KeyDown:
744			i.suggest.Down()
745			return false
746		case tui.KeyPageUp:
747			i.suggest.PageUp()
748			return false
749		case tui.KeyPageDown:
750			i.suggest.PageDown()
751			return false
752		case tui.KeyTab:
753			if name := i.suggest.Selection(i.ed.Value()); name != "" {
754				i.ed.SetValue(name)
755				i.suggest.Reset()
756			}
757			return false
758		case tui.KeyEnter:
759			// Enter on an ambiguous or partial slash prefix: complete to the
760			// currently highlighted command and run it. That way typing
761			// "/lo" + enter picks whichever of /login or /logout is selected
762			// in the popup instead of submitting "/lo" as unknown. Also
763			// clear the editor so the command doesn't linger after the
764			// dialog opens/closes.
765			if name := i.suggest.Selection(i.ed.Value()); name != "" {
766				i.ed.Clear()
767				i.suggest.Reset()
768				return i.runSlash(ctx, name)
769			}
770		case tui.KeyEsc:
771			i.ed.Clear()
772			i.suggest.Reset()
773			return false
774		}
775	}
776
777	// File suggestions: intercept up/down/tab/enter when the @-popup is visible.
778	if i.fileSuggest.Active(i.ed.Value()) {
779		switch k.Kind {
780		case tui.KeyUp:
781			i.fileSuggest.Up()
782			return false
783		case tui.KeyDown:
784			i.fileSuggest.Down()
785			return false
786		case tui.KeyRight:
787			// Open selected directory. The filter the user typed picked
788			// that directory at the current level; once we descend it no
789			// longer applies to the directory's contents, so clear it.
790			// Otherwise typing "@eda" then right would re-filter inside
791			// eda/ by "eda" and show nothing.
792			if i.fileSuggest.Right() {
793				i.clearFileSuggestQuery()
794			}
795			return false
796		case tui.KeyLeft:
797			// Go back to parent directory. Clear the filter for the same
798			// reason as Right: it was scoped to the level we just left.
799			if i.fileSuggest.Left() {
800				i.clearFileSuggestQuery()
801			}
802			return false
803		case tui.KeyEnter:
804			if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
805				var chip string
806				if entry.isDir {
807					chip = "[dir:" + entry.rel + "/]"
808				} else {
809					chip = "[file:" + entry.rel + "]"
810				}
811				val := i.ed.Value()
812				if idx := strings.LastIndex(val, "@"); idx >= 0 {
813					val = val[:idx]
814				}
815				i.ed.SetValue(val + chip + " ")
816				i.fileSuggest.Reset()
817			}
818			return false
819		case tui.KeyEsc:
820			val := i.ed.Value()
821			if idx := strings.LastIndex(val, "@"); idx >= 0 {
822				i.ed.SetValue(val[:idx])
823			}
824			i.fileSuggest.Reset()
825			return false
826		}
827	}
828
829	// Tab-complete a path token in the editor when no popup is open.
830	// Recognises tokens that look like paths (start with ~, /, ./, ../
831	// or contain a slash); shell-style completion expands ~, lists the
832	// parent dir, and completes the basename to the longest common
833	// prefix. Single match: full replace and trailing / for dirs.
834	// No match: no-op. Plain bare words (no slash, no tilde) fall
835	// through so Tab keeps its current no-op behaviour outside paths.
836	if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
837		if i.tryPathTabComplete() {
838			return false
839		}
840	}
841
842	if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
843		i.inputHistoryIndex = -1
844	}
845
846	if k.Kind == tui.KeyEsc {
847		i.clipboardImages = nil
848	}
849	if submit := i.ed.HandleKey(k); submit {
850		// SubmitValue() expands any [pasted text #N +L lines]
851		// placeholders back into their bodies; the raw Value()
852		// is only what the user sees on screen.
853		text := strings.TrimRight(i.ed.SubmitValue(), "\n")
854		// Expand [file:name] and [dir:name/] chips to full paths.
855		text = expandFileChips(text, i.cfg.CWD)
856		text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
857		if text == "" && len(images) == 0 {
858			return false
859		}
860		clearSubmittedInput := func() {
861			i.clipboardImages = nil
862			i.ed.Clear()
863			i.inputHistoryIndex = -1
864			i.suggest.Reset()
865			i.fileSuggest.Reset()
866		}
867
868		// Shell escapes and slash commands are text-only. An image-bearing
869		// draft remains a normal provider prompt instead of silently dropping
870		// its attachments.
871		if len(images) == 0 {
872			if cmd, ok := shellEscapeCommand(text); ok {
873				clearSubmittedInput()
874				i.startShellEscape(ctx, cmd)
875				return false
876			}
877		}
878
879		if len(images) == 0 && looksLikeSlashCommand(text) {
880			clearSubmittedInput()
881			head := text
882			rest := ""
883			if idx := strings.IndexAny(text, " \t"); idx >= 0 {
884				head = text[:idx]
885				rest = strings.TrimSpace(text[idx:])
886			}
887			if !isKnownSlashCommand(text) {
888				// Try extensions before giving up. Extensions register
889				// commands by bare name (no leading slash); strip it here.
890				extName := strings.TrimPrefix(head, "/")
891				if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
892					go i.invokeExtensionCommand(ctx, extName, rest)
893					return false
894				}
895				i.mu.Lock()
896				i.statusErr = "unknown command " + head + " — type /help to see the list"
897				i.statusOK = ""
898				i.mu.Unlock()
899				return false
900			}
901			// Slash commands run regardless of busy state. Commands that
902			// would mutate the transcript or replace the agent (/clear,
903			// /compact, /logout, /login, /model, /fork, /session fork)
904			// cancel the active turn
905			// first and wait for the goroutine to wind down so they don't
906			// race with a streaming response. Safe commands (/help,
907			// /jump, /sessions, /jail, /unjail, /exit) run immediately
908			// without disturbing the active turn.
909			if slashCommandCancelsTurn(text) {
910				i.cancelAndWaitForIdle()
911			}
912			return i.runSlash(ctx, text)
913		}
914
915		if i.agent == nil {
916			i.mu.Lock()
917			i.statusErr = "not logged in. type /login first."
918			i.mu.Unlock()
919			return false
920		}
921		// Mirror the user's typed prompt into the paired Telegram
922		// chat (when the bridge is active) so the Telegram thread
923		// stays a complete record of the session, not just the half
924		// that originated on the phone. On a goroutine so the
925		// network write doesn't delay the local turn.
926		if i.telegramBridge != nil && i.telegramBridge.Active() {
927			go i.telegramBridge.OnUserTyped(text)
928		}
929		i.maybeStartSessionTitle(ctx, text)
930		// If a turn is already in flight, queue this prompt inside the
931		// agent loop so it is delivered at the next safe model-call
932		// boundary instead of waiting for the whole run to finish.
933		i.mu.Lock()
934		busy := i.busy
935		compacting := i.compacting
936		ag := i.agent
937		i.mu.Unlock()
938		if busy {
939			clearSubmittedInput()
940			var handoff json.RawMessage
941			var persistHandoff bool
942			if ag != nil && !compacting {
943				i.mu.Lock()
944				handoff, persistHandoff = i.resetCompactContinuationLocked()
945				ag.QueueMessage(text, images)
946				i.mu.Unlock()
947			} else {
948				i.mu.Lock()
949				handoff, persistHandoff = i.resetCompactContinuationLocked()
950				i.queued = append(i.queued, core.QueuedMessage{Text: text, Images: images})
951				i.mu.Unlock()
952			}
953			if persistHandoff {
954				i.persistCompactHandoff(handoff)
955			}
956			i.invalidate()
957			return false
958		}
959		clearSubmittedInput()
960		i.startTurnWithImages(ctx, text, images)
961	}
962	return false
963}
964func (i *Interactive) pasteClipboard(ctx context.Context) {
965	image, ok, _ := tui.ReadClipboardImage(ctx)
966	if ok {
967		i.mu.Lock()
968		marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
969		i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
970			Marker: marker,
971			Image:  provider.ImageBlock{MimeType: image.MimeType, Data: image.Data},
972		})
973		i.ed.Insert(marker + " ")
974		i.statusOK = ""
975		i.statusErr = ""
976		i.mu.Unlock()
977		return
978	}
979	key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText)
980	i.mu.Lock()
981	defer i.mu.Unlock()
982	if err != nil {
983		i.statusErr = "clipboard paste failed: " + err.Error()
984		i.statusOK = ""
985		return
986	}
987	if pastedText {
988		i.ed.HandleKey(key)
989		i.statusErr = ""
990		i.statusOK = ""
991		return
992	}
993	i.statusErr = "clipboard does not contain text or an image"
994	i.statusOK = ""
995}
996func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
997	if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
998		return false
999	}
1000	// Do not steal normal vertical cursor movement. History browsing can only
1001	// start from an empty editor; once active, Up/Down keep walking
1002	// the ring so repeated presses work even though the editor now
1003	// contains the selected historical prompt.
1004	if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
1005		return false
1006	}
1007	hist := i.inputHistory()
1008	if len(hist) == 0 {
1009		return false
1010	}
1011
1012	if i.inputHistoryIndex < 0 {
1013		// Start just after the newest item so Up lands on the most
1014		// recent user prompt. A lone Down from an empty editor is not
1015		// history navigation; let the caller fall through to normal UI
1016		// behavior instead.
1017		if k.Kind != tui.KeyUp {
1018			return false
1019		}
1020		i.inputHistoryIndex = len(hist)
1021	}
1022
1023	switch k.Kind {
1024	case tui.KeyUp:
1025		if i.inputHistoryIndex > 0 {
1026			i.inputHistoryIndex--
1027		}
1028	case tui.KeyDown:
1029		if i.inputHistoryIndex < len(hist) {
1030			i.inputHistoryIndex++
1031		}
1032	}
1033
1034	if i.inputHistoryIndex >= len(hist) {
1035		i.ed.Clear()
1036	} else {
1037		i.ed.SetValue(hist[i.inputHistoryIndex])
1038	}
1039	return true
1040}
1041func (i *Interactive) inputHistory() []string {
1042	if i.agent == nil {
1043		return nil
1044	}
1045	msgs := i.agent.Messages()
1046	hist := make([]string, 0, len(msgs))
1047	for _, m := range msgs {
1048		if !isForkableUserMessage(m) {
1049			continue
1050		}
1051		text := userMessageText(m)
1052		if strings.TrimSpace(text) == "" {
1053			continue
1054		}
1055		hist = append(hist, text)
1056	}
1057	return hist
1058}
1059func userMessageText(m provider.Message) string {
1060	var sb strings.Builder
1061	for _, c := range m.Content {
1062		if tb, ok := c.(provider.TextBlock); ok {
1063			if sb.Len() > 0 {
1064				sb.WriteByte('\n')
1065			}
1066			sb.WriteString(tb.Text)
1067		}
1068	}
1069	return sb.String()
1070}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close