v0.51.0 a9d674e

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

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