v0.19.0 d7aa01a

interactive_input_routing.go RawHistoryFolder

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

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