v0.47.0 1ddba1e

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

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