v0.35.0 21873e8

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

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