v0.0.44 d50eed7

interactive.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"strings"
8	"sync"
9	"time"
10
11	"github.com/patriceckhart/zot/internal/agent/extensions"
12	"github.com/patriceckhart/zot/internal/agent/tools"
13	"github.com/patriceckhart/zot/internal/auth"
14	"github.com/patriceckhart/zot/internal/core"
15	"github.com/patriceckhart/zot/internal/provider"
16	"github.com/patriceckhart/zot/internal/skills"
17	"github.com/patriceckhart/zot/internal/tui"
18)
19
20// InteractiveConfig configures the interactive loop.
21type InteractiveConfig struct {
22	Terminal     tui.Terminal
23	Theme        tui.Theme
24	Model        string
25	Provider     string
26	AuthMethod   string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
27	BaseURL      string
28	Reasoning    string
29	SystemPrompt string
30	Tools        core.Registry
31	MaxSteps     int
32	CWD          string
33
34	// Agent is optional. If nil, zot opens without credentials; the
35	// user must /login before they can prompt.
36	Agent *core.Agent
37
38	InitialInput string
39
40	// Auth is required. When the user runs /login, Interactive talks to
41	// AuthManager to open a browser and wait for the callback.
42	AuthManager *auth.Manager
43	// BuildAgent is called after a successful login to (re)construct the
44	// agent with the fresh credential. It returns the new agent and
45	// the concrete provider/model in use.
46	BuildAgent func() (*core.Agent, string, string, error)
47
48	// BuildAgentFor rebuilds the agent with an explicit provider/model
49	// override (used by the /model picker when switching providers).
50	// If providerOverride is empty, the current provider is kept.
51	BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
52
53	// ZotHome is the root directory for sessions/, used by /sessions
54	// and the update-check cache.
55	ZotHome string
56
57	// Version is the binary's current version (from main.version).
58	// Used only for display; the update check itself is done outside
59	// this package to avoid an import cycle.
60	Version string
61
62	// UpdateInfoChan is an optional channel that delivers the result
63	// of the github-release update check. Interactive reads at most
64	// one value, drops it if the check reported nothing, and otherwise
65	// surfaces a yellow "update available" banner at the top of the
66	// chat. Nil channel = no banner, no startup cost.
67	UpdateInfoChan <-chan UpdateInfo
68
69	// Sandbox is the shared sandbox pointer. Toggled by /lock and /unlock.
70	Sandbox *tools.Sandbox
71
72	// LoadSession swaps the current session for the one at path. The
73	// callback returns the new agent message slice so the TUI can invalidate.
74	LoadSession func(path string) error
75
76	// PersistModel is called whenever the user switches model or provider.
77	// It should update config.json and (if there's an active session)
78	// write a new meta row so resume picks up the same model.
79	PersistModel func(providerName, model string)
80
81	OnAssistant  func(m provider.Message)
82	OnToolResult func(id string, r core.ToolResult)
83
84	// Extensions, if non-nil, lets users invoke extension-registered
85	// slash commands. Commands declared by extensions are looked up
86	// AFTER the built-in catalog so a built-in name always wins.
87	Extensions *extensions.Manager
88
89	// SkillSnapshot, if non-nil, returns the current list of
90	// discovered SKILL.md files. Re-invoked each time /skills opens
91	// so the picker reflects edits made during the session.
92	SkillSnapshot func() []*skills.Skill
93
94	// ChangelogChan, if non-nil, delivers release-notes for the
95	// current binary version once at startup. Interactive opens a
96	// dismissible overlay when the channel produces a non-empty
97	// body. Receiver fires at most once.
98	ChangelogChan <-chan ChangelogPayload
99
100	// OnChangelogDismiss, if non-nil, is called once the user
101	// closes the changelog overlay. The cli wires this to a
102	// MarkChangelogShown call so the same version doesn't show
103	// again on the next launch.
104	OnChangelogDismiss func()
105
106	// NoYolo is true when --no-yolo was passed. Interactive opens
107	// a confirmation dialog before every tool call and blocks the
108	// tool until the user picks yes / always-this-tool /
109	// always-all / no. When false (default), tools run freely.
110	NoYolo bool
111
112	// ConfirmGate is the session-scoped gate wrapping this
113	// interactive's Confirmer. When non-nil, /yolo can call
114	// AllowAll() on it to disable confirmation for the rest of the
115	// session. When nil (yolo mode), /yolo reports that there's
116	// nothing to disable.
117	ConfirmGate *core.ConfirmGate
118}
119
120// ChangelogPayload mirrors agent.ChangelogInfo without the import
121// cycle. The cli builds one from the http response, the tui opens
122// the overlay when one arrives.
123type ChangelogPayload struct {
124	Version string
125	Body    string
126	URL     string
127}
128
129// Interactive is the TUI chat loop.
130type Interactive struct {
131	cfg  InteractiveConfig
132	view *tui.View
133	ed   *tui.Editor
134	rend *tui.Renderer
135
136	mu           sync.Mutex
137	agent        *core.Agent
138	streaming    strings.Builder
139	streamOn     bool
140	toolCalls    map[string]*tui.ToolCallView
141	toolOrder    []string
142	statusErr    string
143	statusOK     string
144	helpBlock    []string // rendered above the chat when /help was typed
145	cumUsage     provider.Usage
146	lastCtxInput int // input_tokens of the most recent turn — approximates current context size
147	busy         bool
148	dirty        chan struct{}
149	cancelTurn   context.CancelFunc
150	scrollOffset int // rows from the bottom; 0 = pinned to latest
151
152	// Messages typed while a turn is in flight. Each is delivered as
153	// its own follow-up turn once the current one finishes. Rendered
154	// above the status bar as "sliding in: ..." chips.
155	queued []string
156
157	// runCtx is the top-level context passed to Run(). Follow-up turns
158	// drained from `queued` are started against this context so they
159	// survive past the ctx of the key event that enqueued them.
160	runCtx context.Context
161
162	// autoCompacting is true while a model-triggered compaction is in
163	// flight. Surfaced in the status bar so the user can tell a
164	// condense pass from a regular assistant turn.
165	autoCompacting bool
166
167	// updateInfo is the result of the async update check. Zero value
168	// while the check hasn't completed or nothing is available.
169	updateInfo UpdateInfo
170
171	dialog          *loginDialog
172	modelDialog     *modelDialog
173	sessionDialog   *sessionDialog
174	jumpDialog      *jumpDialog
175	btwDialog       *btwDialog
176	skillsDialog    *skillsDialog
177	changelogDialog *changelogDialog
178	confirmDialog   *confirmDialog
179	suggest         *slashSuggester
180	spin            *spinner
181
182	// parkedTurn is the 1-based turn number the viewport is currently
183	// scrolled to by /jump. 0 = not parked, showing the tail as usual.
184	// Rendered as a muted footer at the bottom of the chat so users
185	// don't forget they're looking at history.
186	parkedTurn  int
187	parkedTotal int
188
189	// lastCtrlC is when the user last pressed ctrl+c. The first press
190	// clears the editor / cancels a turn / shows a hint; a second press
191	// within ctrlCExitWindow exits. Mirrors the python-repl convention.
192	lastCtrlC time.Time
193
194	// welcomeStart is when the interactive run began. The welcome
195	// banner shows the binary version for welcomeVersionDuration
196	// after this point and reverts to plain text after.
197	welcomeStart time.Time
198
199	// extNotes are one-shot styled lines pushed by extensions via
200	// Notify / Display. They live above the editor (just below the
201	// transcript) until cleared by /clear or another reset.
202	extNotes []string
203}
204
205// welcomeVersionDuration is how long the welcome banner shows the
206// version suffix before reverting to the plain headline. 1.5s is
207// enough to read at a glance and keeps the splash short.
208const welcomeVersionDuration = 1500 * time.Millisecond
209
210// NewInteractive constructs an Interactive from cfg.
211func NewInteractive(cfg InteractiveConfig) *Interactive {
212	i := &Interactive{
213		cfg: cfg,
214		view: &tui.View{
215			Theme:      cfg.Theme,
216			ImageProto: tui.DetectImageProtocol(),
217		},
218		ed:              tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
219		rend:            tui.NewRenderer(cfg.Terminal),
220		toolCalls:       map[string]*tui.ToolCallView{},
221		dirty:           make(chan struct{}, 8),
222		dialog:          newLoginDialog(),
223		modelDialog:     newModelDialog(),
224		sessionDialog:   newSessionDialog(),
225		jumpDialog:      newJumpDialog(),
226		btwDialog:       newBtwDialog(),
227		skillsDialog:    newSkillsDialog(),
228		changelogDialog: newChangelogDialog(),
229		confirmDialog:   newConfirmDialog(),
230		suggest:         newSlashSuggester(),
231		spin:            newSpinner(),
232	}
233	if cfg.Agent != nil {
234		i.agent = cfg.Agent
235		i.view.Messages = cfg.Agent.Messages()
236	}
237	return i
238}
239
240// Run blocks until the user quits.
241func (i *Interactive) Run(ctx context.Context) error {
242	i.runCtx = ctx
243	term := i.cfg.Terminal
244	restore, err := term.EnterRaw()
245	if err != nil {
246		return err
247	}
248	defer restore()
249
250	_, _ = term.Write([]byte(tui.SeqBracketedPasteOn))
251	_, _ = term.Write([]byte(tui.SeqAltScreenOn))
252	defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
253
254	cols, rows := term.Size()
255	i.rend.Resize(cols, rows)
256	term.OnResize(func() {
257		c, r := term.Size()
258		i.rend.Resize(c, r)
259		// Force an immediate redraw on resize. The throttled invalidate
260		// path is fine for animation, but a window resize is a discrete
261		// user action where any visible delay (or stale frame) reads as
262		// brokenness. redraw() is mutex-safe; the worst that happens is
263		// a duplicate paint if the throttler is mid-flight, which is
264		// invisible.
265		i.redraw()
266	})
267
268	if i.cfg.InitialInput != "" {
269		i.ed.SetValue(i.cfg.InitialInput)
270	}
271
272	// Stamp the welcome time and schedule a one-shot redraw at the
273	// expiry so the version suffix disappears on its own even if the
274	// user hasn't typed anything yet.
275	i.welcomeStart = time.Now()
276	time.AfterFunc(welcomeVersionDuration, i.invalidate)
277
278	// If the agent was constructed with a pre-loaded transcript
279	// (--continue, --resume, --session) park the viewport on the
280	// most recent turn so the user lands looking at where the
281	// previous session left off rather than at the bottom of an
282	// already-rendered final reply.
283	if i.agent != nil {
284		if msgs := i.agent.Messages(); len(msgs) > 0 {
285			i.scrollToLastTurn(msgs)
286		}
287	}
288
289	// No credential at startup? Auto-open the login dialog, and mark
290	// the status line. The user can Esc out of the dialog if they
291	// want to dismiss it (e.g. to check /help or /exit first).
292	if i.agent == nil {
293		i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
294		i.dialog.Open()
295	}
296
297	// Input goroutine.
298	keys := make(chan tui.Key, 8)
299	go func() {
300		reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
301		for {
302			k, err := reader.Read()
303			if err != nil {
304				return
305			}
306			keys <- k
307		}
308	}()
309
310	// Subscribe to auth events.
311	var authEvents <-chan auth.Event
312	if i.cfg.AuthManager != nil {
313		authEvents = i.cfg.AuthManager.Events()
314	}
315
316	// Animation ticker: drives spinner and dialog-related redraws when
317	// nothing else changed. 120ms is slow enough that highlighting a huge
318	// transcript doesn't spin the cpu.
319	tick := time.NewTicker(120 * time.Millisecond)
320	defer tick.Stop()
321
322	// Redraw throttle: coalesce bursts of invalidate() calls so we paint
323	// at most once every redrawMinInterval. Huge tool-result dumps can
324	// fire hundreds of invalidations while the user is typing; without
325	// this, the input goroutine never gets CPU and keystrokes lag.
326	const redrawMinInterval = 16 * time.Millisecond
327	var lastRedraw time.Time
328	var pendingRedraw bool
329	var pendingTimer *time.Timer
330
331	drainPending := func() {
332		if pendingTimer != nil {
333			pendingTimer.Stop()
334			pendingTimer = nil
335		}
336		if pendingRedraw {
337			pendingRedraw = false
338			lastRedraw = time.Now()
339			i.redraw()
340		}
341	}
342
343	requestRedraw := func() {
344		since := time.Since(lastRedraw)
345		if since >= redrawMinInterval {
346			// Redrawing right now subsumes any pending redraw, so clear
347			// the throttle state. Without this, a pending flag stays
348			// stuck at true and subsequent invalidate() calls within
349			// redrawMinInterval get dropped — which is exactly how the
350			// final "turn finished" frame went missing until the user
351			// nudged the ui by typing or scrolling.
352			if pendingTimer != nil {
353				pendingTimer.Stop()
354			}
355			pendingRedraw = false
356			lastRedraw = time.Now()
357			i.redraw()
358			return
359		}
360		if pendingRedraw {
361			return // already scheduled
362		}
363		pendingRedraw = true
364		wait := redrawMinInterval - since
365		if pendingTimer == nil {
366			pendingTimer = time.AfterFunc(wait, func() {
367				// Poke the dirty channel so the main loop wakes and
368				// drains the pending redraw on its own goroutine. We
369				// can't call drainPending here directly — it touches
370				// closure state shared with the main loop.
371				i.invalidate()
372			})
373		} else {
374			pendingTimer.Reset(wait)
375		}
376	}
377
378	i.invalidate()
379
380	updates := i.cfg.UpdateInfoChan  // nil-safe; nil channel blocks forever in select
381	changelog := i.cfg.ChangelogChan // single-shot, see case below
382
383	for {
384		select {
385		case <-ctx.Done():
386			return ctx.Err()
387		case k := <-keys:
388			if done := i.handleKey(ctx, k); done {
389				return nil
390			}
391			i.invalidate()
392		case ev := <-authEvents:
393			i.handleAuthEvent(ev)
394			i.invalidate()
395		case info, ok := <-updates:
396			if ok && info.Available {
397				i.mu.Lock()
398				i.updateInfo = info
399				i.mu.Unlock()
400				i.invalidate()
401			}
402			updates = nil // single-shot; subsequent iterations skip this case
403		case cl, ok := <-changelog:
404			if ok && cl.Body != "" {
405				i.changelogDialog.Open(cl.Version, cl.URL, cl.Body)
406				i.invalidate()
407			}
408			changelog = nil // single-shot
409		case <-i.dirty:
410			requestRedraw()
411		case <-tick.C:
412			// Always drain a pending redraw on the tick. This is the
413			// safety net that catches the case where the dirty channel
414			// was saturated when the final "turn finished" invalidate
415			// fired, or where the throttle scheduled a deferred redraw
416			// and the AfterFunc-driven invalidate got dropped on a
417			// full channel.
418			drainPending()
419			if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() {
420				requestRedraw() // keep the spinner / dialog animation moving
421			}
422		}
423	}
424}
425
426func (i *Interactive) invalidate() {
427	select {
428	case i.dirty <- struct{}{}:
429	default:
430	}
431}
432
433// lastCols returns the current terminal width in columns.
434func (i *Interactive) lastCols() int {
435	cols, _ := i.cfg.Terminal.Size()
436	return cols
437}
438
439// chatPage returns the number of chat rows currently visible, used
440// as the page size for PageUp/PageDown.
441func (i *Interactive) chatPage() int {
442	_, rows := i.cfg.Terminal.Size()
443	p := rows - 6 // rough reservation for status + editor + a dialog line
444	if p < 4 {
445		p = 4
446	}
447	return p
448}
449
450// scrollBy adjusts the scroll offset. Positive = up (into history).
451// Clearing the parked-turn label when we're back at the bottom means
452// the "viewing turn N" footer goes away automatically as soon as you
453// scroll back to the live tail.
454func (i *Interactive) scrollBy(delta int) {
455	i.mu.Lock()
456	i.scrollOffset += delta
457	if i.scrollOffset < 0 {
458		i.scrollOffset = 0
459	}
460	if i.scrollOffset == 0 {
461		i.parkedTurn = 0
462		i.parkedTotal = 0
463	}
464	i.mu.Unlock()
465	i.invalidate()
466}
467
468// scrollToBottom pins the view to the latest content.
469func (i *Interactive) scrollToBottom() {
470	i.mu.Lock()
471	i.scrollOffset = 0
472	i.parkedTurn = 0
473	i.parkedTotal = 0
474	i.mu.Unlock()
475	i.invalidate()
476}
477
478func (i *Interactive) redraw() {
479	i.mu.Lock()
480	defer i.mu.Unlock()
481
482	cols, _ := i.cfg.Terminal.Size()
483	if i.agent != nil {
484		i.view.Messages = i.agent.Messages()
485	} else {
486		i.view.Messages = nil
487	}
488	i.view.Streaming = i.streaming.String()
489	i.view.StreamingActive = i.streamOn
490	// Belt and suspenders: if the transcript's last message is already an
491	// assistant message, the streaming view is stale (EvAssistantMessage may
492	// not have fired yet) — suppress it to avoid double-rendering the reply.
493	if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
494		i.view.StreamingActive = false
495	}
496	// Live tool-call view: only shown while a turn is in flight. Once
497	// the agent is idle, every tool call has already been folded into
498	// the transcript (as assistant.ToolCallBlock + a tool-role message),
499	// so showing v.ToolCalls a second time would duplicate them below
500	// the final assistant text — which looks like the summary came
501	// "before" the tools.
502	i.view.ToolCalls = i.view.ToolCalls[:0]
503	if i.busy {
504		for _, id := range i.toolOrder {
505			if tc, ok := i.toolCalls[id]; ok {
506				i.view.ToolCalls = append(i.view.ToolCalls, *tc)
507			}
508		}
509	}
510	i.view.Err = i.statusErr
511
512	chat := i.view.Build(cols)
513
514	// Welcome banner: shown at the top of the chat area when there is
515	// no transcript yet. Disappears after the first message is sent.
516	// The version suffix is shown for welcomeVersionDuration after
517	// startup, then drops off automatically.
518	if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
519		showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
520		chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
521	}
522
523	// Update-available banner: prepended above everything else so it's
524	// the first thing the user sees when opening a new zot session.
525	// Once rendered, it stays until the user updates to a newer
526	// version — we don't persist a "dismissed" flag because this is
527	// cheap and re-showing it is how most users remember to update.
528	if i.updateInfo.Available {
529		banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
530		chat = append(banner, chat...)
531	}
532
533	// /help block: appended to the transcript so it appears at the
534	// bottom of the chat area (right above the status bar / editor).
535	// Prepending it would push long conversations off the top of the
536	// viewport, which users would miss entirely.
537	if len(i.helpBlock) > 0 {
538		chat = append(chat, i.helpBlock...)
539	}
540
541	if i.statusOK != "" {
542		// Hard-truncate the OK line to the visible width so a long
543		// session path ("resumed session: /Users/.../sessions/...")
544		// doesn't overflow past the right edge and look broken on a
545		// narrow terminal.
546		line := "✓ " + i.statusOK
547		if cols > 4 && len(line) > cols {
548			line = line[:cols-1] + "…"
549		}
550		chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
551	}
552
553	// Extension notes (notify / display) live just under the
554	// transcript, above the dialog/editor band. Cleared by /clear.
555	if len(i.extNotes) > 0 {
556		chat = append(chat, i.extNotes...)
557		chat = append(chat, "")
558	}
559
560	// Dialogs (login or model picker) render between chat and the editor.
561	var dialog []string
562	switch {
563	case i.dialog.Active():
564		dialog = i.dialog.Render(i.cfg.Theme, cols)
565	case i.modelDialog.Active():
566		dialog = i.modelDialog.Render(i.cfg.Theme, cols)
567	case i.sessionDialog.Active():
568		dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
569	case i.jumpDialog.Active():
570		dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
571	case i.btwDialog.Active():
572		dialog = i.btwDialog.Render(i.cfg.Theme, cols)
573	case i.skillsDialog.Active():
574		dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
575	case i.changelogDialog.Active():
576		dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
577	case i.confirmDialog.Active():
578		dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
579	}
580
581	// Slash-command autocomplete: popup above the status line, only
582	// when the editor starts with "/" and no dialog is already open.
583	// Feed extension-registered commands into the suggester first so
584	// they show up in tab-complete + the popup alongside the built-ins.
585	if i.cfg.Extensions != nil {
586		catalog := i.cfg.Extensions.Commands()
587		extra := make([]slashCommand, 0, len(catalog))
588		for _, c := range catalog {
589			// The popup renders extension commands under a dedicated
590			// "── extensions ───" divider, so the description doesn't
591			// need to repeat the source. If the description is empty,
592			// fall back to the extension name so the row isn't blank.
593			desc := c.Description
594			if strings.TrimSpace(desc) == "" {
595				desc = "(" + c.Extension + ")"
596			}
597			extra = append(extra, slashCommand{
598				Name: "/" + c.Name,
599				Desc: desc,
600			})
601		}
602		i.suggest.SetExtra(extra)
603	}
604	var suggest []string
605	currentInput := i.ed.Value()
606	if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy {
607		suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
608	}
609
610	// Busy prefix shown at the far left of the status bar.
611	var busyPrefix string
612	if i.busy {
613		busyPrefix = fmt.Sprintf("%s %s · %s",
614			i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Frame()),
615			i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Message()),
616			i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
617		)
618	}
619
620	ctxMax := 0
621	if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
622		ctxMax = m.ContextWindow
623	}
624	statusLines := tui.StatusBar(tui.StatusBarParams{
625		Theme:          i.cfg.Theme,
626		Provider:       i.cfg.Provider,
627		Model:          i.cfg.Model,
628		Busy:           i.busy,
629		BusyPrefix:     busyPrefix,
630		CWD:            i.cfg.CWD,
631		Locked:         i.cfg.Sandbox.Locked(),
632		Usage:          i.cumUsage,
633		Subscription:   i.cfg.AuthMethod == "oauth",
634		ContextUsed:    i.lastCtxInput,
635		ContextMax:     ctxMax,
636		AutoCompacting: i.autoCompacting,
637		Cols:           cols,
638	})
639	edLines, curR, curC := i.ed.Render(cols)
640
641	// "Sliding in" chips for messages the user typed while a turn is
642	// in flight. Shown directly above the status bar so they're close
643	// to the editor but don't push the chat around.
644	var queue []string
645	if len(i.queued) > 0 {
646		for _, q := range i.queued {
647			label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")
648			text := truncateLine(q, cols-15)
649			queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
650		}
651	}
652
653	// Bottom-sticky sections (always visible, never scroll).
654	bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)
655	bottom = append(bottom, dialog...)
656	bottom = append(bottom, suggest...)
657	bottom = append(bottom, queue...)
658	bottom = append(bottom, statusLines...)
659	bottom = append(bottom, edLines...)
660
661	_, rows := i.cfg.Terminal.Size()
662	chatRows := rows - len(bottom)
663	if chatRows < 1 {
664		chatRows = 1
665	}
666
667	// Apply scroll offset to the chat slice.
668	maxOffset := len(chat) - chatRows
669	if maxOffset < 0 {
670		maxOffset = 0
671	}
672	if i.scrollOffset > maxOffset {
673		i.scrollOffset = maxOffset
674	}
675	if i.scrollOffset < 0 {
676		i.scrollOffset = 0
677	}
678
679	var visibleChat []string
680	if len(chat) <= chatRows {
681		visibleChat = chat
682	} else {
683		end := len(chat) - i.scrollOffset
684		start := end - chatRows
685		if start < 0 {
686			start = 0
687		}
688		visibleChat = chat[start:end]
689	}
690
691	// A tiny "scrolled up" indicator in the top-right of the chat pane
692	// so you know you're not at the bottom. When the viewport was
693	// parked by /jump we include the turn number so the user remembers
694	// they're reading history rather than the live conversation.
695	if i.scrollOffset > 0 && len(visibleChat) > 0 {
696		var text string
697		if i.parkedTurn > 0 && i.parkedTotal > 0 {
698			text = fmt.Sprintf("  ↑ viewing turn %d of %d · %d lines more below (pgdn / end)",
699				i.parkedTurn, i.parkedTotal, i.scrollOffset)
700		} else {
701			text = fmt.Sprintf("  ↑ %d lines more below (end to jump)", i.scrollOffset)
702		}
703		note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
704		visibleChat = append([]string{note}, visibleChat...)
705		if len(visibleChat) > chatRows {
706			visibleChat = visibleChat[:chatRows]
707		}
708	}
709
710	frame := make([]string, 0, len(visibleChat)+len(bottom))
711	frame = append(frame, visibleChat...)
712	frame = append(frame, bottom...)
713
714	cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR
715	cursorCol := curC
716	i.rend.Draw(frame, cursorRow, cursorCol)
717}
718
719// truncateLine shortens s so it fits within n display cells, with an
720// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
721// novel doesn't blow past the status line.
722func truncateLine(s string, n int) string {
723	if n <= 0 {
724		return ""
725	}
726	// Collapse newlines — chips are single line.
727	s = strings.ReplaceAll(s, "\n", " ↩ ")
728	runes := []rune(s)
729	if len(runes) <= n {
730		return s
731	}
732	if n <= 1 {
733		return "…"
734	}
735	return string(runes[:n-1]) + "…"
736}
737
738// ctrlCExitWindow is how long after a ctrl+c press a *second* press
739// will exit instead of just clearing input. Long enough to be
740// deliberate (rules out accidental key chord), short enough that the
741// hint stays meaningful.
742const ctrlCExitWindow = 2 * time.Second
743
744// armCtrlCExit records the timestamp of the current ctrl+c so the next
745// one within ctrlCExitWindow exits.
746func (i *Interactive) armCtrlCExit() {
747	i.mu.Lock()
748	i.lastCtrlC = time.Now()
749	i.mu.Unlock()
750}
751
752// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
753// that another press should now exit.
754func (i *Interactive) ctrlCExitArmed() bool {
755	i.mu.Lock()
756	t := i.lastCtrlC
757	i.mu.Unlock()
758	return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
759}
760
761func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
762	// Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
763	// pressing ctrl+c then typing then ctrl+c much later doesn't quit
764	// unexpectedly. The hint message also goes stale; clear it.
765	if k.Kind != tui.KeyCtrlC {
766		i.mu.Lock()
767		if !i.lastCtrlC.IsZero() {
768			i.lastCtrlC = time.Time{}
769			if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
770				i.statusOK = ""
771			}
772		}
773		i.mu.Unlock()
774	}
775
776	// Dialogs consume keys while open (except ctrl+c, which always closes them).
777
778	// Confirm dialog has highest priority: the agent goroutine is
779	// blocked waiting for an answer, so we must not let keys leak
780	// anywhere else while it's up.
781	if i.confirmDialog.Active() {
782		i.confirmDialog.HandleKey(k)
783		i.invalidate()
784		return false
785	}
786	if i.dialog.Active() {
787		if k.Kind == tui.KeyCtrlC {
788			i.dialog.Close()
789			if i.cfg.AuthManager != nil {
790				i.cfg.AuthManager.CancelOAuth()
791			}
792			return false
793		}
794		act := i.dialog.HandleKey(k)
795		if act.StartAPIKey {
796			i.startAPIKeyFlow(act.Provider)
797		}
798		if act.StartOAuth {
799			i.startOAuthFlow(act.Provider)
800		}
801		return false
802	}
803	if i.modelDialog.Active() {
804		if k.Kind == tui.KeyCtrlC {
805			i.modelDialog.Close()
806			return false
807		}
808		act := i.modelDialog.HandleKey(k)
809		if act.Select {
810			i.applyModelSelection(act.Provider, act.Model)
811		}
812		return false
813	}
814	if i.sessionDialog.Active() {
815		if k.Kind == tui.KeyCtrlC {
816			i.sessionDialog.Close()
817			return false
818		}
819		act := i.sessionDialog.HandleKey(k)
820		if act.Select {
821			i.applySessionSelection(act.Path)
822		}
823		return false
824	}
825	if i.jumpDialog.Active() {
826		if k.Kind == tui.KeyCtrlC {
827			i.jumpDialog.Close()
828			return false
829		}
830		act := i.jumpDialog.HandleKey(k)
831		if act.Select {
832			i.applyJumpSelection(act.MessageIdx, act.TurnNo)
833		}
834		return false
835	}
836	if i.btwDialog.Active() {
837		if k.Kind == tui.KeyCtrlC {
838			i.btwDialog.Close()
839			i.invalidate()
840			return false
841		}
842		i.btwDialog.HandleKey(k, i.invalidate)
843		return false
844	}
845	if i.skillsDialog.Active() {
846		if k.Kind == tui.KeyCtrlC {
847			i.skillsDialog.Close()
848			i.invalidate()
849			return false
850		}
851		i.skillsDialog.HandleKey(k)
852		i.invalidate()
853		return false
854	}
855	if i.changelogDialog.Active() {
856		if closed := i.changelogDialog.HandleKey(k); closed {
857			// User dismissed; let the parent persist the
858			// LastChangelogShown marker via the close callback.
859			if i.cfg.OnChangelogDismiss != nil {
860				i.cfg.OnChangelogDismiss()
861			}
862		}
863		i.invalidate()
864		return false
865	}
866
867	// Global keys.
868	switch k.Kind {
869	case tui.KeyCtrlC:
870		// While busy: cancel the active turn (same as esc). The exit
871		// hint stays armed so a quick second ctrl+c after the turn
872		// dies still exits, matching habits from other repls.
873		if i.busy && i.cancelTurn != nil {
874			i.cancelTurn()
875			i.armCtrlCExit()
876			return false
877		}
878		// Idle: first press clears the editor (and any queued
879		// follow-up messages); a second press within ctrlCExitWindow
880		// exits. With both an empty editor and no queue the first
881		// press still just arms — require a deliberate double-tap.
882		hadInput := !i.ed.IsEmpty() || len(i.queued) > 0
883		if hadInput {
884			i.ed.Clear()
885			i.suggest.Reset()
886			i.mu.Lock()
887			i.queued = nil
888			i.statusOK = "input cleared"
889			i.statusErr = ""
890			i.mu.Unlock()
891			i.armCtrlCExit()
892			return false
893		}
894		if i.ctrlCExitArmed() {
895			return true
896		}
897		i.mu.Lock()
898		i.statusOK = "press ctrl+c again to exit"
899		i.statusErr = ""
900		i.mu.Unlock()
901		i.armCtrlCExit()
902		return false
903	case tui.KeyEsc:
904		// Esc interrupts a running turn. When idle, fall through so the
905		// editor can clear itself.
906		if i.busy && i.cancelTurn != nil {
907			i.cancelTurn()
908			// If a confirm dialog is pending, refuse it so the agent
909			// goroutine unblocks and the context cancellation can
910			// actually take effect.
911			i.confirmDialog.CancelAll("turn cancelled")
912			return false
913		}
914	case tui.KeyCtrlD:
915		if i.ed.IsEmpty() && !i.busy {
916			return true
917		}
918	case tui.KeyCtrlL:
919		i.rend.Clear()
920		i.invalidate()
921		return false
922	case tui.KeyCtrlO:
923		// Toggle expansion of collapsed tool results. Affects every tool
924		// call in the transcript — press again to re-collapse.
925		i.mu.Lock()
926		i.view.ExpandAll = !i.view.ExpandAll
927		i.mu.Unlock()
928		i.invalidate()
929		return false
930	case tui.KeyPageUp:
931		i.scrollBy(+i.chatPage())
932		return false
933	case tui.KeyPageDown:
934		i.scrollBy(-i.chatPage())
935		return false
936	case tui.KeyUp:
937		// Wheel-up in alt screen sends Up arrows on most terminals.
938		// When the editor is empty we use up/down for chat scroll —
939		// independently of whether the agent is busy, so users can
940		// scroll back through long streaming replies while they run.
941		if i.ed.IsEmpty() {
942			i.scrollBy(+3)
943			return false
944		}
945	case tui.KeyDown:
946		if i.ed.IsEmpty() {
947			if i.scrollOffset > 0 {
948				i.scrollBy(-3)
949			}
950			return false
951		}
952	}
953
954	// Note: we intentionally do NOT gate the editor on i.busy here.
955	// Typing while the agent is working is supported — submitted
956	// messages are queued and delivered as follow-up turns when the
957	// current turn ends. See the submit handler below.
958
959	if k.Kind == tui.KeyEnter && k.Alt {
960		i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
961		return false
962	}
963
964	// Slash suggestions: intercept up/down/tab/enter when the popup is visible.
965	if i.suggest.Active(i.ed.Value()) {
966		switch k.Kind {
967		case tui.KeyUp:
968			i.suggest.Up()
969			return false
970		case tui.KeyDown:
971			i.suggest.Down()
972			return false
973		case tui.KeyTab:
974			if name := i.suggest.Selection(i.ed.Value()); name != "" {
975				i.ed.SetValue(name)
976				i.suggest.Reset()
977			}
978			return false
979		case tui.KeyEnter:
980			// Enter on an ambiguous or partial slash prefix: complete to the
981			// currently highlighted command and run it. That way typing
982			// "/lo" + enter picks whichever of /login or /logout is selected
983			// in the popup instead of submitting "/lo" as unknown. Also
984			// clear the editor so the command doesn't linger after the
985			// dialog opens/closes.
986			if name := i.suggest.Selection(i.ed.Value()); name != "" {
987				i.ed.PushHistory(name)
988				i.ed.Clear()
989				i.suggest.Reset()
990				return i.runSlash(ctx, name)
991			}
992		case tui.KeyEsc:
993			i.ed.Clear()
994			i.suggest.Reset()
995			return false
996		}
997	}
998
999	if submit := i.ed.HandleKey(k); submit {
1000		text := strings.TrimRight(i.ed.Value(), "\n")
1001		if text == "" {
1002			return false
1003		}
1004		i.ed.PushHistory(text)
1005		i.ed.Clear()
1006		i.suggest.Reset()
1007
1008		if looksLikeSlashCommand(text) {
1009			head := text
1010			rest := ""
1011			if idx := strings.IndexAny(text, " \t"); idx >= 0 {
1012				head = text[:idx]
1013				rest = strings.TrimSpace(text[idx:])
1014			}
1015			if !isKnownSlashCommand(text) {
1016				// Try extensions before giving up. Extensions register
1017				// commands by bare name (no leading slash); strip it here.
1018				extName := strings.TrimPrefix(head, "/")
1019				if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1020					go i.invokeExtensionCommand(ctx, extName, rest)
1021					return false
1022				}
1023				i.mu.Lock()
1024				i.statusErr = "unknown command " + head + " — type /help to see the list"
1025				i.statusOK = ""
1026				i.mu.Unlock()
1027				return false
1028			}
1029			// Slash commands run regardless of busy state. Commands that
1030			// would mutate the transcript or replace the agent (/clear,
1031			// /compact, /logout, /login, /model) cancel the active turn
1032			// first and wait for the goroutine to wind down so they don't
1033			// race with a streaming response. Safe commands (/help,
1034			// /jump, /sessions, /lock, /unlock, /exit) run immediately
1035			// without disturbing the active turn.
1036			if slashCancelsTurn(head) {
1037				i.cancelAndWaitForIdle()
1038			}
1039			return i.runSlash(ctx, text)
1040		}
1041
1042		if i.agent == nil {
1043			i.mu.Lock()
1044			i.statusErr = "not logged in. type /login first."
1045			i.mu.Unlock()
1046			return false
1047		}
1048		// If a turn is already in flight, queue this prompt instead of
1049		// starting a second one. The drain loop at the end of startTurn
1050		// will pick it up when the current turn finishes.
1051		i.mu.Lock()
1052		if i.busy {
1053			i.queued = append(i.queued, text)
1054			i.mu.Unlock()
1055			i.invalidate()
1056			return false
1057		}
1058		i.mu.Unlock()
1059		i.startTurn(ctx, text)
1060	}
1061	return false
1062}
1063
1064// invokeExtensionCommand fires an extension-registered slash command
1065// in a background goroutine, awaits the response, and applies the
1066// requested action (prompt / insert / display / noop). Errors and
1067// timeouts surface as a status_err line.
1068func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
1069	resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
1070	if err != nil {
1071		i.mu.Lock()
1072		i.statusErr = "extension /" + name + ": " + err.Error()
1073		i.statusOK = ""
1074		i.mu.Unlock()
1075		i.invalidate()
1076		return
1077	}
1078	if resp.Error != "" {
1079		i.mu.Lock()
1080		i.statusErr = "extension /" + name + ": " + resp.Error
1081		i.statusOK = ""
1082		i.mu.Unlock()
1083		i.invalidate()
1084		return
1085	}
1086	switch resp.Action {
1087	case "prompt":
1088		if strings.TrimSpace(resp.Prompt) == "" {
1089			return
1090		}
1091		i.startTurn(i.runCtx, resp.Prompt)
1092	case "insert":
1093		i.ed.Insert(resp.Insert)
1094		i.invalidate()
1095	case "display":
1096		i.appendExtensionNote(name, resp.Display, "info")
1097	case "noop", "":
1098		// nothing
1099	default:
1100		i.mu.Lock()
1101		i.statusErr = "extension /" + name + ": unknown action " + resp.Action
1102		i.mu.Unlock()
1103		i.invalidate()
1104	}
1105}
1106
1107// appendExtensionNote renders an extension-originated note in the
1108// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
1109// "success" (tool/ok green).
1110func (i *Interactive) appendExtensionNote(extName, msg, level string) {
1111	if msg == "" {
1112		return
1113	}
1114	i.mu.Lock()
1115	defer i.mu.Unlock()
1116	color := i.cfg.Theme.Muted
1117	switch level {
1118	case "warn":
1119		color = i.cfg.Theme.Warning
1120	case "error":
1121		color = i.cfg.Theme.Error
1122	case "success":
1123		color = i.cfg.Theme.Tool
1124	}
1125	prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
1126	for _, line := range strings.Split(msg, "\n") {
1127		i.statusOK = "" // clear any stale ok
1128		i.statusErr = ""
1129		i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
1130	}
1131}
1132
1133// HostHooks implementation for the extension manager. The manager
1134// holds an interface, not a concrete *Interactive, so these methods
1135// are the only thing the manager sees.
1136
1137// Notify is the manager's NotifyFromExt entry point.
1138func (i *Interactive) Notify(extName, level, message string) {
1139	i.appendExtensionNote(extName, message, level)
1140	i.invalidate()
1141}
1142
1143// Submit feeds text through the agent loop as if the user had typed it.
1144func (i *Interactive) Submit(text string) {
1145	i.startTurn(i.runCtx, text)
1146}
1147
1148// Insert places text at the cursor in the editor.
1149func (i *Interactive) Insert(text string) {
1150	i.ed.Insert(text)
1151	i.invalidate()
1152}
1153
1154// Display appends a styled note from extName to the chat without a
1155// model call.
1156func (i *Interactive) Display(extName, text string) {
1157	i.appendExtensionNote(extName, text, "info")
1158	i.invalidate()
1159}
1160
1161func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1162	parts := strings.Fields(cmd)
1163	switch parts[0] {
1164	case "/exit":
1165		return true
1166	case "/clear":
1167		if i.agent != nil {
1168			i.agent.SetMessages(nil)
1169		}
1170		i.mu.Lock()
1171		i.toolCalls = map[string]*tui.ToolCallView{}
1172		i.toolOrder = nil
1173		i.statusErr = ""
1174		i.statusOK = ""
1175		i.helpBlock = nil
1176		i.parkedTurn = 0
1177		i.parkedTotal = 0
1178		i.scrollOffset = 0
1179		i.extNotes = nil
1180		i.view.InvalidateRenderCache()
1181		i.mu.Unlock()
1182	case "/help":
1183		i.mu.Lock()
1184		i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
1185		i.statusErr = ""
1186		i.statusOK = ""
1187		// Pin the viewport to the newest content so the help block,
1188		// which we just appended to the end of the transcript, is
1189		// what the user actually sees.
1190		i.scrollOffset = 0
1191		i.mu.Unlock()
1192	case "/login":
1193		i.dialog.Open()
1194	case "/logout":
1195		target := "all"
1196		if len(parts) >= 2 {
1197			target = parts[1]
1198		}
1199		i.doLogout(target)
1200	case "/model":
1201		if len(parts) >= 2 {
1202			i.applyModelSelection("", parts[1])
1203		} else {
1204			i.modelDialog.Open(i.cfg.Model)
1205		}
1206	case "/sessions":
1207		i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
1208	case "/jump":
1209		i.openJumpDialog(parts[1:])
1210	case "/btw":
1211		i.openBtwDialog(parts[1:])
1212	case "/skills":
1213		i.openSkillsDialog()
1214	case "/compact":
1215		i.runCompact(ctx, false)
1216	case "/lock":
1217		if i.cfg.Sandbox == nil {
1218			i.mu.Lock()
1219			i.statusErr = "sandbox not available in this build"
1220			i.mu.Unlock()
1221			break
1222		}
1223		i.cfg.Sandbox.Lock()
1224		i.mu.Lock()
1225		i.statusOK = "locked to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
1226		i.statusErr = ""
1227		i.mu.Unlock()
1228	case "/unlock":
1229		if i.cfg.Sandbox == nil {
1230			i.mu.Lock()
1231			i.statusErr = "sandbox not available in this build"
1232			i.mu.Unlock()
1233			break
1234		}
1235		i.cfg.Sandbox.Unlock()
1236		i.mu.Lock()
1237		i.statusOK = "unlocked"
1238		i.statusErr = ""
1239		i.mu.Unlock()
1240	case "/reload-ext":
1241		i.runReloadExt(ctx)
1242	case "/yolo":
1243		i.runYoloOn()
1244	default:
1245		// Last-resort fallback: try the extension manager. Built-in
1246		// cases above always win; this branch only fires for slash
1247		// commands the extension manager registered. Same routing as
1248		// the editor's submit-handler dispatch path so the autocomplete
1249		// "enter on highlighted suggestion" flow also works.
1250		extName := strings.TrimPrefix(parts[0], "/")
1251		if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1252			rest := ""
1253			if len(parts) > 1 {
1254				rest = strings.Join(parts[1:], " ")
1255			}
1256			go i.invokeExtensionCommand(ctx, extName, rest)
1257			return false
1258		}
1259		i.mu.Lock()
1260		i.statusErr = "unknown command: " + parts[0]
1261		i.mu.Unlock()
1262	}
1263	return false
1264}
1265
1266// doLogout clears credentials for the given provider (or all providers)
1267// from auth.json. If the active agent was using those credentials, it
1268// is torn down so the user is forced through /login before their next
1269// prompt.
1270//
1271// target: "anthropic" | "openai" | "all"
1272func (i *Interactive) doLogout(target string) {
1273	if i.cfg.AuthManager == nil {
1274		i.mu.Lock()
1275		i.statusErr = "no auth manager configured"
1276		i.mu.Unlock()
1277		return
1278	}
1279	store := i.cfg.AuthManager.Store()
1280	if store == nil {
1281		i.mu.Lock()
1282		i.statusErr = "auth store is not available"
1283		i.mu.Unlock()
1284		return
1285	}
1286
1287	var providers []string
1288	switch target {
1289	case "", "all":
1290		providers = []string{"anthropic", "openai"}
1291	case "anthropic", "openai":
1292		providers = []string{target}
1293	default:
1294		i.mu.Lock()
1295		i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"
1296		i.mu.Unlock()
1297		return
1298	}
1299
1300	var errs []string
1301	clearedCurrent := false
1302	for _, p := range providers {
1303		if err := store.Clear(p); err != nil {
1304			errs = append(errs, p+": "+err.Error())
1305			continue
1306		}
1307		if p == i.cfg.Provider {
1308			clearedCurrent = true
1309		}
1310	}
1311
1312	i.mu.Lock()
1313	defer i.mu.Unlock()
1314	if len(errs) > 0 {
1315		i.statusErr = "logout errors: " + strings.Join(errs, "; ")
1316		return
1317	}
1318	i.statusErr = ""
1319	if clearedCurrent {
1320		// The running agent was using a credential we just wiped. Drop
1321		// it so prompts can't go out with the stale client, and hint at
1322		// /login.
1323		i.agent = nil
1324		i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
1325	} else {
1326		i.statusOK = "logged out of " + strings.Join(providers, ", ")
1327	}
1328}
1329
1330func (i *Interactive) startAPIKeyFlow(provider string) {
1331	url, err := i.cfg.AuthManager.StartAPIKey(provider)
1332	if err != nil {
1333		i.dialog.ShowResult(false, err.Error())
1334		return
1335	}
1336	i.dialog.ShowWaiting(url)
1337}
1338
1339func (i *Interactive) startOAuthFlow(provider string) {
1340	url, err := i.cfg.AuthManager.StartOAuth(provider)
1341	if err != nil {
1342		i.dialog.ShowResult(false, err.Error())
1343		return
1344	}
1345	i.dialog.ShowWaiting(url)
1346}
1347
1348// applyModelSelection switches the active model (and provider, if the
1349// new model belongs to a different one). It rebuilds the underlying
1350// client when needed so the provider wire-protocol matches.
1351// cancelAndWaitForIdle cancels the active turn (if any) and blocks
1352// briefly until the turn goroutine has updated i.busy = false. Used
1353// before destructive slash commands so transcript-mutating work
1354// (/clear, /compact, /logout, /login completion, cross-provider
1355// /model swap) doesn't race with the still-running stream.
1356//
1357// The wait is bounded; if the turn doesn't release within the timeout
1358// we proceed anyway. Worst case is a brief overlap that the agent's
1359// own mutex protects against.
1360func (i *Interactive) cancelAndWaitForIdle() {
1361	i.mu.Lock()
1362	busy := i.busy
1363	cancel := i.cancelTurn
1364	i.mu.Unlock()
1365	if !busy {
1366		return
1367	}
1368	if cancel != nil {
1369		cancel()
1370	}
1371	deadline := time.Now().Add(2 * time.Second)
1372	for time.Now().Before(deadline) {
1373		i.mu.Lock()
1374		done := !i.busy
1375		i.mu.Unlock()
1376		if done {
1377			return
1378		}
1379		time.Sleep(10 * time.Millisecond)
1380	}
1381}
1382
1383// openBtwDialog opens the side-chat overlay with a frozen snapshot
1384// of the current main session. The optional argument is auto-
1385// submitted as the first question, so '/btw does X work?' fires the
1386// model call immediately instead of just opening an empty dialog.
1387func (i *Interactive) openBtwDialog(args []string) {
1388	if i.agent == nil {
1389		i.mu.Lock()
1390		i.statusErr = "not logged in. type /login first."
1391		i.mu.Unlock()
1392		return
1393	}
1394	seed := strings.TrimSpace(strings.Join(args, " "))
1395	i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed)
1396	i.invalidate()
1397}
1398
1399// openSkillsDialog opens the skill inspector. The picker reflects
1400// whatever SkillSnapshot returns at call time, so edits to a
1401// SKILL.md made during a session show up on the next /skills.
1402func (i *Interactive) openSkillsDialog() {
1403	var list []*skills.Skill
1404	if i.cfg.SkillSnapshot != nil {
1405		list = i.cfg.SkillSnapshot()
1406	}
1407	i.skillsDialog.Open(list)
1408	i.invalidate()
1409}
1410
1411// openJumpDialog builds a /jump picker from the current transcript.
1412// If the user typed "/jump foo" with a filter and it matches exactly
1413// one turn, jump there directly without showing the dialog.
1414func (i *Interactive) openJumpDialog(args []string) {
1415	if i.view == nil || len(i.view.Messages) == 0 {
1416		i.mu.Lock()
1417		i.statusErr = "nothing to jump to \u2014 the session is empty"
1418		i.mu.Unlock()
1419		return
1420	}
1421	filter := strings.TrimSpace(strings.Join(args, " "))
1422	i.jumpDialog.Open(i.view.Messages, filter)
1423	// Shortcut: with a filter argument that matches exactly one turn,
1424	// jump immediately and skip the picker.
1425	if filter != "" {
1426		if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
1427			t := tgts[0]
1428			i.jumpDialog.Close()
1429			i.applyJumpSelection(t.MessageIdx, t.TurnNo)
1430		}
1431	}
1432}
1433
1434// applyJumpSelection scrolls the chat viewport so the user message at
1435// msgIdx is visible at (or near) the top of the chat area. Uses the
1436// anchor slice returned by view.BuildWithAnchors so the mapping from
1437// message index to row is exact, regardless of variable-height tool
1438// blocks above the target.
1439func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
1440	cols := i.lastCols()
1441	chat, anchors := i.view.BuildWithAnchors(cols)
1442	var row int
1443	found := false
1444	for _, a := range anchors {
1445		if a.MessageIdx == msgIdx {
1446			row = a.Row
1447			found = true
1448			break
1449		}
1450	}
1451	if !found {
1452		i.mu.Lock()
1453		i.statusErr = "could not resolve jump target"
1454		i.mu.Unlock()
1455		return
1456	}
1457
1458	chatLen := len(chat)
1459	page := i.chatPage()
1460	if page < 1 {
1461		page = 1
1462	}
1463	// scrollOffset is measured from the bottom of the chat slice, so
1464	// to place `row` at the top of the viewport we want:
1465	//     chatLen - scrollOffset - page == row
1466	// Solve for scrollOffset and clamp to [0, chatLen-page].
1467	offset := chatLen - (row + page)
1468	if offset < 0 {
1469		offset = 0
1470	}
1471	maxOffset := chatLen - page
1472	if maxOffset < 0 {
1473		maxOffset = 0
1474	}
1475	if offset > maxOffset {
1476		offset = maxOffset
1477	}
1478
1479	i.mu.Lock()
1480	i.scrollOffset = offset
1481	i.parkedTurn = turnNo
1482	i.parkedTotal = totalTurnsLocked(i.view.Messages)
1483	i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
1484	i.statusErr = ""
1485	i.mu.Unlock()
1486}
1487
1488// totalTurnsLocked counts user messages in the transcript. Caller is
1489// assumed to hold i.mu (the name is a mild reminder; this function
1490// itself doesn't touch shared state beyond the slice it's handed).
1491func totalTurnsLocked(msgs []provider.Message) int {
1492	n := 0
1493	for _, m := range msgs {
1494		if m.Role == provider.RoleUser {
1495			n++
1496		}
1497	}
1498	return n
1499}
1500
1501// applySessionSelection loads the given session via the cli-provided
1502// callback and parks the viewport on the last turn so the user lands
1503// looking at where the conversation left off (their last prompt at the
1504// top of the chat, the assistant's last reply right below). Older
1505// history is one scroll up; pgdn or end snaps to the current tail.
1506//
1507// Without this, scrollOffset stayed at 0 (pinned to the live tail),
1508// which on a long resumed session showed only the last few rows of
1509// the final assistant message — the user read that as "only one liner
1510// happened, the resume didn't work".
1511func (i *Interactive) applySessionSelection(path string) {
1512	if i.cfg.LoadSession == nil {
1513		i.mu.Lock()
1514		i.statusErr = "session loading is not wired in this build"
1515		i.mu.Unlock()
1516		return
1517	}
1518	if err := i.cfg.LoadSession(path); err != nil {
1519		i.mu.Lock()
1520		i.statusErr = err.Error()
1521		i.mu.Unlock()
1522		return
1523	}
1524
1525	i.mu.Lock()
1526	i.statusOK = "resumed session: " + path
1527	i.statusErr = ""
1528	i.parkedTurn = 0
1529	i.parkedTotal = 0
1530	i.view.InvalidateRenderCache()
1531	// Pull the freshly-loaded transcript into the view so the anchor
1532	// math below sees the post-resume messages, not the empty pre-load
1533	// state. redraw() does the same on its next pass; we just front-run
1534	// it here to compute the scroll target.
1535	if i.agent != nil {
1536		i.view.Messages = i.agent.Messages()
1537	}
1538	msgs := i.view.Messages
1539	i.mu.Unlock()
1540
1541	i.scrollToLastTurn(msgs)
1542}
1543
1544// scrollToLastTurn parks the viewport at the most recent user turn,
1545// or at the top if the transcript has no user messages. Used after
1546// resume so the user lands looking at where they left off.
1547func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
1548	if len(msgs) == 0 {
1549		i.mu.Lock()
1550		i.scrollOffset = 0
1551		i.mu.Unlock()
1552		return
1553	}
1554	// Find the last user message index.
1555	lastUser := -1
1556	turnNo, totalTurns := 0, 0
1557	for idx, m := range msgs {
1558		if m.Role == provider.RoleUser {
1559			totalTurns++
1560			lastUser = idx
1561		}
1562	}
1563	if lastUser < 0 {
1564		i.mu.Lock()
1565		i.scrollOffset = 0
1566		i.mu.Unlock()
1567		return
1568	}
1569	turnNo = totalTurns
1570
1571	cols := i.lastCols()
1572	chat, anchors := i.view.BuildWithAnchors(cols)
1573	var row int
1574	found := false
1575	for _, a := range anchors {
1576		if a.MessageIdx == lastUser {
1577			row = a.Row
1578			found = true
1579			break
1580		}
1581	}
1582	if !found {
1583		i.mu.Lock()
1584		i.scrollOffset = 0
1585		i.mu.Unlock()
1586		return
1587	}
1588
1589	chatLen := len(chat)
1590	page := i.chatPage()
1591	if page < 1 {
1592		page = 1
1593	}
1594	offset := chatLen - (row + page)
1595	if offset < 0 {
1596		offset = 0
1597	}
1598	maxOffset := chatLen - page
1599	if maxOffset < 0 {
1600		maxOffset = 0
1601	}
1602	if offset > maxOffset {
1603		offset = maxOffset
1604	}
1605
1606	i.mu.Lock()
1607	i.scrollOffset = offset
1608	// Mark the parked-turn footer so the user sees "viewing turn N of
1609	// M · pgdn to catch up" — same affordance as /jump. Tells them at
1610	// a glance that they're looking at history, not the live tail.
1611	if offset > 0 {
1612		i.parkedTurn = turnNo
1613		i.parkedTotal = totalTurns
1614	}
1615	i.mu.Unlock()
1616	i.invalidate()
1617}
1618
1619func (i *Interactive) applyModelSelection(prov, model string) {
1620	if model == "" {
1621		return
1622	}
1623	m, err := provider.FindModel(prov, model)
1624	if err != nil {
1625		i.mu.Lock()
1626		i.statusErr = err.Error()
1627		i.mu.Unlock()
1628		return
1629	}
1630	// Same provider? Just swap the model on the existing agent.
1631	if i.agent != nil && m.Provider == i.cfg.Provider {
1632		i.mu.Lock()
1633		i.cfg.Model = m.ID
1634		i.agent.Model = m.ID
1635		i.statusOK = "model: " + m.ID
1636		i.statusErr = ""
1637		i.mu.Unlock()
1638		if i.cfg.PersistModel != nil {
1639			i.cfg.PersistModel(i.cfg.Provider, m.ID)
1640		}
1641		return
1642	}
1643	// Different provider: rebuild agent (needs credentials for target provider).
1644	if i.cfg.BuildAgentFor == nil {
1645		i.mu.Lock()
1646		i.statusErr = "cannot switch provider: no builder configured"
1647		i.mu.Unlock()
1648		return
1649	}
1650	// Snapshot the current transcript and cumulative usage BEFORE we
1651	// build the replacement agent so we can hand them off. Without
1652	// this the user perceives the entire session as wiped on a
1653	// cross-provider /model swap.
1654	var carryMsgs []provider.Message
1655	var carryCost provider.Usage
1656	if i.agent != nil {
1657		carryMsgs = i.agent.Messages()
1658		carryCost = i.agent.Cost()
1659	}
1660
1661	ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)
1662	if err != nil {
1663		i.mu.Lock()
1664		i.statusErr = err.Error()
1665		i.mu.Unlock()
1666		return
1667	}
1668
1669	// Replay the transcript and seed the cost on the freshly-built
1670	// agent. Messages travel cleanly between providers because they
1671	// use the same provider.Message shape; tool-call ids are local
1672	// to a turn so cross-provider continuation never confuses the
1673	// new model (it just sees the assistant's reply, no orphan
1674	// tool_use blocks because /model swaps are gated to idle state).
1675	if len(carryMsgs) > 0 {
1676		ag.SetMessages(carryMsgs)
1677	}
1678	ag.SeedCost(carryCost)
1679
1680	i.mu.Lock()
1681	i.agent = ag
1682	i.cfg.Provider = p
1683	i.cfg.Model = md
1684	i.statusOK = "switched to " + p + " / " + md
1685	i.statusErr = ""
1686	// Render cache keys are width+content based, so the new agent's
1687	// identical messages will reuse the existing entries. Nothing
1688	// to invalidate.
1689	i.mu.Unlock()
1690	if i.cfg.PersistModel != nil {
1691		i.cfg.PersistModel(p, md)
1692	}
1693}
1694
1695func (i *Interactive) handleAuthEvent(ev auth.Event) {
1696	switch ev.Kind {
1697	case "started":
1698		i.dialog.ShowWaiting(ev.URL)
1699	case "browser_open":
1700		// no-op
1701	case "error":
1702		i.dialog.ShowResult(false, ev.Message)
1703	case "success":
1704		// Rebuild the agent with the fresh credential.
1705		ag, prov, model, err := i.cfg.BuildAgent()
1706		if err != nil {
1707			i.dialog.ShowResult(false, err.Error())
1708			return
1709		}
1710		i.mu.Lock()
1711		i.agent = ag
1712		i.cfg.Provider = prov
1713		i.cfg.Model = model
1714		i.statusErr = ""
1715		i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
1716		i.mu.Unlock()
1717		i.dialog.ShowResult(true, "")
1718	}
1719}
1720
1721// runCompact invokes core.Agent.Compact and reflects the progress in
1722// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
1723// cancel via the same cancelTurn channel used for normal turns.
1724//
1725// When auto is true the spinner message is pinned to "condensing
1726// history" and the status bar surfaces "(auto)" next to the context
1727// percentage so it's obvious the system triggered this, not the user.
1728func (i *Interactive) runCompact(parent context.Context, auto bool) {
1729	if i.agent == nil {
1730		i.mu.Lock()
1731		i.statusErr = "not logged in. type /login first."
1732		i.mu.Unlock()
1733		return
1734	}
1735	ctx, cancel := context.WithCancel(parent)
1736	i.mu.Lock()
1737	i.busy = true
1738	if auto {
1739		i.spin.StartFixed("condensing history")
1740		i.autoCompacting = true
1741		i.statusOK = "condensing history… (esc to cancel)"
1742	} else {
1743		i.spin.Start()
1744		i.statusOK = "compacting..."
1745	}
1746	i.cancelTurn = cancel
1747	i.statusErr = ""
1748	i.streaming.Reset()
1749	i.streamOn = true
1750	i.scrollOffset = 0
1751	i.helpBlock = nil
1752	i.mu.Unlock()
1753	i.invalidate()
1754
1755	go func() {
1756		sink := func(delta string) {
1757			i.mu.Lock()
1758			i.streaming.WriteString(delta)
1759			i.mu.Unlock()
1760			i.invalidate()
1761		}
1762		summary, err := i.agent.Compact(ctx, 4, sink)
1763		i.mu.Lock()
1764		i.busy = false
1765		i.streamOn = false
1766		i.streaming.Reset()
1767		i.cancelTurn = nil
1768		i.autoCompacting = false
1769		switch {
1770		case err != nil && ctx.Err() != nil:
1771			i.statusErr = ""
1772			if auto {
1773				i.statusOK = "auto-condense cancelled"
1774			} else {
1775				i.statusOK = "compaction cancelled"
1776			}
1777		case err != nil:
1778			i.statusErr = "compaction failed: " + err.Error()
1779			i.statusOK = ""
1780		default:
1781			i.statusErr = ""
1782			i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary))
1783			i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
1784			i.toolCalls = map[string]*tui.ToolCallView{}
1785			i.toolOrder = nil
1786			// Transcript was rewritten in place — purge the per-message
1787			// render cache so stale entries keyed on the old messages
1788			// don't linger.
1789			i.view.InvalidateRenderCache()
1790		}
1791		i.mu.Unlock()
1792		i.invalidate()
1793	}()
1794}
1795
1796func (i *Interactive) startTurn(parent context.Context, prompt string) {
1797	if i.agent == nil {
1798		return
1799	}
1800	ctx, cancel := context.WithCancel(parent)
1801	i.mu.Lock()
1802	i.busy = true
1803	i.spin.Start()
1804	i.cancelTurn = cancel
1805	i.statusErr = ""
1806	i.statusOK = ""
1807	i.streaming.Reset()
1808	i.streamOn = true
1809	i.toolCalls = map[string]*tui.ToolCallView{}
1810	i.toolOrder = nil
1811	i.scrollOffset = 0 // jump back to the bottom on new turn
1812	i.parkedTurn = 0   // starting a turn clears the /jump parked state
1813	i.parkedTotal = 0
1814	i.helpBlock = nil // hide the help block once the user asks something
1815	i.mu.Unlock()
1816	i.invalidate()
1817
1818	sink := func(ev core.AgentEvent) {
1819		i.handleEvent(ev)
1820		i.invalidate()
1821	}
1822
1823	go func() {
1824		err := i.agent.Prompt(ctx, prompt, nil, sink)
1825		i.mu.Lock()
1826		i.busy = false
1827		i.streamOn = false
1828		i.cancelTurn = nil
1829		if err != nil && ctx.Err() == nil {
1830			i.statusErr = err.Error()
1831		}
1832		// Pop the next queued message, if any, and relaunch.
1833		var next string
1834		var hasNext bool
1835		if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
1836			next, i.queued = i.queued[0], i.queued[1:]
1837			hasNext = true
1838		}
1839		// If the turn was cancelled or errored, drop the queue so the
1840		// user isn't bombarded with stale messages after an interrupt.
1841		if ctx.Err() != nil || err != nil {
1842			i.queued = nil
1843		}
1844		// Decide whether the next thing to do is an auto-compaction.
1845		// Only fires when the turn completed cleanly AND the queue is
1846		// empty (otherwise a queued message would race the condense).
1847		shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
1848		i.mu.Unlock()
1849		i.invalidate()
1850		parent := i.runCtx
1851		if parent == nil {
1852			parent = context.Background()
1853		}
1854		switch {
1855		case hasNext:
1856			i.startTurn(parent, next)
1857		case shouldAutoCompact:
1858			i.runCompact(parent, true)
1859		}
1860	}()
1861}
1862
1863// autoCompactThreshold is the context-window fraction at which the
1864// agent will auto-compact after a turn ends. 0.85 leaves enough
1865// headroom for one more user prompt + response before we bump the
1866// hard limit.
1867const autoCompactThreshold = 0.85
1868
1869// shouldAutoCompactLocked reports whether the last turn pushed context
1870// usage past the auto-compact threshold. Must be called with i.mu
1871// held; it reads lastCtxInput and the current model's context window.
1872func (i *Interactive) shouldAutoCompactLocked() bool {
1873	if i.agent == nil {
1874		return false
1875	}
1876	if i.autoCompacting {
1877		return false
1878	}
1879	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
1880	if err != nil || m.ContextWindow <= 0 {
1881		return false
1882	}
1883	if i.lastCtxInput <= 0 {
1884		return false
1885	}
1886	return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
1887}
1888
1889func (i *Interactive) handleEvent(ev core.AgentEvent) {
1890	i.mu.Lock()
1891	defer i.mu.Unlock()
1892	switch e := ev.(type) {
1893	case core.EvTextDelta:
1894		i.streaming.WriteString(e.Delta)
1895	case core.EvAssistantMessage:
1896		i.streaming.Reset()
1897		i.streamOn = false
1898		if i.cfg.OnAssistant != nil {
1899			i.cfg.OnAssistant(e.Message)
1900		}
1901	case core.EvToolCall:
1902		tcv := &tui.ToolCallView{
1903			ID:   e.ID,
1904			Name: e.Name,
1905			Args: shortArgs(e.Args),
1906		}
1907		i.toolCalls[e.ID] = tcv
1908		i.toolOrder = append(i.toolOrder, e.ID)
1909	case core.EvToolResult:
1910		if tc, ok := i.toolCalls[e.ID]; ok {
1911			tc.Done = true
1912			tc.Error = e.Result.IsError
1913			var text strings.Builder
1914			for _, c := range e.Result.Content {
1915				if tb, ok := c.(provider.TextBlock); ok {
1916					if text.Len() > 0 {
1917						text.WriteString("\n")
1918					}
1919					text.WriteString(tb.Text)
1920				}
1921			}
1922			tc.Result = text.String()
1923		}
1924		if i.cfg.OnToolResult != nil {
1925			i.cfg.OnToolResult(e.ID, e.Result)
1926		}
1927	case core.EvUsage:
1928		i.cumUsage = e.Cumulative
1929		if e.Usage.InputTokens > 0 {
1930			i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
1931		}
1932	case core.EvTurnEnd:
1933		if e.Stop == provider.StopAborted {
1934			// Aborted turn: discard the partial streaming text (it is not
1935			// persisted in the transcript) and clear any transient error.
1936			i.streaming.Reset()
1937			i.streamOn = false
1938			i.statusErr = ""
1939			i.statusOK = "cancelled"
1940			return
1941		}
1942		if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") {
1943			i.statusErr = e.Err.Error()
1944		}
1945	}
1946}
1947
1948// Agent returns the current agent, if any. Used by cli.go to flush the
1949// final transcript to the session file.
1950func (i *Interactive) Agent() *core.Agent {
1951	i.mu.Lock()
1952	defer i.mu.Unlock()
1953	return i.agent
1954}
1955
1956func shortArgs(raw json.RawMessage) string {
1957	var v any
1958	if err := json.Unmarshal(raw, &v); err != nil {
1959		return ""
1960	}
1961	if m, ok := v.(map[string]any); ok {
1962		for _, k := range []string{"path", "file_path", "command"} {
1963			if s, ok := m[k].(string); ok {
1964				if len(s) > 60 {
1965					s = s[:57] + "..."
1966				}
1967				return s
1968			}
1969		}
1970	}
1971	b, _ := json.Marshal(v)
1972	s := string(b)
1973	if len(s) > 60 {
1974		s = s[:57] + "..."
1975	}
1976	return s
1977}
1978
1979// silence unused import in some build configs
1980var _ = fmt.Sprintf
1981
1982// runReloadExt triggers a live reload of every extension (discovered
1983// + explicit). Runs on a goroutine so the TUI stays responsive; the
1984// Manager.Reload takes a couple of hundred ms to shut down subprocs
1985// and respawn them. Shows a status line throughout.
1986func (i *Interactive) runReloadExt(ctx context.Context) {
1987	if i.cfg.Extensions == nil {
1988		i.mu.Lock()
1989		i.statusErr = "no extension manager in this build"
1990		i.mu.Unlock()
1991		i.invalidate()
1992		return
1993	}
1994	i.mu.Lock()
1995	i.statusOK = "reloading extensions…"
1996	i.statusErr = ""
1997	i.mu.Unlock()
1998	i.invalidate()
1999
2000	go func() {
2001		stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
2002		msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
2003		if len(stats.Errors) > 0 {
2004			msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
2005		}
2006		i.mu.Lock()
2007		i.statusOK = msg
2008		i.statusErr = ""
2009		i.mu.Unlock()
2010		i.invalidate()
2011	}()
2012}
2013
2014// Confirm implements core.Confirmer. The agent goroutine calls
2015// this synchronously before every tool invocation when --no-yolo is
2016// active. We push the request onto the confirmDialog queue, trigger
2017// a redraw, and block the caller until the user answers.
2018//
2019// If the session is cancelled or the TUI exits mid-prompt, any
2020// pending request is refused via CancelAll so the agent doesn't
2021// deadlock.
2022func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
2023	resp := make(chan core.ConfirmDecision, 1)
2024	i.confirmDialog.Enqueue(&confirmRequest{
2025		toolName: toolName,
2026		preview:  preview,
2027		resp:     resp,
2028	})
2029	i.invalidate()
2030	return <-resp
2031}
2032
2033// runYoloOn disables --no-yolo for the rest of the session. Tool
2034// calls run without prompting after this; there's intentionally no
2035// way to re-enable gating mid-session, if the user wants that back
2036// they can exit and restart with --no-yolo.
2037func (i *Interactive) runYoloOn() {
2038	if i.cfg.ConfirmGate == nil {
2039		i.mu.Lock()
2040		i.statusOK = "yolo mode is already on (no --no-yolo in this session)"
2041		i.statusErr = ""
2042		i.mu.Unlock()
2043		i.invalidate()
2044		return
2045	}
2046	i.cfg.ConfirmGate.AllowAll()
2047	// Also auto-allow any currently pending confirmation so the
2048	// agent doesn't deadlock if /yolo is typed while a prompt is
2049	// open.
2050	i.confirmDialog.AllowAllPending()
2051	i.mu.Lock()
2052	i.statusOK = "yolo engaged: no more tool-call confirmations this session"
2053	i.statusErr = ""
2054	i.mu.Unlock()
2055	i.invalidate()
2056}

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