v0.30.0 4a12e83

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		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}

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 477

↑↓ moveenter openesc close