v0.3.83 d66bd89

interactive.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"os"
8	"path/filepath"
9	"runtime"
10	"strconv"
11	"strings"
12	"sync"
13	"time"
14
15	"github.com/patriceckhart/zot/packages/agent/extensions"
16	"github.com/patriceckhart/zot/packages/agent/extproto"
17	"github.com/patriceckhart/zot/packages/agent/modes/telegram"
18	"github.com/patriceckhart/zot/packages/agent/skills"
19	"github.com/patriceckhart/zot/packages/agent/swarm"
20	"github.com/patriceckhart/zot/packages/agent/tools"
21	"github.com/patriceckhart/zot/packages/core"
22	"github.com/patriceckhart/zot/packages/provider"
23	"github.com/patriceckhart/zot/packages/provider/auth"
24	"github.com/patriceckhart/zot/packages/tui"
25)
26
27// InteractiveConfig configures the interactive loop.
28type InteractiveConfig struct {
29	Terminal     tui.Terminal
30	Theme        tui.Theme
31	Model        string
32	Provider     string
33	AuthMethod   string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
34	BaseURL      string
35	Reasoning    string
36	SystemPrompt string
37	Tools        core.Registry
38	MaxSteps     int
39	CWD          string
40
41	// Startup resource fields are loaded inputs available to list before the
42	// transcript. They are never sent to the provider or persisted by the view.
43	StartupAgentName      string
44	StartupContextPaths   []string
45	StartupExtensionNames []string
46	StartupSkillNames     []string
47
48	// StartupExtensionErrors contains non-fatal extension and skill discovery
49	// failures collected before the TUI starts. It is display-only and never
50	// enters the transcript. The historical field name is retained for callers.
51	StartupExtensionErrors []string
52
53	// ShowInstructionsAtStartup controls whether startup resources are visible.
54	// nil means the default, off.
55	ShowInstructionsAtStartup *bool
56
57	// InlineImagesEnabled overrides terminal image rendering. nil means
58	// auto-detect and render when supported; false disables; true uses
59	// the detected protocol when available.
60	InlineImagesEnabled *bool
61
62	// AutoSwarmEnabled mirrors the persisted config flag at startup so
63	// the /settings dialog can render the current state without
64	// re-reading config.json on every open.
65	AutoSwarmEnabled *bool
66
67	// AutoCompactThreshold is the context-window percentage that triggers
68	// automatic compaction. nil means 85; zero disables percentage-based
69	// triggers while preserving payload-too-large recovery.
70	AutoCompactThreshold *int
71
72	// JailByDefault mirrors the persisted jail_by_default preference.
73	// The current sandbox may differ after a session-scoped /jail or /unjail.
74	JailByDefault *bool
75
76	// PowerShellEnabled mirrors the Windows-only default tool preference.
77	PowerShellEnabled *bool
78
79	// ToolSelectionExplicit preserves an explicit --tools selection.
80	ToolSelectionExplicit bool
81
82	// OpenRouterServerToolsEnabled mirrors the persisted preference.
83	// nil means the default, off.
84	OpenRouterServerToolsEnabled *bool
85
86	// OpenRouterServerToolCallLimit mirrors the persisted call limit.
87	// nil means the built-in default (4).
88	OpenRouterServerToolCallLimit *int
89
90	// NoTools preserves the launch-time promise that all tools stay disabled.
91	NoTools bool
92
93	// RecursiveFileSuggest mirrors the persisted recursive_file_suggest
94	// flag at startup. When true the @-mention picker fuzzy-searches the
95	// whole project tree instead of browsing one directory at a time.
96	RecursiveFileSuggest *bool
97
98	// FuzzySkillSuggest opts into ranked skill-name subsequence suggestions.
99	FuzzySkillSuggest *bool
100
101	// RespectGitignore mirrors the persisted respect_gitignore flag at
102	// startup. nil means the default (on); when false the @-mention
103	// picker shows files matched by the project's root .gitignore.
104	RespectGitignore *bool
105
106	// ThemeName mirrors the persisted config theme value. Empty means auto.
107	ThemeName string
108
109	// FlatTools renders tool calls without the bordered panel (a quiet
110	// header line plus indented, frameless output). Mirrors the
111	// resolved tool_render config / ZOT_FLAT_TOOLS env at startup.
112	FlatTools bool
113
114	// CompactUser renders sent user messages as a single quiet gutter
115	// line instead of a padded, tinted bubble. Mirrors the resolved
116	// compact_input config / ZOT_COMPACT_INPUT env at startup.
117	CompactUser bool
118
119	// CompactMode mirrors the persisted compact_mode flag at startup.
120	// When true, it enables both flat tool calls and compact user turns.
121	CompactMode *bool
122
123	// CollapseToolCall retains each tool-call header, a final preview line,
124	// and failure status while hiding the rest of the body.
125	CollapseToolCall *bool
126
127	// TUIInputStyle controls the main input rendering: plain or lines.
128	TUIInputStyle string
129
130	// TUIStatusPosition controls whether the status block renders above
131	// or below the main input.
132	TUIStatusPosition string
133
134	// TUIWorkingPosition controls whether the busy/working spinner renders
135	// above or below the main input.
136	TUIWorkingPosition string
137
138	// QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
139	// shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
140	// forwards Command/Super keypresses, but Ctrl is the displayed chord.
141	QuickModelShortcuts []QuickModelShortcut
142
143	// Keymap maps configured key chords to slash commands. It is copied from
144	// config.json by the CLI and is only active in the main interactive input.
145	Keymap map[string]string
146
147	// ExtensionThemes returns themes bundled with loaded extensions.
148	ExtensionThemes func() []tui.ThemeOption
149
150	// AutoSwarmSystemAddendum is the system-prompt block that gets
151	// appended/stripped when the user toggles auto-swarm at runtime.
152	// Plumbed in from the cli so this package doesn't have to import
153	// agent (cycle).
154	AutoSwarmSystemAddendum string
155	SettingsStore           SettingsStore
156
157	// Agent is optional. If nil, zot opens without credentials; the
158	// user must /login before they can prompt.
159	Agent *core.Agent
160
161	InitialInput string
162
163	// StartupPre is auto-submitted once when the interactive session
164	// opens, before InitialInput is applied. Uses the same path as a
165	// typed Enter submit ("!" → shell escape, otherwise a model turn).
166	StartupPre string
167
168	// OnStartupPreDone runs after StartupPre finishes (shell escape or
169	// model turn), before deferred InitialInput is applied. Hosts use
170	// it to rediscover skills / reload extensions installed by pre.
171	OnStartupPreDone func()
172
173	// AutoSubmitInitial, when true, auto-submits InitialInput after
174	// StartupPre completes (or immediately when StartupPre is empty).
175	// When false, InitialInput only pre-fills the editor.
176	AutoSubmitInitial bool
177
178	// Auth is required. When the user runs /login, Interactive talks to
179	// AuthManager to open a browser and wait for the callback.
180	AuthManager *auth.Manager
181
182	// LlamaCPPConfig resolves the router URL and optional API key from env or
183	// the credential saved by /login.
184	LlamaCPPConfig func() (baseURL, apiKey string, err error)
185
186	// RefreshLlamaCPPModels synchronizes currently loaded router models into
187	// the catalog before /model opens.
188	RefreshLlamaCPPModels func(context.Context) error
189
190	// RefreshLMStudioModels is a no-op until a server URL is registered.
191	RefreshLMStudioModels func(context.Context) error
192	LMStudioConfigured    func() bool
193
194	// RefreshCustomProviderModels lists models from models.json providers
195	// that enabled discovery. CustomDiscoveryConfigured gates the refresh
196	// so /model opens immediately when nothing opted in.
197	RefreshCustomProviderModels func(context.Context) error
198	CustomDiscoveryConfigured   func() bool
199
200	// BuildAgent is called after a successful login to (re)construct the
201	// agent with the fresh credential. It returns the new agent and
202	// the concrete provider/model in use.
203	BuildAgent func() (*core.Agent, string, string, error)
204
205	// SetKimiCLIFallbackDisabled controls whether zot may fall back to
206	// the official Kimi Code CLI token when zot has no stored Kimi token.
207	SetKimiCLIFallbackDisabled func(disabled bool) error
208
209	// BuildAgentFor rebuilds the agent with an explicit provider/model
210	// override (used by the /model picker when switching providers).
211	// If providerOverride is empty, the current provider is kept.
212	BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
213
214	// BuildAgentForRescue rebuilds the agent for the rescue picker that
215	// opens after a recoverable provider failure. Unlike BuildAgentFor,
216	// this builder drops launch-time --api-key and --base-url overrides
217	// because those are usually the reason rescue triggered. Re-resolves
218	// credentials from env vars / auth.json / provider defaults so the
219	// retry has a real chance of succeeding. Falls back to BuildAgentFor
220	// when nil so embedders that don't wire it keep working.
221	BuildAgentForRescue func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
222
223	// LoggedInProviders returns the list of provider names that
224	// currently have credentials. Used by /model to filter the
225	// picker to only show reachable models.
226	LoggedInProviders func() []string
227
228	// ZotHome is zot's global state directory, used by authentication,
229	// themes, extensions, and other shared configuration.
230	ZotHome string
231
232	// SessionsRoot is the root passed to core session operations. It differs
233	// from ZotHome for Zotfile agents, whose sessions are isolated by agent
234	// name. Empty falls back to ZotHome for embedders and tests.
235	SessionsRoot string
236
237	// Version is the binary's current version (from main.version).
238	// Used only for display; the update check itself is done outside
239	// this package to avoid an import cycle.
240	Version string
241
242	// UpdateInfoChan is an optional channel that delivers the result
243	// of the github-release update check. Interactive reads at most
244	// one value, drops it if the check reported nothing, and otherwise
245	// surfaces a yellow "update available" banner at the top of the
246	// chat. Nil channel = no banner, no startup cost.
247	UpdateInfoChan <-chan UpdateInfo
248
249	// Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.
250	Sandbox *tools.Sandbox
251
252	// LoadSession swaps the current session for the one at path. The
253	// callback returns the new agent message slice so the TUI can invalidate.
254	LoadSession func(path string) error
255
256	// ChangeCWD switches the running zot session's working directory
257	// to path. The host closes the current session, rebuilds the
258	// agent so tools / AGENTS.md / sandbox bind to the new cwd, and
259	// opens a fresh session there. Returns an error if path doesn't
260	// exist, isn't a directory, or the host can't rebuild the agent.
261	//
262	// Optional: not wired by every embedder. When nil the hidden /cd
263	// command surfaces a clear error rather than no-oping.
264	ChangeCWD func(path string) error
265
266	// CurrentSessionPath returns the path of the live session file
267	// on disk (the one every AppendMessage writes to). Used by
268	// /session export so the exporter ships the exact bytes on
269	// disk. Returns an empty string when --no-session is set or
270	// no session is open.
271	CurrentSessionPath func() string
272
273	// FlushSession writes any in-memory agent messages to the
274	// session file that haven't been persisted yet. Called by
275	// /session export right before reading the file so the
276	// exported bytes reflect the full current conversation, not
277	// just the rows the agent happened to write synchronously.
278	// The default WriteNewTranscript-at-exit strategy means most
279	// of a running session lives only in memory until the tui
280	// closes; without a flush hook, /session export writes a
281	// file that only has the meta row.
282	FlushSession func()
283
284	// PersistModel is called whenever the user switches model or provider.
285	// It should update config.json and (if there's an active session)
286	// write a new meta row so resume picks up the same model.
287	PersistModel func(providerName, model string)
288
289	// OnReasoningChanged reports the normalized level after updating the live agent.
290	OnReasoningChanged func(level string)
291
292	OnAssistant  func(m provider.Message)
293	OnToolResult func(id string, r core.ToolResult)
294
295	// Extensions, if non-nil, lets users invoke extension-registered
296	// slash commands. Commands declared by extensions are looked up
297	// AFTER the built-in catalog so a built-in name always wins.
298	Extensions *extensions.Manager
299
300	// Swarm, if non-nil, enables the /swarm slash command and the
301	// dashboard dialog. The cli constructs the Swarm once per
302	// interactive run and tears it down on exit. nil disables the
303	// feature entirely (used by embedders / tests that don't want
304	// subprocesses).
305	Swarm *swarm.Swarm
306
307	// SkillSnapshot, if non-nil, returns the current list of
308	// discovered SKILL.md files. Re-invoked each time /skills opens
309	// so the picker reflects edits made during the session.
310	SkillSnapshot func() []*skills.Skill
311
312	// ChangelogChan, if non-nil, delivers release-notes for the
313	// current binary version once at startup. Interactive opens a
314	// dismissible overlay when the channel produces a non-empty
315	// body. Receiver fires at most once.
316	ChangelogChan <-chan ChangelogPayload
317
318	// OnChangelogDismiss, if non-nil, is called once the user
319	// closes the changelog overlay. The cli wires this to a
320	// MarkChangelogShown call so the same version doesn't show
321	// again on the next launch.
322	OnChangelogDismiss func()
323
324	// NoYolo is true when --no-yolo was passed. Interactive opens
325	// a confirmation dialog before every tool call and blocks the
326	// tool until the user picks yes / always-this-tool /
327	// always-all / no. When false (default), tools run freely.
328	NoYolo bool
329
330	// ConfirmGate is the session-scoped gate wrapping this
331	// interactive's Confirmer. When non-nil, /yolo can call
332	// AllowAll() on it to disable confirmation for the rest of the
333	// session. When nil (yolo mode), /yolo reports that there's
334	// nothing to disable.
335	ConfirmGate *core.ConfirmGate
336}
337
338// ChangelogPayload mirrors agent.ChangelogInfo without the import
339// cycle. The cli builds one from the http response, the tui opens
340// the overlay when one arrives.
341type ChangelogPayload struct {
342	Version string
343	Body    string
344	URL     string
345}
346
347type modelRefreshResult struct{ err error }
348
349// Interactive is the TUI chat loop.
350type chatCacheKey struct {
351	cols            int
352	agentRev        uint64
353	statusOK        string
354	statusErr       string
355	help            string
356	extNotes        string
357	reloadErrors    string
358	updateAvailable bool
359	updateCurrent   string
360	updateLatest    string
361	updateURL       string
362	welcomeShowVer  bool
363	expandAll       bool
364	tailLimit       int
365}
366
367// QuickModelShortcut is one configured quick model switch slot.
368type QuickModelShortcut struct {
369	Provider string
370	Model    string
371}
372
373// SettingsStore persists user-toggleable settings surfaced by /settings.
374type SettingsStore interface {
375	SetQuickModelShortcut(slot int, providerName, model string) error
376	SetInlineImages(enabled bool) error
377	SetAutoSwarm(enabled bool) error
378	SetJailByDefault(enabled bool) error
379	SetOpenRouterServerTools(enabled bool) error
380	SetRecursiveFileSuggest(enabled bool) error
381	SetFuzzySkillSuggest(enabled bool) error
382	SetRespectGitignore(enabled bool) error
383	SetCompactMode(enabled bool) error
384	SetCollapseToolCall(enabled bool) error
385	SetTUIInputStyle(style string) error
386	SetTUIStatusPosition(position string) error
387	SetTUIWorkingPosition(position string) error
388	SetReasoning(level string) error
389	SetTheme(name string) error
390}
391
392type showInstructionsSettingsStore interface {
393	SetShowInstructionsAtStartup(enabled bool) error
394}
395
396type autoCompactThresholdSettingsStore interface {
397	SetAutoCompactThreshold(percent int) error
398}
399
400type Interactive struct {
401	cfg  InteractiveConfig
402	view *tui.View
403	ed   *tui.Editor
404	rend *tui.Renderer
405
406	mu        sync.Mutex
407	agent     *core.Agent
408	streaming strings.Builder // what's currently painted on screen
409	streamOn  bool
410
411	// streamPending is the runes buffered after each EvTextDelta that
412	// haven't yet been promoted into `streaming` for rendering. It
413	// exists because some provider paths (notably Anthropic via the
414	// oauth/subscription channel) coalesce the model's output into a
415	// few fat chunks instead of drip-streaming. Painting those fat
416	// chunks verbatim looks like the summary "just appears". The
417	// paintPace goroutine drains a handful of runes per tick from
418	// this buffer into `streaming`, giving every path the same
419	// typewriter feel regardless of upstream chunk size.
420	streamPending []rune
421	// streamFlushPending is set when EvAssistantMessage fires while
422	// streamPending still has unrendered runes. The ticker flushes
423	// them, then closes out the stream (clearing flags, resetting
424	// buffers) so the final paint matches the on-disk message.
425	streamFlushPending bool
426	toolCalls          map[string]*tui.ToolCallView
427	toolOrder          []string
428	// toolGate records, per tool-call id, how many runes of paced
429	// assistant text must have drained into `streaming` before that
430	// tool block may appear. It exists to make stream ordering
431	// deterministic: a tool call can arrive from the provider while
432	// the prose that logically precedes it is still being typed out
433	// by the pacer. Without gating, the tool block would render
434	// immediately while the intro paragraph keeps filling in below
435	// it. We snapshot the total expected stream length (already
436	// streamed + still pending) at the moment the tool starts, and
437	// hold the block back until the pacer reaches it.
438	toolGate         map[string]int
439	statusErr        string
440	statusOK         string
441	reloadStatusSeq  uint64
442	liveBlock        []string // live streaming/tool progress rendered outside scrollback
443	helpBlock        []string // rendered above the chat when /help was typed
444	cumUsage         provider.Usage
445	lastCtxInput     int // input_tokens of the most recent turn — approximates current context size
446	busy             bool
447	dirty            chan struct{}
448	modelRefresh     chan modelRefreshResult
449	startupPreDone   chan startupPreResult
450	cancelTurn       context.CancelFunc
451	scrollOffset     int // rows from the bottom; 0 = pinned to latest
452	prevScrollOffset int // last value redraw snapped against; tracks intent
453
454	// prevChatLen and prevChatCols track the chat buffer's size at the
455	// last redraw so that when content grows below the user's viewport
456	// while they're scrolled up reading history, we can bump
457	// scrollOffset by exactly the growth and keep the visible content
458	// pinned. Without this, every streamed line shifts the visible
459	// window down through the buffer (because scrollOffset is measured
460	// from the bottom) and the user's reading position drifts upward
461	// and off the top.
462	prevChatLen     int
463	prevChatCols    int
464	prevChatRows    int
465	prevOverlayOpen bool
466
467	// chatCache stores the built transcript/status-note rows for idle
468	// frames. Editor typing changes only the bottom input region, so
469	// reusing this cache avoids copying/walking/reassembling a long
470	// session on every keypress.
471	chatCache      []string
472	chatCacheKey   chatCacheKey
473	chatCacheValid bool
474
475	// Messages typed while a turn is in flight. Each is delivered as
476	// its own follow-up turn once the current one finishes. Rendered
477	// above the status bar as "sliding in: ..." chips.
478	queued []string
479
480	// runCtx is the top-level context passed to Run(). Follow-up turns
481	// drained from `queued` are started against this context so they
482	// survive past the ctx of the key event that enqueued them.
483	runCtx context.Context
484
485	// pendingPostCompactNote is a status_ok message to surface after
486	// a successful auto-compact pass triggered by a 413 or by the
487	// pre-turn fraction guard. Cleared by runCompact once shown.
488	pendingPostCompactNote string
489
490	// A pre-turn compaction must preserve the complete pending request,
491	// including images. Overflow recovery instead continues the user message
492	// already appended to the agent transcript, avoiding a duplicate message.
493	pendingCompactPrompt    string
494	pendingCompactImages    []provider.ImageBlock
495	hasPendingCompactPrompt bool
496	continueAfterCompact    bool
497
498	// compacting is true for both manual and automatic compaction. Prompts
499	// submitted while it is set stay in the host queue because Compact does
500	// not run the agent loop that drains the agent-owned queue.
501	compacting bool
502
503	// autoCompacting is true while a model-triggered compaction is in
504	// flight. Surfaced in the status bar so the user can tell a
505	// condense pass from a regular assistant turn.
506	autoCompacting bool
507
508	// updateInfo is the result of the async update check. Zero value
509	// while the check hasn't completed or nothing is available.
510	updateInfo UpdateInfo
511
512	dialog            *loginDialog
513	modelDialog       *modelDialog
514	llamaDialog       *llamaDialog
515	rescueDialog      *rescueDialog
516	sessionDialog     *sessionDialog
517	swarmDialog       *swarmDialog
518	jumpDialog        *jumpDialog
519	btwDialog         *btwDialog
520	skillsDialog      *skillsDialog
521	changelogDialog   *changelogDialog
522	confirmDialog     *confirmDialog
523	logoutDialog      *logoutDialog
524	telegramDialog    *telegramDialog
525	settingsDialog    *settingsDialog
526	quickModelAssign  int
527	telegramBridge    *telegram.Bridge
528	sessionOpsDialog  *sessionOpsDialog
529	sessionTreeDialog *sessionTreeDialog
530	timeline          *timelineView
531	extPanel          *extPanelDialog
532	llamaConfigured   bool
533
534	// swarmWatch tracks auto-swarm sub-agents the main agent spawned
535	// via swarm_spawn. Each entry holds the agent + the task text;
536	// a per-entry goroutine waits on the agent's terminal state. When
537	// every tracked entry has finished, the watcher flushes a single
538	// summary turn into the main chat (queued if the main agent is
539	// busy, run immediately if idle).
540	swarmWatchMu sync.Mutex
541	swarmWatch   []*swarmWatchEntry
542
543	// pendingFork is true when the user ran /session fork: the next
544	// jump-picker selection should branch off that message instead
545	// of scrolling. Flag resets after the action fires or the dialog
546	// is dismissed, so repeated /jump calls don't turn into forks.
547	pendingFork bool
548	suggest     *slashSuggester
549	fileSuggest *fileSuggester
550	spin        *spinner
551
552	// parkedTurn is the 1-based turn number the viewport is currently
553	// scrolled to by /jump. 0 = not parked, showing the tail as usual.
554	// Rendered as a muted footer at the bottom of the chat so users
555	// don't forget they're looking at history.
556	parkedTurn  int
557	parkedTotal int
558	jumpRow     *int // pending target, applied after the picker closes and layout is known
559
560	// inputHistoryIndex is -1 when not browsing history. When the
561	// editor is empty, Up/Down can walk previous user prompts without
562	// stealing normal vertical cursor movement in non-empty input.
563	inputHistoryIndex int
564
565	// lastCtrlC is when the user last pressed ctrl+c. The first press
566	// clears the editor / cancels a turn / shows a hint; a second press
567	// within ctrlCExitWindow exits. Mirrors the python-repl convention.
568	lastCtrlC time.Time
569
570	// welcomeStart is when the interactive run began. The welcome
571	// banner shows the binary version for welcomeVersionDuration
572	// after this point and reverts to plain text after.
573	welcomeStart time.Time
574
575	// extNotes are one-shot styled lines pushed by extensions via
576	// Notify / Display. They live above the editor (just below the
577	// transcript) until cleared by /clear or another reset.
578	extNotes []string
579
580	// reloadErrors are host-only resource discovery and reload failures. They
581	// stay in the scrolling chat until /clear without entering the transcript.
582	reloadErrors []string
583
584	// keymap holds the compiled config.json keymap bindings, sorted by
585	// chord name. Invalid entries are reported through reloadErrors.
586	keymap []keymapBinding
587
588	// shellRunning is true while a !command is executing. It shares
589	// i.busy/i.cancelTurn so esc cancels it and no turn or other shell
590	// escape can start while one is in flight.
591	shellRunning bool
592	// shellLive is the accumulating stdout/stderr of the in-flight
593	// shell escape, updated via BashTool progress for live rendering.
594	shellLive string
595
596	// awaitingStartupPre is true while the zotfile entry.pre auto-submit
597	// is in flight. When it clears, deferredInitialInput is applied.
598	awaitingStartupPre   bool
599	deferredInitialInput string
600	autoSubmitDeferred   bool
601
602	// sessionLoading is true while a /sessions selection is being read
603	// on a background goroutine. Keeping this off the input goroutine
604	// lets ctrl+c/exit remain responsive for very large JSONL sessions.
605	sessionLoading bool
606
607	// pendingRescuePrompt / pendingRescueImages stash the prompt and
608	// images that should be re-run after the user picks a model in
609	// the rescue dialog. Cleared once applyRescueSelection consumes
610	// them (or when the dialog is dismissed via esc).
611	pendingRescuePrompt string
612	pendingRescueImages []provider.ImageBlock
613
614	clipboardImages []clipboardImageAttachment
615}
616
617// welcomeVersionDuration is how long the welcome banner shows the
618// version suffix before reverting to the plain headline. 1.5s is
619// enough to read at a glance and keeps the splash short.
620const welcomeVersionDuration = 1500 * time.Millisecond
621
622// initialResumeTailLimit caps how many messages from a freshly-resumed
623// transcript we render on the first paint. The full transcript is
624// still in memory; older messages are rendered (and their cached
625// lines kept for the lifetime of the View) as soon as the user
626// scrolls past the rendered tail. Picked to comfortably fill the
627// largest realistic terminal viewport while keeping first paint
628// snappy on multi-thousand-message sessions where markdown / syntax
629// highlighting dominates the redraw cost.
630const initialResumeTailLimit = 80
631
632// resumeTailExpandStep is how many additional messages the tail
633// limit grows by each time the user scrolls past the currently
634// rendered top. Pre-rendering this many messages on a single tick
635// keeps scroll-up smooth without falling back to a one-by-one
636// reveal that would feel jerky.
637const resumeTailExpandStep = 80
638
639type startupPreResult struct {
640	deferred   string
641	autoSubmit bool
642}
643
644// NewInteractive constructs an Interactive from cfg.
645func NewInteractive(cfg InteractiveConfig) *Interactive {
646	renderer := tui.NewRenderer(cfg.Terminal)
647	renderer.SetTheme(cfg.Theme)
648	startupAgentName := ""
649	startupContextPaths := []string(nil)
650	startupExtensionNames := []string(nil)
651	startupSkillNames := []string(nil)
652	if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup {
653		startupAgentName = cfg.StartupAgentName
654		startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...)
655		startupExtensionNames = append(startupExtensionNames, cfg.StartupExtensionNames...)
656		startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...)
657	}
658	i := &Interactive{
659		cfg: cfg,
660		view: &tui.View{
661			Theme:                 cfg.Theme,
662			ImageProto:            effectiveImageProtocol(cfg.InlineImagesEnabled),
663			FlatTools:             cfg.FlatTools,
664			CompactUser:           cfg.CompactUser,
665			CompactMode:           cfg.CompactMode != nil && *cfg.CompactMode,
666			CollapseToolCall:      cfg.CollapseToolCall != nil && *cfg.CollapseToolCall,
667			StartupAgentName:      startupAgentName,
668			StartupContextPaths:   startupContextPaths,
669			StartupExtensionNames: startupExtensionNames,
670			StartupSkillNames:     startupSkillNames,
671		},
672		// Prompt is the standard half-block accent bar used by chat
673		// speaker labels too, so the input gutter matches the rest
674		// of the UI.
675		ed:                tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
676		rend:              renderer,
677		toolCalls:         map[string]*tui.ToolCallView{},
678		toolGate:          map[string]int{},
679		dirty:             make(chan struct{}, 8),
680		modelRefresh:      make(chan modelRefreshResult, 1),
681		startupPreDone:    make(chan startupPreResult, 1),
682		dialog:            newLoginDialog(),
683		modelDialog:       newModelDialog(),
684		llamaDialog:       newLlamaDialog(),
685		rescueDialog:      newRescueDialog(),
686		sessionDialog:     newSessionDialog(),
687		swarmDialog:       newSwarmDialog(),
688		jumpDialog:        newJumpDialog(),
689		btwDialog:         newBtwDialog(),
690		skillsDialog:      newSkillsDialog(),
691		changelogDialog:   newChangelogDialog(),
692		confirmDialog:     newConfirmDialog(),
693		logoutDialog:      newLogoutDialog(),
694		telegramDialog:    newTelegramDialog(),
695		settingsDialog:    newSettingsDialog(),
696		sessionOpsDialog:  newSessionOpsDialog(),
697		sessionTreeDialog: newSessionTreeDialog(),
698		timeline:          newTimelineView(),
699		extPanel:          newExtPanelDialog(),
700		suggest:           newSlashSuggester(),
701		fileSuggest:       newFileSuggester(),
702		spin:              newSpinner(cfg.Theme),
703		inputHistoryIndex: -1,
704		reloadErrors:      append([]string(nil), cfg.StartupExtensionErrors...),
705	}
706	var keymapIssues []string
707	i.keymap, keymapIssues = compileKeymap(cfg.Keymap)
708	i.reloadErrors = append(i.reloadErrors, keymapIssues...)
709	i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
710	i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest)
711	i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
712	if cfg.LlamaCPPConfig != nil {
713		baseURL, _, err := cfg.LlamaCPPConfig()
714		i.llamaConfigured = err == nil && baseURL != ""
715	}
716	if cfg.Agent != nil {
717		i.agent = cfg.Agent
718		i.view.Messages = cfg.Agent.Messages()
719		i.cumUsage = cfg.Agent.Cost()
720		// Rehydrate the "context used" gauge from the last persisted
721		// turn. Without this the status bar reads 0.0% after a resume
722		// until the next turn lands a usage event.
723		if last := cfg.Agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
724			i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
725		}
726		// Cap the first paint at the tail of the transcript so
727		// resuming a multi-thousand-message session doesn't block
728		// on rendering every prior turn before showing anything.
729		if len(i.view.Messages) > initialResumeTailLimit {
730			i.view.TailLimit = initialResumeTailLimit
731		}
732	}
733	if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
734		i.applyAutoSwarmTool(true)
735	}
736	i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
737	return i
738}
739
740// Run blocks until the user quits.
741func (i *Interactive) Run(ctx context.Context) error {
742	i.runCtx = ctx
743	term := i.cfg.Terminal
744	restore, err := term.EnterRaw()
745	if err != nil {
746		return err
747	}
748	defer restore()
749	defer func() {
750		if i.telegramBridge != nil {
751			i.telegramBridge.Stop()
752		}
753	}()
754
755	// Enabling mouse reporting steals click-drag selection from the
756	// host terminal (VS Code, Ghostty, iTerm). The user prefers native
757	// selection over the wheel-speed boost, so we no longer turn it
758	// on automatically. Wheel events fall through to the terminal's
759	// own scrollback handler.
760	// Keep the live log on the terminal's main screen. Only explicit
761	// history browsing uses the alternate screen. The renderer emits
762	// chat as normal terminal flow/scrollback and redraws only the live
763	// input/status block on normal typing.
764	_, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqEnhancedKeyboardOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1)))
765	// Tell the terminal our working directory (OSC 7) so "new tab /
766	// split here" opens in the launch cwd instead of inheriting a stale
767	// directory from an extension subprocess (see issue #38). Harmless
768	// on terminals that ignore it.
769	if seq := tui.ReportCWD(i.cfg.CWD); seq != "" {
770		_, _ = term.Write([]byte(seq))
771	}
772	// Erase the live frame on exit without moving the cursor. The shell can
773	// then draw its prompt on the row where zot ended instead of leaving the
774	// inactive TUI visible above it. Do not erase scrollback: users should
775	// still be able to review the session after closing zot.
776	defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.SeqShowCursor))
777	defer i.rend.CloseHistory()
778	i.applyInputCursorColor()
779
780	// Streaming pacer: drains buffered text deltas at a steady rate
781	// so typewriter feel is identical across providers regardless of
782	// upstream chunk size. Starts here so it lives for the whole
783	// session and exits with ctx.
784	go i.runStreamPacer(ctx)
785
786	cols, rows := term.Size()
787	i.rend.Resize(cols, rows)
788	term.OnResize(func() {
789		c, r := term.Size()
790		i.rend.Resize(c, r)
791		// Force an immediate redraw on resize. The throttled invalidate
792		// path is fine for animation, but a window resize is a discrete
793		// user action where any visible delay (or stale frame) reads as
794		// brokenness. redraw() is mutex-safe; the worst that happens is
795		// a duplicate paint if the throttler is mid-flight, which is
796		// invisible.
797		i.redraw()
798	})
799
800	if i.cfg.StartupPre != "" {
801		i.deferredInitialInput = i.cfg.InitialInput
802		i.autoSubmitDeferred = i.cfg.AutoSubmitInitial
803		i.awaitingStartupPre = true
804		i.Submit(i.cfg.StartupPre)
805	} else if i.cfg.AutoSubmitInitial && i.cfg.InitialInput != "" {
806		i.Submit(i.cfg.InitialInput)
807	} else if i.cfg.InitialInput != "" {
808		i.ed.SetValue(i.cfg.InitialInput)
809	}
810
811	// Stamp the welcome time and schedule a one-shot redraw at the
812	// expiry so the version suffix disappears on its own even if the
813	// user hasn't typed anything yet.
814	i.welcomeStart = time.Now()
815	time.AfterFunc(welcomeVersionDuration, i.invalidate)
816
817	// If the agent was constructed with a pre-loaded transcript
818	// (--continue, --resume, --session) pin the viewport at the
819	// bottom so the most recent reply (and any prompt the user just
820	// typed) is fully visible. Earlier behaviour parked the view at
821	// the last user turn, which could leave the latest message clipped
822	// off the bottom of the page on long sessions.
823	if i.agent != nil {
824		if msgs := i.agent.Messages(); len(msgs) > 0 {
825			i.scrollToBottom()
826		}
827	}
828
829	// No credential at startup? Auto-open the login dialog, and mark
830	// the status line. The user can Esc out of the dialog if they
831	// want to dismiss it (e.g. to check /help or /exit first).
832	if i.agent == nil {
833		i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
834		i.dialog.Open(i.cfg.ZotHome)
835	}
836
837	// Input goroutine. Buffered generously so a drag-drop that the
838	// terminal delivers as a burst of single-character key events
839	// (no bracketed paste) can be drained in one main-loop pass
840	// instead of triggering a redraw per character.
841	keys := make(chan tui.Key, 256)
842	go func() {
843		reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
844		for {
845			k, err := reader.Read()
846			if err != nil {
847				return
848			}
849			keys <- k
850		}
851	}()
852
853	// Subscribe to auth events.
854	var authEvents <-chan auth.Event
855	if i.cfg.AuthManager != nil {
856		authEvents = i.cfg.AuthManager.Events()
857	}
858
859	// Animation ticker: drives spinner and dialog-related redraws when
860	// nothing else changed. 120ms is slow enough that highlighting a huge
861	// transcript doesn't spin the cpu.
862	tick := time.NewTicker(120 * time.Millisecond)
863	defer tick.Stop()
864
865	// Redraw throttle: coalesce bursts of invalidate() calls so we paint
866	// at most once every redrawMinInterval. Huge tool-result dumps can
867	// fire hundreds of invalidations while the user is typing; without
868	// this, the input goroutine never gets CPU and keystrokes lag.
869	const redrawMinInterval = 16 * time.Millisecond
870	var lastRedraw time.Time
871	var pendingRedraw bool
872	var pendingTimer *time.Timer
873
874	drainPending := func() {
875		if pendingTimer != nil {
876			pendingTimer.Stop()
877			pendingTimer = nil
878		}
879		if pendingRedraw {
880			pendingRedraw = false
881			lastRedraw = time.Now()
882			i.redraw()
883		}
884	}
885
886	requestRedraw := func() {
887		since := time.Since(lastRedraw)
888		if since >= redrawMinInterval {
889			// Redrawing right now subsumes any pending redraw, so clear
890			// the throttle state. Without this, a pending flag stays
891			// stuck at true and subsequent invalidate() calls within
892			// redrawMinInterval get dropped — which is exactly how the
893			// final "turn finished" frame went missing until the user
894			// nudged the ui by typing or scrolling.
895			if pendingTimer != nil {
896				pendingTimer.Stop()
897			}
898			pendingRedraw = false
899			lastRedraw = time.Now()
900			i.redraw()
901			return
902		}
903		if pendingRedraw {
904			return // already scheduled
905		}
906		pendingRedraw = true
907		wait := redrawMinInterval - since
908		if pendingTimer == nil {
909			pendingTimer = time.AfterFunc(wait, func() {
910				// Poke the dirty channel so the main loop wakes and
911				// drains the pending redraw on its own goroutine. We
912				// can't call drainPending here directly — it touches
913				// closure state shared with the main loop.
914				i.invalidate()
915			})
916		} else {
917			pendingTimer.Reset(wait)
918		}
919	}
920
921	i.invalidate()
922
923	updates := i.cfg.UpdateInfoChan  // nil-safe; nil channel blocks forever in select
924	changelog := i.cfg.ChangelogChan // single-shot, see case below
925
926	for {
927		select {
928		case <-ctx.Done():
929			return ctx.Err()
930		case k := <-keys:
931			if done := i.handleKey(ctx, k); done {
932				return nil
933			}
934			// Drain any keystrokes that arrived during this iteration.
935			// VS Code (and other terminals that don't bracket drops as
936			// paste) deliver a path one rune at a time — without this
937			// loop the editor would render between every rune and a
938			// long path on a heavy transcript would visibly type in.
939		drain:
940			for {
941				select {
942				case k2 := <-keys:
943					if done := i.handleKey(ctx, k2); done {
944						return nil
945					}
946				default:
947					break drain
948				}
949			}
950			i.invalidate()
951		case ev := <-authEvents:
952			i.handleAuthEvent(ev)
953			i.invalidate()
954		case result := <-i.modelRefresh:
955			i.openModelPickerAfterRefresh(result.err)
956			i.invalidate()
957		case result := <-i.startupPreDone:
958			i.applyStartupPreResult(result)
959			i.invalidate()
960		case info, ok := <-updates:
961			if ok && info.Available {
962				i.mu.Lock()
963				i.updateInfo = info
964				i.mu.Unlock()
965				i.invalidate()
966			}
967			updates = nil // single-shot; subsequent iterations skip this case
968		case cl, ok := <-changelog:
969			if ok && cl.Body != "" {
970				i.changelogDialog.Open(cl.Version, cl.URL, cl.Body)
971				i.invalidate()
972			}
973			changelog = nil // single-shot
974		case <-i.dirty:
975			requestRedraw()
976		case <-tick.C:
977			// Always drain a pending redraw on the tick. This is the
978			// safety net that catches the case where the dirty channel
979			// was saturated when the final "turn finished" invalidate
980			// fired, or where the throttle scheduled a deferred redraw
981			// and the AfterFunc-driven invalidate got dropped on a
982			// full channel.
983			drainPending()
984			// Only force a periodic redraw when something is actually
985			// animating (the main spinner during a busy turn, or the
986			// btw side-chat spinner while it's awaiting a response).
987			// Static pickers (model, session, jump, etc.) don't need
988			// the tick and firing it cancels the terminal's cursor
989			// blink inside dialogs that host their own editor (btw),
990			// because each frame re-emits hide-cursor + show-cursor.
991			//
992			// The swarm dashboard is also animated: its rows reflect
993			// background subprocesses whose activity / age change
994			// without any user input. Without the tick redraw the
995			// dashboard freezes on the snapshot taken when the user
996			// last pressed a key. We exclude the dashboard when one
997			// of its inline editors (spawn task or prompt composer)
998			// is active so the cursor blink in those editors works
999			// the same way it does inside btw.
1000			if i.busy || i.btwDialog.Loading() || i.swarmDialog.NeedsTickRefresh() {
1001				requestRedraw()
1002			}
1003		}
1004	}
1005}
1006
1007func (i *Interactive) invalidate() {
1008	select {
1009	case i.dirty <- struct{}{}:
1010	default:
1011	}
1012}
1013
1014func (i *Interactive) cachedChatLocked(cols int) []string {
1015	key, cacheable := i.chatCacheKeyLocked(cols)
1016	if cacheable && i.chatCacheValid && i.chatCacheKey == key {
1017		return append([]string(nil), i.chatCache...)
1018	}
1019	chat := i.buildChatLocked(cols)
1020	if cacheable {
1021		i.chatCache = append(i.chatCache[:0], chat...)
1022		i.chatCacheKey = key
1023		i.chatCacheValid = true
1024	} else {
1025		i.chatCacheValid = false
1026	}
1027	return chat
1028}
1029
1030func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
1031	// Live turns mutate streaming/tool-call state at high frequency;
1032	// keep those on the old rebuild path. The cache targets the common
1033	// idle case where only the editor contents changed between redraws.
1034	if i.busy || i.streamOn || i.streamFlushPending || i.timeline.Active() {
1035		return chatCacheKey{}, false
1036	}
1037	var rev uint64
1038	if i.agent != nil {
1039		rev = i.agent.Revision()
1040	}
1041	showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
1042	return chatCacheKey{
1043		cols:            cols,
1044		agentRev:        rev,
1045		statusOK:        i.statusOK,
1046		statusErr:       i.statusErr,
1047		help:            strings.Join(i.helpBlock, "\n"),
1048		extNotes:        strings.Join(i.extNotes, "\n"),
1049		reloadErrors:    strings.Join(i.reloadErrors, "\n"),
1050		updateAvailable: i.updateInfo.Available,
1051		updateCurrent:   i.updateInfo.Current,
1052		updateLatest:    i.updateInfo.Latest,
1053		updateURL:       i.updateInfo.URL,
1054		welcomeShowVer:  showVer,
1055		expandAll:       i.view.ExpandAll,
1056		tailLimit:       i.view.TailLimit,
1057	}, true
1058}
1059
1060func (i *Interactive) buildChatLocked(cols int) []string {
1061	if i.agent != nil {
1062		i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1063	} else {
1064		i.view.Messages = nil
1065	}
1066	if i.timeline.Active() {
1067		_, rows := i.cfg.Terminal.Size()
1068		return i.timeline.Render(i.cfg.Theme, cols, rows, i.timelineDataLocked())
1069	}
1070	// Pacer flush: while the streaming pacer is still draining the
1071	// buffer (i.e. EvAssistantMessage already fired but more runes
1072	// are queued), the final assistant message is already in
1073	// i.agent.Messages() in full. Painting it in the transcript
1074	// AND the streaming block at the same time shows the user the
1075	// complete text immediately — which defeats the whole pacer.
1076	// Hide the last message until the pacer catches up; once the
1077	// flush-pending latch clears, the message is revealed (the
1078	// streaming block disappears the same frame because streamOn
1079	// flips off, so the transition is seamless).
1080	if i.streamFlushPending && len(i.view.Messages) > 0 {
1081		i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
1082	}
1083	i.view.Streaming = i.streaming.String()
1084	i.view.StreamingActive = i.streamOn
1085	// Guard against the narrow race where EvAssistantMessage has
1086	// just promoted a streaming reply into the transcript but a
1087	// render tick hasn't flipped streamOn off yet. Without the
1088	// guard, the same text would appear twice (once as the
1089	// in-flight streaming block, once as the last transcript
1090	// message). We detect the duplicate strictly: the last
1091	// assistant message's visible text must equal the streaming
1092	// buffer. Just matching on role is too broad — it also hides
1093	// the next round's typewriter streaming after a tool turn,
1094	// because the last transcript message is always assistant
1095	// (the tool-use block) until the follow-up summary lands.
1096	if i.streamOn && i.streaming.Len() > 0 {
1097		if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
1098			if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
1099				i.view.StreamingActive = false
1100			}
1101		}
1102	}
1103	// Live tool-call view: only shown while a turn is in flight. Once
1104	// the agent is idle, every tool call has already been folded into
1105	// the transcript (as assistant.ToolCallBlock + a tool-role message),
1106	// so showing v.ToolCalls a second time would duplicate them below
1107	// the final assistant text — which looks like the summary came
1108	// "before" the tools.
1109	i.view.ToolCalls = i.view.ToolCalls[:0]
1110	if i.busy {
1111		for _, id := range i.toolOrder {
1112			// Deterministic ordering: a tool block stays hidden until
1113			// the paced assistant text that preceded it has finished
1114			// typing out. toolOrder is append-only in arrival order,
1115			// so once one tool is still gated, every later tool is too,
1116			// so stop here to avoid showing a tool out of sequence.
1117			if !i.toolGateOpenLocked(id) {
1118				break
1119			}
1120			if tc, ok := i.toolCalls[id]; ok {
1121				i.view.ToolCalls = append(i.view.ToolCalls, *tc)
1122			}
1123		}
1124	}
1125	i.view.Err = i.statusErr
1126	// Live streaming/tool rows are appended to the chat buffer (not
1127	// hoisted into a separate live block above the editor). That keeps
1128	// the renderer's diff view append-only: when a tool finishes the
1129	// rows update in place at the end of the buffer, instead of the
1130	// whole bottom band shrinking and shifting chat lines around.
1131	i.liveBlock = nil
1132	chat := i.view.Build(cols)
1133
1134	// Welcome banner: shown at the top of the chat area when there is
1135	// no transcript yet. Disappears after the first message is sent.
1136	// The version suffix is shown for welcomeVersionDuration after
1137	// startup, then drops off automatically.
1138	if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
1139		showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
1140		chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
1141	}
1142
1143	// Update-available banner: prepended above everything else so it's
1144	// the first thing the user sees when opening a new zot session.
1145	// Once rendered, it stays until the user updates to a newer
1146	// version — we don't persist a "dismissed" flag because this is
1147	// cheap and re-showing it is how most users remember to update.
1148	if i.updateInfo.Available {
1149		banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
1150		chat = append(banner, chat...)
1151	}
1152
1153	// /help block: appended to the transcript so it appears at the
1154	// bottom of the chat area (right above the status bar / editor).
1155	// Prepending it would push long conversations off the top of the
1156	// viewport, which users would miss entirely.
1157	if len(i.helpBlock) > 0 {
1158		chat = append(chat, i.helpBlock...)
1159	}
1160
1161	if i.statusOK != "" {
1162		// Hard-truncate the OK line to the visible width so a long
1163		// session path ("resumed session: /Users/.../sessions/...")
1164		// doesn't overflow past the right edge and look broken on a
1165		// narrow terminal.
1166		line := "✓ " + i.statusOK
1167		if cols > 4 && len(line) > cols {
1168			line = line[:cols-3] + "..."
1169		}
1170		chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
1171	}
1172
1173	// Live shell-escape output (!command / entry.pre) streams into the
1174	// transcript area while the command runs, then is replaced by the
1175	// final user-context message when it finishes.
1176	if i.shellRunning && i.shellLive != "" {
1177		for _, line := range strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n") {
1178			chat = append(chat, line)
1179		}
1180		chat = append(chat, "")
1181	}
1182
1183	// Extension notes (notify / display) live just under the
1184	// transcript, above the dialog/editor band. Cleared by /clear.
1185	if len(i.extNotes) > 0 {
1186		chat = append(chat, i.extNotes...)
1187		chat = append(chat, "")
1188	}
1189
1190	// Reload failures remain in the scrolling chat until /clear. They are
1191	// host-only display state, not provider messages or persisted context.
1192	// While the latest failure is still the temporary status error, hide its
1193	// stored copy to avoid painting the same message twice.
1194	reloadErrors := i.reloadErrors
1195	if n := len(reloadErrors); n > 0 && reloadErrors[n-1] == i.statusErr {
1196		reloadErrors = reloadErrors[:n-1]
1197	}
1198	if len(reloadErrors) > 0 {
1199		chat = append(chat, renderReloadErrors(i.cfg.Theme, reloadErrors, cols)...)
1200		chat = append(chat, "")
1201	}
1202
1203	// Strip trailing blank rows so the chat content sits flush
1204	// against the new "blank above status bar" row added by the
1205	// bottom-region assembly. Build() ends every message with a
1206	// blank separator; without this trim, the final message in
1207	// the transcript would have its own trailing blank plus the
1208	// status block's leading blank, doubling the gap.
1209	for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
1210		chat = chat[:len(chat)-1]
1211	}
1212	return chat
1213}
1214
1215// sessionsRoot returns the session namespace for this run.
1216func (i *Interactive) sessionsRoot() string {
1217	if i.cfg.SessionsRoot != "" {
1218		return i.cfg.SessionsRoot
1219	}
1220	return i.cfg.ZotHome
1221}
1222
1223// lastCols returns the current terminal width in columns.
1224func (i *Interactive) lastCols() int {
1225	cols, _ := i.cfg.Terminal.Size()
1226	return cols
1227}
1228
1229func (i *Interactive) timelineData() timelineData {
1230	i.mu.Lock()
1231	defer i.mu.Unlock()
1232	return i.timelineDataLocked()
1233}
1234
1235// timelineDataLocked snapshots the current provider-neutral context for the
1236// read-only timeline. The caller must hold i.mu.
1237func (i *Interactive) timelineDataLocked() timelineData {
1238	data := timelineData{ContextUsed: i.lastCtxInput}
1239	if model, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
1240		data.ContextMax = model.ContextWindow
1241	}
1242	if i.agent != nil {
1243		data.System, data.Tools, data.Messages = i.agent.ContextSnapshot()
1244	}
1245	return data
1246}
1247
1248// chatPage returns the number of chat rows currently visible, used
1249// as the page size for PageUp/PageDown.
1250func (i *Interactive) chatPage() int {
1251	_, rows := i.cfg.Terminal.Size()
1252	p := rows - 6 // rough reservation for status + editor + a dialog line
1253	if p < 4 {
1254		p = 4
1255	}
1256	return p
1257}
1258
1259// scrollBy adjusts the scroll offset. Positive = up (into history).
1260// Clearing the parked-turn label when we're back at the bottom means
1261// the "viewing turn N" footer goes away automatically as soon as you
1262// scroll back to the live tail.
1263func (i *Interactive) scrollBy(delta int) {
1264	i.mu.Lock()
1265	i.scrollOffset += delta
1266	if i.scrollOffset < 0 {
1267		i.scrollOffset = 0
1268	}
1269	if i.scrollOffset == 0 {
1270		i.parkedTurn = 0
1271		i.parkedTotal = 0
1272	}
1273	if i.rend != nil {
1274		// VS Code's terminal is especially prone to leaving stray
1275		// wrapped-character fragments behind during scroll-driven
1276		// viewport changes. Force a full repaint on scroll, but
1277		// avoid a whole-screen clear because that visibly flickers.
1278		i.rend.Invalidate()
1279	}
1280	i.mu.Unlock()
1281	i.invalidate()
1282}
1283
1284// anchorScrollOffset keeps the user's reading position pinned when the
1285// chat buffer grows/shrinks or the viewport height changes between two
1286// redraws while they're scrolled up.
1287//
1288// scrollOffset is measured from the bottom of the chat buffer, so the
1289// top visible row is start = chatLen - scrollOffset - chatRows. To hold
1290// `start` constant we adjust the offset by the buffer-length delta minus
1291// the viewport-height delta. The result is clamped to [0, newLen] so a
1292// shrinking buffer can't push it negative.
1293func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int {
1294	adj := (newLen - prevLen) - (newRows - prevRows)
1295	offset += adj
1296	if offset < 0 {
1297		offset = 0
1298	}
1299	if offset > newLen {
1300		offset = newLen
1301	}
1302	return offset
1303}
1304
1305// scrollToBottom pins the view to the latest content.
1306func (i *Interactive) scrollToBottom() {
1307	i.mu.Lock()
1308	i.jumpRow = nil
1309	i.scrollOffset = 0
1310	i.parkedTurn = 0
1311	i.parkedTotal = 0
1312	// Reset the auto-follow baseline. scrollToBottom is the resume /
1313	// session-swap snap point, where the chat buffer changes length
1314	// wholesale. Without zeroing these, the next render's follow guard
1315	// compares the fresh transcript's length against a stale baseline
1316	// and nudges scrollOffset, which reads as a viewport jump right
1317	// after resume. See commit 43da5e5 for the same fix on new turns.
1318	i.prevChatLen = 0
1319	i.prevChatCols = 0
1320	if i.rend != nil {
1321		i.rend.Invalidate()
1322	}
1323	i.mu.Unlock()
1324	i.invalidate()
1325}
1326
1327func (i *Interactive) redraw() {
1328	i.mu.Lock()
1329	defer i.mu.Unlock()
1330
1331	cols, _ := i.cfg.Terminal.Size()
1332	chat := i.cachedChatLocked(cols)
1333
1334	// Dialogs (login or model picker) render between chat and the editor.
1335	var dialog []string
1336	switch {
1337	case i.dialog.Active():
1338		dialog = i.dialog.Render(i.cfg.Theme, cols)
1339	case i.modelDialog.Active():
1340		dialog = i.modelDialog.Render(i.cfg.Theme, cols)
1341	case i.llamaDialog.Active():
1342		dialog = i.llamaDialog.Render(i.cfg.Theme, cols)
1343	case i.rescueDialog.Active():
1344		dialog = i.rescueDialog.Render(i.cfg.Theme, cols)
1345	case i.sessionDialog.Active():
1346		// Reserve rows for the editor (~3), status line (1-2),
1347		// dialog chrome (header + hint + rule + indicators, ~5),
1348		// and leave the remainder for session rows. Minimum of 3
1349		// rows so even a very small terminal shows something.
1350		_, rows := i.cfg.Terminal.Size()
1351		avail := rows - 12
1352		if avail < 3 {
1353			avail = 3
1354		}
1355		i.sessionDialog.MaxRows = avail
1356		dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
1357	case i.swarmDialog.Active():
1358		dialog = i.swarmDialog.Render(i.cfg.Theme, cols)
1359	case i.jumpDialog.Active():
1360		dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
1361	case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
1362		dialog = i.extPanel.Render(i.cfg.Theme, cols)
1363	case i.confirmDialog.Active():
1364		if i.btwDialog.Active() {
1365			// Keep the side-chat transcript and its live tool preview visible
1366			// above confirmation, matching how the main transcript remains
1367			// visible while --no-yolo waits for a decision.
1368			dialog = renderBtwConfirmation(i.cfg.Theme, cols, i.btwDialog, i.confirmDialog)
1369		} else {
1370			dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
1371		}
1372	case i.btwDialog.Active():
1373		dialog = i.btwDialog.Render(i.cfg.Theme, cols)
1374	case i.skillsDialog.Active():
1375		dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
1376	case i.changelogDialog.Active():
1377		dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
1378	case i.logoutDialog.Active():
1379		dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
1380	case i.telegramDialog.Active():
1381		dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
1382	case i.settingsDialog.Active():
1383		dialog = i.settingsDialog.Render(i.cfg.Theme, cols)
1384	case i.sessionOpsDialog.Active():
1385		dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
1386	case i.sessionTreeDialog.Active():
1387		dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
1388	}
1389	if len(dialog) > 0 {
1390		dialog = padDialogFrame(dialog)
1391	}
1392
1393	// Slash-command autocomplete: popup above the status line, only
1394	// when the editor starts with "/" and no dialog is already open.
1395	// Feed extension-registered commands into the suggester first so
1396	// they show up in tab-complete + the popup alongside the built-ins.
1397	i.suggest.SetJailed(i.cfg.Sandbox.Locked())
1398	i.suggest.SetLlamaConfigured(i.llamaConfigured)
1399	if i.cfg.Extensions != nil {
1400		catalog := i.cfg.Extensions.Commands()
1401		extra := make([]slashCommand, 0, len(catalog))
1402		for _, c := range catalog {
1403			// The popup renders extension commands under a dedicated
1404			// "── extensions ───" divider, so the description doesn't
1405			// need to repeat the source. If the description is empty,
1406			// fall back to the extension name so the row isn't blank.
1407			desc := c.Description
1408			if strings.TrimSpace(desc) == "" {
1409				desc = "(" + c.Extension + ")"
1410			}
1411			extra = append(extra, slashCommand{
1412				Name: "/" + c.Name,
1413				Desc: desc,
1414			})
1415		}
1416		i.suggest.SetExtra(extra)
1417	}
1418	var suggest []string
1419	currentInput := i.ed.Value()
1420	if i.suggest.SkillInputStarted(currentInput) {
1421		var list []*skills.Skill
1422		if i.cfg.SkillSnapshot != nil {
1423			list = i.cfg.SkillSnapshot()
1424		}
1425		i.suggest.SetSkills(list)
1426	}
1427	// Slash popup renders even while the agent is busy so the user
1428	// can queue a destructive command (/clear, /compact, /logout,
1429	// /model) or a read-only one (/help, /jump, /sessions, etc.)
1430	// without waiting for the current turn to finish. The dispatcher
1431	// in runSlash already handles the busy case per-command: safe
1432	// ones run immediately, destructive ones cancel the turn first.
1433	i.fileSuggest.SetCWD(i.cfg.CWD)
1434	mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActive())
1435	if mainInputFocused && i.suggest.Active(currentInput) {
1436		suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
1437	} else if mainInputFocused && i.fileSuggest.Active(currentInput) {
1438		suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)
1439	}
1440
1441	// Detect overlay close (any dialog or slash/file suggestion popup
1442	// just transitioned from open to closed). Force a full redraw so
1443	// the rows the overlay occupied are guaranteed to be repainted
1444	// from the chat below, instead of the diff path leaving stale
1445	// dialog content behind. Equivalent to the user pressing ctrl+l.
1446	overlayOpen := len(dialog) > 0 || len(suggest) > 0
1447	if i.rend != nil && i.prevOverlayOpen && !overlayOpen && i.jumpRow == nil {
1448		// An overlay (dialog or slash/file popup) just closed, so the
1449		// bottom band shrinks. On terminals where we can drop
1450		// scrollback, a full Clear is the simplest way to guarantee
1451		// the vacated rows are repainted from the chat below.
1452		//
1453		// On VS Code's terminal closing a dialog leaves the stale
1454		// overlay rows in the retained scrollback (we can't drop them
1455		// with the quiet in-place diff). Run the same full Clear() that
1456		// Ctrl+L uses so the scrollback is purged and the conversation
1457		// is repainted clean, matching what the user expects after
1458		// dismissing a picker. Clear() is keepScrollback-aware and
1459		// emits \x1b[3J there.
1460		i.rend.Clear()
1461	}
1462	i.prevOverlayOpen = overlayOpen
1463	if len(suggest) > 0 {
1464		// One blank row above the popup so it doesn't sit flush
1465		// against the chat / welcome content above.
1466		suggest = append([]string{""}, suggest...)
1467	}
1468
1469	// Busy prefix shown at the far left of the status bar. The
1470	// spinner glyph and its funny-line message share the `zot`
1471	// label colour (Theme.Assistant) so the whole "who's working"
1472	// band reads at a glance. Elapsed time stays muted because it
1473	// drifts every second and shouldn't grab focus.
1474	var busyPrefix string
1475	if i.busy {
1476		busyPrefix = fmt.Sprintf("%s %s %s %s",
1477			i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()),
1478			i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()),
1479			i.cfg.Theme.FG256(i.cfg.Theme.Muted, "-"),
1480			i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
1481		)
1482	}
1483
1484	ctxMax := 0
1485	if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
1486		ctxMax = m.ContextWindow
1487	}
1488	statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
1489	workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
1490	workingWithStatus := statusPosition == workingPosition
1491	statusBusyPrefix := ""
1492	if workingWithStatus {
1493		statusBusyPrefix = busyPrefix
1494	}
1495	statusLines := tui.StatusBar(tui.StatusBarParams{
1496		Theme:          i.cfg.Theme,
1497		Provider:       i.cfg.Provider,
1498		Model:          i.cfg.Model,
1499		Reasoning:      i.cfg.Reasoning,
1500		Busy:           i.busy,
1501		BusyPrefix:     statusBusyPrefix,
1502		CWD:            i.cfg.CWD,
1503		Locked:         i.cfg.Sandbox.Locked(),
1504		NoYolo:         i.cfg.NoYolo,
1505		Usage:          i.cumUsage,
1506		Subscription:   i.cfg.AuthMethod == "oauth",
1507		ContextUsed:    i.lastCtxInput,
1508		ContextMax:     ctxMax,
1509		AutoCompacting: i.autoCompacting,
1510		Telegram:       i.telegramBridge != nil && i.telegramBridge.Active(),
1511		Cols:           cols,
1512	})
1513	inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
1514	if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {
1515		i.ed.Prompt = ""
1516	} else {
1517		i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
1518	}
1519	edLines, curR, curC := i.ed.Render(cols)
1520	var workingLines []string
1521	if busyPrefix != "" && !workingWithStatus {
1522		workingLines = []string{"  " + busyPrefix}
1523	}
1524	inputCursorOffset := 0
1525	inputCursorColOffset := 0
1526	switch inputStyle {
1527	case tui.InputStyleLines:
1528		edLines = tui.InputLines(i.cfg.Theme, edLines, cols)
1529		inputCursorOffset = 1
1530	case tui.InputStyleBlock:
1531		edLines = tui.InputBlock(i.cfg.Theme, edLines, cols)
1532		inputCursorOffset = 1
1533		inputCursorColOffset = 2
1534	}
1535
1536	// "Sliding in" chips for messages the user typed while a turn is
1537	// in flight. Shown directly above the status bar so they're close
1538	// to the editor but don't push the chat around.
1539	var queue []string
1540	queued := append([]string(nil), i.queued...)
1541	if i.agent != nil {
1542		queued = append(queued, i.agent.PendingQueuedMessages()...)
1543	}
1544	if len(queued) > 0 {
1545		queue = append(queue, "")
1546		for _, q := range queued {
1547			label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "  sliding in: ")
1548			text := truncateLine(q, cols-17)
1549			queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
1550		}
1551		// Hint row, rendered in the same muted tone as the model
1552		// info on the status bar so it reads as ambient metadata
1553		// rather than a chip. Tells the user how to recover the
1554		// most recent queued message back into the editor.
1555		hint := "  Press " + slideBackChordHint() + " to slide back into input"
1556		queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
1557	}
1558
1559	// Bottom-sticky sections (always visible, never scroll). Each
1560	// non-empty subsection (dialog, suggest popup, sliding-in queue)
1561	// is preceded by one blank row so it has air above the chat
1562	// content. The status block and editor get their own dedicated
1563	// blanks so spacing stays consistent whether or not a dialog or
1564	// popup is showing.
1565	bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(statusLines)+len(edLines)+9)
1566	inputStartRow := -1
1567	if len(dialog) > 0 {
1568		bottom = append(bottom, "")
1569	}
1570	bottom = append(bottom, dialog...)
1571	// The swarm dashboard owns the bottom of the screen while it's
1572	// active: it has its own inline editors for spawn (`n`) and
1573	// prompt (`p`), so the main input would be a confusing second
1574	// caret. The suggest popup, sliding-in queue, status block, and
1575	// main editor are all hidden underneath it. Keystrokes still
1576	// reach handleKey — it routes them to swarmDialog.HandleKey
1577	// before the editor ever sees them — so the only effect of this
1578	// branch is visual.
1579	if !i.swarmDialog.Active() {
1580		bottom = append(bottom, suggest...)
1581		bottom = append(bottom, queue...)
1582		lineInput := inputStyle == tui.InputStyleLines
1583		statusBelow := statusPosition == tui.StatusPositionBelowInput
1584		workingBelow := workingPosition == tui.WorkingPositionBelowInput
1585
1586		var aboveInput []string
1587		if !statusBelow {
1588			aboveInput = append(aboveInput, statusLines...)
1589		}
1590		if !workingBelow {
1591			aboveInput = append(aboveInput, workingLines...)
1592		}
1593		var belowInput []string
1594		if workingBelow {
1595			belowInput = append(belowInput, workingLines...)
1596		}
1597		if statusBelow {
1598			belowInput = append(belowInput, statusLines...)
1599		}
1600
1601		bottom = append(bottom, "")
1602		bottom = append(bottom, aboveInput...)
1603		needsGapAboveInput := len(aboveInput) > 0 && !lineInput
1604		if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 {
1605			needsGapAboveInput = true
1606		}
1607		if needsGapAboveInput {
1608			bottom = append(bottom, "")
1609		}
1610		inputStartRow = len(bottom)
1611		bottom = append(bottom, edLines...)
1612		if len(belowInput) > 0 {
1613			if !lineInput {
1614				bottom = append(bottom, "")
1615			}
1616			bottom = append(bottom, belowInput...)
1617		}
1618	}
1619
1620	_, rows := i.cfg.Terminal.Size()
1621	chatRows := rows - len(bottom)
1622	if chatRows < 1 {
1623		chatRows = 1
1624	}
1625
1626	// Auto-follow guard: when the user has scrolled up (scrollOffset
1627	// > 0) and the agent appends new content below the viewport while
1628	// they're reading, compensate so the visible content stays
1629	// anchored. scrollOffset is measured from the bottom of `chat`,
1630	// so without compensation a growing buffer pushes the window
1631	// downward through the content and the lines the user was
1632	// reading scroll off the top.
1633	//
1634	// Skip compensation when the terminal width changed (a resize
1635	// reflows the whole buffer and the line-count delta no longer
1636	// corresponds to appended content) and when scrollOffset is 0
1637	// (the user is following the tail and wants new content to push
1638	// the view down as usual).
1639	//
1640	// The window the user sees starts at row
1641	//   start = len(chat) - scrollOffset - chatRows
1642	// so to keep `start` fixed across a redraw we must offset by both
1643	// the buffer growth (len delta) AND the viewport-height change
1644	// (chatRows delta, e.g. the status band or sliding-in queue
1645	// appearing during a turn). Compensating only for the len delta
1646	// let a shrinking chatRows pull the window toward the tail, which
1647	// read as the viewport jumping to the bottom whenever the agent
1648	// streamed text or a tool call grew the bottom band.
1649	if i.jumpRow != nil {
1650		i.scrollOffset = max(0, len(chat)-*i.jumpRow-chatRows)
1651		i.jumpRow = nil
1652		i.prevScrollOffset = i.scrollOffset
1653	} else if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {
1654		i.scrollOffset = anchorScrollOffset(i.scrollOffset,
1655			i.prevChatLen, len(chat), i.prevChatRows, chatRows)
1656	}
1657	i.prevChatLen = len(chat)
1658	i.prevChatCols = cols
1659	i.prevChatRows = chatRows
1660
1661	// Apply scroll offset to the chat slice.
1662	maxOffset := len(chat) - chatRows
1663	if maxOffset < 0 {
1664		maxOffset = 0
1665	}
1666	// Tail-render expansion: if the user has scrolled to (or above)
1667	// the top of the currently rendered tail and there are still
1668	// truncated messages above, widen view.TailLimit and rebuild.
1669	// The chat cache is keyed on tailLimit so the next cachedChatLocked
1670	// will re-issue Build instead of returning the stale slice. We
1671	// rebuild immediately so the same redraw shows the freshly-revealed
1672	// rows; otherwise the user would have to scroll again to see them.
1673	if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
1674		prevLen := len(chat)
1675		i.view.TailLimit += resumeTailExpandStep
1676		if i.view.TailLimit >= len(i.view.Messages) {
1677			i.view.TailLimit = 0 // unbounded
1678		}
1679		i.chatCacheValid = false
1680		chat = i.cachedChatLocked(cols)
1681		for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
1682			chat = chat[:len(chat)-1]
1683		}
1684		// Newly-revealed rows are older messages prepended at the top.
1685		// scrollOffset counts rows from the bottom, so to keep the
1686		// viewport visually anchored on the same content the user was
1687		// looking at we shift it up by the number of rows added. Keep
1688		// the auto-follow baseline (prevChatLen) in sync with the
1689		// post-expansion length too, so the next render's follow guard
1690		// doesn't see this growth as a synthetic delta and yank the
1691		// viewport again.
1692		if grew := len(chat) - prevLen; grew > 0 {
1693			i.scrollOffset += grew
1694		}
1695		i.prevChatLen = len(chat)
1696		i.prevChatCols = cols
1697		maxOffset = len(chat) - chatRows
1698		if maxOffset < 0 {
1699			maxOffset = 0
1700		}
1701	}
1702	if i.scrollOffset > maxOffset {
1703		i.scrollOffset = maxOffset
1704	}
1705	if i.scrollOffset < 0 {
1706		i.scrollOffset = 0
1707	}
1708
1709	var visibleChat []string
1710	if len(chat) <= chatRows {
1711		visibleChat = chat
1712	} else {
1713		end := len(chat) - i.scrollOffset
1714		rawStart := end - chatRows
1715		if rawStart < 0 {
1716			rawStart = 0
1717		}
1718		start := snapViewportStartToImageBlock(chat, rawStart)
1719		// If the snap pulled start upward (an image-block was atomic) while
1720		// the user is scrolling downward, the viewport would sit on the same
1721		// image until the user mashes down past every reserved row. Bump
1722		// scrollOffset past the image so one keypress always clears it.
1723		if start < rawStart && i.scrollOffset < i.prevScrollOffset {
1724			jump := rawStart - start
1725			i.scrollOffset -= jump
1726			if i.scrollOffset < 0 {
1727				i.scrollOffset = 0
1728			}
1729			end = len(chat) - i.scrollOffset
1730			rawStart = end - chatRows
1731			if rawStart < 0 {
1732				rawStart = 0
1733			}
1734			start = snapViewportStartToImageBlock(chat, rawStart)
1735		}
1736		end = start + chatRows
1737		if end > len(chat) {
1738			end = len(chat)
1739			start = end - chatRows
1740			if start < 0 {
1741				start = 0
1742			}
1743		}
1744		visibleChat = chat[start:end]
1745	}
1746	i.prevScrollOffset = i.scrollOffset
1747	visibleChat = clipBottomClippedImages(visibleChat)
1748
1749	// A tiny "scrolled up" indicator in the top-right of the chat pane
1750	// so you know you're not at the bottom. When the viewport was
1751	// parked by /jump we include the turn number so the user remembers
1752	// they're reading history rather than the live conversation.
1753	if i.scrollOffset > 0 && len(visibleChat) > 0 {
1754		var text string
1755		if i.parkedTurn > 0 && i.parkedTotal > 0 {
1756			text = fmt.Sprintf("  ↑ viewing turn %d of %d - %d lines more below (pgdn / end)",
1757				i.parkedTurn, i.parkedTotal, i.scrollOffset)
1758		} else {
1759			text = fmt.Sprintf("  ↑ %d lines more below (end to jump)", i.scrollOffset)
1760		}
1761		note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
1762		visibleChat = append([]string{note}, visibleChat...)
1763		if len(visibleChat) > chatRows {
1764			visibleChat = visibleChat[:chatRows]
1765		}
1766	}
1767
1768	// Default: the real terminal cursor sits on the main editor's
1769	// input position. In main-screen log mode cursor rows are relative
1770	// to the fixed bottom band, not the chat transcript.
1771	// dialogLead is 1 when the bottom region prepends a blank above
1772	// the dialog block (whenever a dialog is showing) so popup-side
1773	// cursor positions still land in the right cell.
1774	dialogLead := 0
1775	if len(dialog) > 0 {
1776		dialogLead = 1
1777	}
1778	cursorRow := inputStartRow + inputCursorOffset + curR
1779	cursorCol := curC + inputCursorColOffset
1780	if i.btwDialog.Active() {
1781		if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
1782			cursorRow = dialogLead + r
1783			cursorCol = c
1784		}
1785	}
1786	if i.dialog.Active() {
1787		if r, c := i.dialog.CursorPos(cols); r >= 0 {
1788			cursorRow = dialogLead + r
1789			cursorCol = c
1790		}
1791	}
1792	if i.llamaDialog.Active() {
1793		if r, c := i.llamaDialog.CursorPos(); r >= 0 {
1794			cursorRow = dialogLead + r
1795			cursorCol = c
1796		} else {
1797			cursorRow = -1
1798			cursorCol = 0
1799		}
1800	}
1801	if i.sessionDialog.Active() {
1802		if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1803			cursorRow = dialogLead + r
1804			cursorCol = c
1805		}
1806	}
1807	if i.sessionTreeDialog.Active() {
1808		if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
1809			cursorRow = dialogLead + r
1810			cursorCol = c
1811		}
1812	}
1813	if i.swarmDialog.Active() {
1814		if r, c := i.swarmDialog.CursorPos(cols); r >= 0 {
1815			cursorRow = dialogLead + r
1816			cursorCol = c
1817		} else {
1818			// Dashboard list / transcript view has no caret. Without
1819			// this branch the default cursorRow points at the
1820			// (hidden) main editor row, so the terminal would draw
1821			// a stray block somewhere in the chat region.
1822			cursorRow = -1
1823			cursorCol = 0
1824		}
1825	}
1826	if i.extPanel.Active() {
1827		cursorRow = -1
1828		cursorCol = 0
1829	}
1830	if i.scrollOffset > 0 {
1831		i.rend.DrawHistory(visibleChat, bottom, cursorRow, cursorCol)
1832	} else {
1833		i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
1834	}
1835}
1836
1837func hasImageEscape(line string) bool {
1838	return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
1839}
1840
1841// snapViewportStartToImageBlock treats inline images as atomic blocks for
1842// scrolling. Terminal image protocols draw from a single escape row into a
1843// separate graphics layer; the following blank rows are only zot's reserved
1844// footprint. If the viewport starts on one of those blank rows, there is no
1845// correct partial-image state to render. Snap back to the escape row instead
1846// so the image is either shown from its beginning or skipped entirely.
1847func snapViewportStartToImageBlock(chat []string, start int) int {
1848	if start <= 0 || start >= len(chat) {
1849		return start
1850	}
1851	if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
1852		return start
1853	}
1854	for k := start - 1; k >= 0; k-- {
1855		line := chat[k]
1856		if hasImageEscape(line) {
1857			return k
1858		}
1859		if !isBoxBlankLine(line) {
1860			break
1861		}
1862	}
1863	return start
1864}
1865
1866const (
1867	hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
1868	shellEscapeMetaKey            = "shell_escape"
1869	shellEscapeCommandMetaKey     = "shell_escape_command"
1870)
1871
1872func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
1873	if len(msgs) == 0 {
1874		return nil
1875	}
1876	out := make([]provider.Message, 0, len(msgs))
1877	for _, m := range msgs {
1878		if isHiddenTranscriptMessage(m) {
1879			continue
1880		}
1881		out = append(out, m)
1882	}
1883	return out
1884}
1885
1886func isHiddenTranscriptMessage(m provider.Message) bool {
1887	if m.Role != provider.RoleUser || len(m.Content) == 0 {
1888		return false
1889	}
1890	tb, ok := m.Content[0].(provider.TextBlock)
1891	if !ok {
1892		return false
1893	}
1894	return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
1895}
1896
1897func clipBottomClippedImages(lines []string) []string {
1898	if len(lines) == 0 {
1899		return lines
1900	}
1901	out := append([]string(nil), lines...)
1902	for i, line := range out {
1903		if !hasImageEscape(line) {
1904			continue
1905		}
1906		// Image blocks render as: image escape, zero or more blank
1907		// reservation rows, then the muted "image - ..." info line,
1908		// then one trailing blank. If the info line isn't visible in
1909		// the current chat slice, the image would paint down into the
1910		// fixed status bar area. Suppress that image for this frame.
1911		//
1912		// When the image lives inside a tool box, the reservation rows
1913		// are wrapped in vertical box edges ("│  ...  │"); those rows
1914		// look non-blank under a naive whitespace check but are still
1915		// reservation rows for this scan, so treat them as blank.
1916		foundInfo := false
1917		for j := i + 1; j < len(out); j++ {
1918			if strings.Contains(out[j], "image - ") {
1919				foundInfo = true
1920				break
1921			}
1922			if !isBoxBlankLine(out[j]) {
1923				break
1924			}
1925		}
1926		if !foundInfo {
1927			out[i] = ""
1928		}
1929	}
1930	return out
1931}
1932
1933// isBoxBlankLine reports whether line is visually empty after
1934// stripping ANSI escape sequences, surrounding whitespace, and the
1935// vertical box edges drawn by the tool-box renderer. Used by
1936// clipBottomClippedImages so an image's reservation rows still count
1937// as blank when those rows are wrapped in "│  ...  │" inside a tool box.
1938func isBoxBlankLine(line string) bool {
1939	stripped := stripANSIBytes(line)
1940	stripped = strings.TrimSpace(stripped)
1941	stripped = strings.Trim(stripped, "│")
1942	stripped = strings.TrimSpace(stripped)
1943	return stripped == ""
1944}
1945
1946// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final
1947// byte) from s without pulling in the regexp package. Mirrors the
1948// internal helper in package tui; the duplicated copy avoids exporting
1949// it just for one caller.
1950func stripANSIBytes(s string) string {
1951	if !strings.Contains(s, "\x1b") {
1952		return s
1953	}
1954	var b strings.Builder
1955	b.Grow(len(s))
1956	i := 0
1957	for i < len(s) {
1958		if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1959			end := i + 2
1960			for end < len(s) {
1961				c := s[end]
1962				end++
1963				if c >= 0x40 && c <= 0x7e {
1964					break
1965				}
1966			}
1967			i = end
1968			continue
1969		}
1970		b.WriteByte(s[i])
1971		i++
1972	}
1973	return b.String()
1974}
1975
1976// truncateLine shortens s so it fits within n display cells, with an
1977// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
1978// novel doesn't blow past the status line.
1979func panelKeyName(k tui.Key) string {
1980	switch k.Kind {
1981	case tui.KeyUp:
1982		return "up"
1983	case tui.KeyDown:
1984		return "down"
1985	case tui.KeyLeft:
1986		return "left"
1987	case tui.KeyRight:
1988		return "right"
1989	case tui.KeyEnter:
1990		return "enter"
1991	case tui.KeyEsc:
1992		return "esc"
1993	case tui.KeyTab:
1994		return "tab"
1995	case tui.KeyBackspace:
1996		return "backspace"
1997	case tui.KeyDelete:
1998		return "delete"
1999	case tui.KeyHome:
2000		return "home"
2001	case tui.KeyEnd:
2002		return "end"
2003	case tui.KeyPageUp:
2004		return "pageup"
2005	case tui.KeyPageDown:
2006		return "pagedown"
2007	case tui.KeyRune:
2008		return "rune"
2009	default:
2010		return "unknown"
2011	}
2012}
2013
2014func panelKeyText(k tui.Key) string {
2015	if k.Kind == tui.KeyRune {
2016		return string(k.Rune)
2017	}
2018	return ""
2019}
2020
2021func truncateLine(s string, n int) string {
2022	if n <= 0 {
2023		return ""
2024	}
2025	// Collapse newlines — chips are single line.
2026	s = strings.ReplaceAll(s, "\n", " ↩ ")
2027	runes := []rune(s)
2028	if len(runes) <= n {
2029		return s
2030	}
2031	if n <= 3 {
2032		return strings.Repeat(".", n)
2033	}
2034	return string(runes[:n-3]) + "..."
2035}
2036
2037// ctrlCExitWindow is how long after a ctrl+c press a *second* press
2038// will exit instead of just clearing input. Long enough to be
2039// deliberate (rules out accidental key chord), short enough that the
2040// hint stays meaningful.
2041const ctrlCExitWindow = 2 * time.Second
2042
2043// armCtrlCExit records the timestamp of the current ctrl+c so the next
2044// one within ctrlCExitWindow exits.
2045func (i *Interactive) armCtrlCExit() {
2046	i.mu.Lock()
2047	i.lastCtrlC = time.Now()
2048	i.mu.Unlock()
2049}
2050
2051// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
2052// that another press should now exit.
2053func (i *Interactive) ctrlCExitArmed() bool {
2054	i.mu.Lock()
2055	t := i.lastCtrlC
2056	i.mu.Unlock()
2057	return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
2058}
2059
2060// clearFileSuggestQuery strips the filter the user typed after the
2061// last "@", leaving the bare "@" so the picker stays open. Called when
2062// navigating between directory levels (Right/Left): the filter applied
2063// to the level the user was on, not the one being entered, so carrying
2064// it forward would wrongly hide the new directory's contents.
2065func (i *Interactive) clearFileSuggestQuery() {
2066	val := i.ed.Value()
2067	if idx := strings.LastIndex(val, "@"); idx >= 0 {
2068		i.ed.SetValue(val[:idx+1])
2069	}
2070}
2071
2072// setToolExpansion updates long tool result expansion and replays the
2073// transcript so already-emitted rows reflect the new state.
2074func (i *Interactive) setToolExpansion(expanded bool) {
2075	i.mu.Lock()
2076	i.view.ExpandAll = expanded
2077	if i.rend != nil {
2078		i.rend.Clear()
2079	}
2080	i.mu.Unlock()
2081	i.invalidate()
2082}
2083
2084func (i *Interactive) toggleToolExpansion() {
2085	i.mu.Lock()
2086	expanded := !i.view.ExpandAll
2087	i.mu.Unlock()
2088	i.setToolExpansion(expanded)
2089}
2090
2091func (i *Interactive) toggleBtwToolExpansion() {
2092	i.btwDialog.ToggleToolExpansion()
2093	i.mu.Lock()
2094	if i.rend != nil {
2095		i.rend.Clear()
2096	}
2097	i.mu.Unlock()
2098	i.invalidate()
2099}
2100
2101// confirmChildActive reports whether an interaction opened from slash input
2102// currently owns the keyboard while a tool confirmation remains pending.
2103func (i *Interactive) confirmChildActive() bool {
2104	return len(i.helpBlock) > 0 ||
2105		len(i.extNotes) > 0 ||
2106		i.dialog.Active() ||
2107		i.modelDialog.Active() ||
2108		i.llamaDialog.Active() ||
2109		i.rescueDialog.Active() ||
2110		i.sessionDialog.Active() ||
2111		i.swarmDialog.Active() ||
2112		i.jumpDialog.Active() ||
2113		i.btwDialog.Active() ||
2114		i.skillsDialog.Active() ||
2115		i.changelogDialog.Active() ||
2116		i.logoutDialog.Active() ||
2117		i.telegramDialog.Active() ||
2118		i.settingsDialog.Active() ||
2119		i.sessionOpsDialog.Active() ||
2120		i.sessionTreeDialog.Active() ||
2121		i.timeline.Active() ||
2122		i.extPanel.Active()
2123}
2124
2125// dialogOwnsInput reports whether a dialog, panel, or focused
2126// confirmation prompt currently consumes keys instead of the main editor.
2127func (i *Interactive) dialogOwnsInput() bool {
2128	return i.confirmDialog.Focused() ||
2129		i.dialog.Active() ||
2130		i.modelDialog.Active() ||
2131		i.llamaDialog.Active() ||
2132		i.rescueDialog.Active() ||
2133		i.sessionDialog.Active() ||
2134		i.swarmDialog.Active() ||
2135		i.jumpDialog.Active() ||
2136		i.btwDialog.Active() ||
2137		i.skillsDialog.Active() ||
2138		i.changelogDialog.Active() ||
2139		i.logoutDialog.Active() ||
2140		i.telegramDialog.Active() ||
2141		i.settingsDialog.Active() ||
2142		i.sessionOpsDialog.Active() ||
2143		i.sessionTreeDialog.Active() ||
2144		i.timeline.Active() ||
2145		i.extPanel.Active()
2146}
2147
2148// restoreConfirmFocus returns input to confirmation after slash input is
2149// cleared or a child interaction closes. A non-empty editor keeps command
2150// input focused, and an active child continues to own its keys.
2151func (i *Interactive) restoreConfirmFocus() {
2152	if !i.confirmDialog.Active() || i.confirmDialog.Focused() {
2153		return
2154	}
2155	if !i.ed.IsEmpty() || i.confirmChildActive() {
2156		return
2157	}
2158	i.confirmDialog.Focus()
2159	i.invalidate()
2160}
2161
2162func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2163	defer i.restoreConfirmFocus()
2164
2165	// Dialogs route keys before the main clipboard handler below. Resolve text
2166	// here when a child interaction owns input so every editor and filter sees
2167	// the same KeyPaste event as terminal-native bracketed paste. Main chat is
2168	// left image-first to preserve macOS clipboard image attachments.
2169	if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() {
2170		resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText)
2171		if err != nil {
2172			i.mu.Lock()
2173			i.statusErr = "clipboard paste failed: " + err.Error()
2174			i.statusOK = ""
2175			i.mu.Unlock()
2176			return false
2177		}
2178		if pastedText {
2179			k = resolved
2180			i.mu.Lock()
2181			i.statusErr = ""
2182			i.statusOK = ""
2183			i.mu.Unlock()
2184		}
2185	}
2186
2187	// Ctrl and Super chords reach us as modified runes so the keymap can
2188	// bind them on the main input. Dialogs key off bare letters ('r'
2189	// renames, 'q' closes, digits answer confirmations) and must not see
2190	// a chord as its letter, so neutralize it while one owns input.
2191	if isChordRune(k) && i.dialogOwnsInput() {
2192		k = tui.Key{Kind: tui.KeyUnknown}
2193	}
2194
2195	// Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
2196	// pressing ctrl+c then typing then ctrl+c much later doesn't quit
2197	// unexpectedly. The hint message also goes stale; clear it.
2198	if k.Kind != tui.KeyCtrlC {
2199		i.mu.Lock()
2200		if !i.lastCtrlC.IsZero() {
2201			i.lastCtrlC = time.Time{}
2202			if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
2203				i.statusOK = ""
2204			}
2205		}
2206		i.mu.Unlock()
2207	}
2208
2209	// Dialogs consume keys while open (except ctrl+c, which always closes them).
2210
2211	// Confirmation owns input by default. Pressing / deliberately moves
2212	// focus to the main editor without answering the pending request, so
2213	// slash commands and the dialogs they open can run first.
2214	if i.confirmDialog.Focused() {
2215		if k.Kind == tui.KeyRune && k.Rune == '/' {
2216			i.confirmDialog.Blur()
2217			i.ed.Insert("/")
2218			i.suggest.Reset()
2219			i.invalidate()
2220			return false
2221		}
2222		if k.Kind == tui.KeyCtrlO {
2223			if i.btwDialog != nil && i.btwDialog.Active() {
2224				i.toggleBtwToolExpansion()
2225			} else {
2226				i.toggleToolExpansion()
2227			}
2228			return false
2229		}
2230		i.confirmDialog.HandleKey(k)
2231		i.invalidate()
2232		return false
2233	}
2234	if i.confirmDialog.Active() && !i.confirmChildActive() && k.Kind == tui.KeyEsc {
2235		i.ed.Clear()
2236		i.suggest.Reset()
2237		i.fileSuggest.Reset()
2238		i.confirmDialog.Focus()
2239		i.invalidate()
2240		return false
2241	}
2242	if i.dialog.Active() {
2243		if k.Kind == tui.KeyCtrlC {
2244			i.dialog.Close()
2245			if i.cfg.AuthManager != nil {
2246				i.cfg.AuthManager.CancelOAuth()
2247			}
2248			return false
2249		}
2250		act := i.dialog.HandleKey(k)
2251		if act.StartAPIKey {
2252			i.startAPIKeyFlow(act.Provider)
2253		}
2254		if act.StartOAuth {
2255			i.startOAuthFlow(act.Provider)
2256		}
2257		if act.StartManual {
2258			i.startManualOAuthFlow(act.Provider)
2259		}
2260		if act.SubmitCode != "" {
2261			i.submitManualOAuthCode(act.SubmitCode)
2262		}
2263		if act.SaveLlama {
2264			if act.Provider == provider.LMStudioProviderID {
2265				i.saveLMStudioLogin(act.LlamaURL, act.LlamaAPIKey)
2266			} else {
2267				i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey)
2268			}
2269		}
2270		return false
2271	}
2272	if i.modelDialog.Active() {
2273		if k.Kind == tui.KeyCtrlC {
2274			i.modelDialog.Close()
2275			i.quickModelAssign = 0
2276			return false
2277		}
2278		act := i.modelDialog.HandleKey(k)
2279		if act.Close {
2280			i.quickModelAssign = 0
2281		}
2282		if act.Select {
2283			if i.quickModelAssign > 0 {
2284				i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model)
2285				i.quickModelAssign = 0
2286			} else {
2287				i.applyModelSelection(act.Provider, act.Model)
2288			}
2289		}
2290		return false
2291	}
2292	if i.llamaDialog.Active() {
2293		i.llamaDialog.HandleKey(k)
2294		i.invalidate()
2295		return false
2296	}
2297	if i.rescueDialog.Active() {
2298		if k.Kind == tui.KeyCtrlC {
2299			i.rescueDialog.Close()
2300			i.invalidate()
2301			return false
2302		}
2303		act := i.rescueDialog.HandleKey(k)
2304		if act.Select {
2305			i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
2306		}
2307		i.invalidate()
2308		return false
2309	}
2310	if i.sessionDialog.Active() {
2311		if k.Kind == tui.KeyCtrlC {
2312			i.sessionDialog.Close()
2313			return false
2314		}
2315		act := i.sessionDialog.HandleKey(k)
2316		if act.Select {
2317			i.applySessionSelection(act.Path)
2318		}
2319		if act.Delete {
2320			i.applySessionDeletion(act.Path)
2321		}
2322		// Always request a redraw after handling a key here: when esc
2323		// closes the picker, the overlay-close detection in the render
2324		// pass needs to run so the tall dialog rows get repainted from
2325		// the chat (otherwise VS Code's retained scrollback leaves a
2326		// duplicate frame on screen).
2327		i.invalidate()
2328		return false
2329	}
2330	if i.swarmDialog.Active() {
2331		if k.Kind == tui.KeyCtrlC {
2332			i.swarmDialog.Close()
2333			i.invalidate()
2334			return false
2335		}
2336		_, msg, errMsg := i.swarmDialog.HandleKey(k)
2337		if msg != "" || errMsg != "" {
2338			i.mu.Lock()
2339			i.statusOK = msg
2340			i.statusErr = errMsg
2341			i.mu.Unlock()
2342		}
2343		i.invalidate()
2344		return false
2345	}
2346	if i.logoutDialog.Active() {
2347		if k.Kind == tui.KeyCtrlC {
2348			i.logoutDialog.Close()
2349			i.invalidate()
2350			return false
2351		}
2352		act := i.logoutDialog.HandleKey(k)
2353		if act.Select {
2354			i.doLogout(act.Target)
2355		}
2356		i.invalidate()
2357		return false
2358	}
2359	if i.telegramDialog.Active() {
2360		if k.Kind == tui.KeyCtrlC {
2361			i.telegramDialog.Close()
2362			i.invalidate()
2363			return false
2364		}
2365		act := i.telegramDialog.HandleKey(k)
2366		if act.Select {
2367			i.doTelegram(act.Action)
2368		}
2369		i.invalidate()
2370		return false
2371	}
2372	if i.settingsDialog.Active() {
2373		if k.Kind == tui.KeyCtrlC {
2374			i.settingsDialog.Close()
2375			i.invalidate()
2376			return false
2377		}
2378		act := i.settingsDialog.HandleKey(k)
2379		if act.ModelShortcutSlot > 0 {
2380			i.openQuickModelPicker(act.ModelShortcutSlot)
2381		}
2382		if act.Toggle {
2383			i.applySettingChange(act)
2384		}
2385		i.invalidate()
2386		return false
2387	}
2388	if i.sessionOpsDialog.Active() {
2389		if k.Kind == tui.KeyCtrlC {
2390			i.sessionOpsDialog.Close()
2391			i.invalidate()
2392			return false
2393		}
2394		act := i.sessionOpsDialog.HandleKey(k)
2395		if act.Select {
2396			i.doSessionOp(act.Action, "")
2397		}
2398		i.invalidate()
2399		return false
2400	}
2401	if i.sessionTreeDialog.Active() {
2402		if k.Kind == tui.KeyCtrlC {
2403			i.sessionTreeDialog.Close()
2404			i.invalidate()
2405			return false
2406		}
2407		act := i.sessionTreeDialog.HandleKey(k)
2408		if act.Select {
2409			i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
2410		}
2411		i.invalidate()
2412		return false
2413	}
2414	if i.extPanel.Active() {
2415		if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
2416			if i.cfg.Extensions != nil {
2417				_ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
2418			}
2419			i.extPanel.Close()
2420			i.invalidate()
2421			return false
2422		}
2423		if i.cfg.Extensions != nil {
2424			_ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
2425		}
2426		return false
2427	}
2428	if i.timeline.Active() {
2429		data := i.timelineData()
2430		act := i.timeline.HandleKey(k, data)
2431		if act.Export {
2432			path, err := i.timeline.Export("", data)
2433			if err != nil {
2434				i.timeline.SetNotice("export failed: " + err.Error())
2435			} else {
2436				i.timeline.SetNotice("exported to " + friendlyPath(path))
2437			}
2438		}
2439		i.mu.Lock()
2440		i.chatCacheValid = false
2441		i.scrollOffset = 0
2442		i.mu.Unlock()
2443		i.invalidate()
2444		return false
2445	}
2446	if i.jumpDialog.Active() {
2447		if k.Kind == tui.KeyCtrlC {
2448			i.jumpDialog.Close()
2449			i.pendingFork = false
2450			return false
2451		}
2452		act := i.jumpDialog.HandleKey(k)
2453		if act.Select {
2454			if i.pendingFork {
2455				i.applyForkSelection(act.MessageIdx)
2456			} else {
2457				i.applyJumpSelection(act.MessageIdx, act.TurnNo)
2458			}
2459		}
2460		// If the user dismissed the dialog without selecting, also
2461		// clear the pending-fork flag so a later plain /jump isn't
2462		// hijacked.
2463		if act.Close {
2464			i.pendingFork = false
2465		}
2466		return false
2467	}
2468	if i.btwDialog.Active() {
2469		if k.Kind == tui.KeyCtrlC {
2470			i.btwDialog.Close()
2471			i.invalidate()
2472			return false
2473		}
2474		if k.Kind == tui.KeyCtrlO {
2475			i.toggleBtwToolExpansion()
2476			return false
2477		}
2478		i.btwDialog.HandleKey(k, i.invalidate)
2479		return false
2480	}
2481	if i.skillsDialog.Active() {
2482		if k.Kind == tui.KeyCtrlC {
2483			i.skillsDialog.Close()
2484			i.invalidate()
2485			return false
2486		}
2487		i.skillsDialog.HandleKey(k)
2488		i.invalidate()
2489		return false
2490	}
2491	if i.changelogDialog.Active() {
2492		if closed := i.changelogDialog.HandleKey(k); closed {
2493			// User dismissed; let the parent persist the
2494			// LastChangelogShown marker via the close callback.
2495			if i.cfg.OnChangelogDismiss != nil {
2496				i.cfg.OnChangelogDismiss()
2497			}
2498		}
2499		i.invalidate()
2500		return false
2501	}
2502
2503	// Configured shortcuts only apply to the main input. Every dialog
2504	// above has already consumed its keys, and the confirmation prompt
2505	// keeps focus until the user explicitly moves it with '/'.
2506	if !keymapReservedKey(k) {
2507		if command := lookupKeymap(i.keymap, k); command != "" {
2508			parts := strings.Fields(command)
2509			if len(parts) > 0 && slashCancelsTurn(parts[0]) {
2510				i.cancelAndWaitForIdle()
2511			}
2512			i.ed.Clear()
2513			i.suggest.Reset()
2514			i.fileSuggest.Reset()
2515			return i.runSlash(ctx, command)
2516		}
2517	}
2518
2519	if slot := quickModelShortcutSlot(k); slot > 0 {
2520		i.applyQuickModelShortcut(slot)
2521		return false
2522	}
2523
2524	// Global keys.
2525	switch k.Kind {
2526	case tui.KeyCtrlC:
2527		i.mu.Lock()
2528		loadingSession := i.sessionLoading
2529		i.mu.Unlock()
2530		if loadingSession {
2531			return true
2532		}
2533		// While busy: do NOT cancel the turn. ctrl+c during a
2534		// running turn is almost always reflex muscle memory
2535		// ("be quiet" in a shell) rather than a deliberate
2536		// decision to kill a multi-minute model call that's
2537		// already cost tokens. Use esc to interrupt a turn; use
2538		// a deliberate double-ctrl+c to exit zot entirely. First
2539		// press arms the exit hint, second press within
2540		// ctrlCExitWindow quits.
2541		if i.busy {
2542			if i.ctrlCExitArmed() {
2543				return true
2544			}
2545			i.mu.Lock()
2546			i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
2547			i.statusErr = ""
2548			i.mu.Unlock()
2549			i.armCtrlCExit()
2550			return false
2551		}
2552		// Idle: first press clears the editor (and any queued
2553		// follow-up messages); a second press within ctrlCExitWindow
2554		// exits. With both an empty editor and no queue the first
2555		// press still just arms — require a deliberate double-tap.
2556		ag := i.agent
2557		pending := 0
2558		if ag != nil {
2559			pending = ag.QueuedMessageCount()
2560		}
2561		hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
2562		if hadInput {
2563			i.ed.Clear()
2564			i.clipboardImages = nil
2565			i.suggest.Reset()
2566			if ag != nil {
2567				ag.DrainQueuedMessages()
2568			}
2569			i.mu.Lock()
2570			i.queued = nil
2571			i.statusOK = "input cleared"
2572			i.statusErr = ""
2573			i.mu.Unlock()
2574			i.armCtrlCExit()
2575			return false
2576		}
2577		if i.ctrlCExitArmed() {
2578			return true
2579		}
2580		i.mu.Lock()
2581		i.statusOK = "press ctrl+c again to exit"
2582		i.statusErr = ""
2583		i.mu.Unlock()
2584		i.armCtrlCExit()
2585		return false
2586	case tui.KeyEsc:
2587		// Esc interrupts a running turn — but only when nothing
2588		// else on screen wants to consume the key first. The slash
2589		// popup has its own Esc behaviour (close + clear editor),
2590		// and transient overlays like the /help block and extension
2591		// notes should dismiss on Esc before we even consider the
2592		// turn. Without these guards, a casual Esc press after
2593		// running /help on a busy turn rips the turn away.
2594		if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
2595			break
2596		}
2597		i.mu.Lock()
2598		hadHelp := len(i.helpBlock) > 0
2599		hadNotes := len(i.extNotes) > 0
2600		if hadHelp {
2601			i.helpBlock = nil
2602		}
2603		if hadNotes {
2604			i.extNotes = nil
2605		}
2606		i.mu.Unlock()
2607		if hadHelp || hadNotes {
2608			i.invalidate()
2609			return false
2610		}
2611		if i.busy && i.cancelTurn != nil {
2612			i.cancelTurn()
2613			// If a confirm dialog is pending, refuse it so the agent
2614			// goroutine unblocks and the context cancellation can
2615			// actually take effect.
2616			i.confirmDialog.CancelAll("turn cancelled")
2617			return false
2618		}
2619	case tui.KeyCtrlD:
2620		if i.ed.IsEmpty() && !i.busy {
2621			return true
2622		}
2623	case tui.KeyCtrlL:
2624		i.rend.Clear()
2625		i.invalidate()
2626		return false
2627	case tui.KeyPasteClipboard:
2628		i.pasteClipboard()
2629		return false
2630	case tui.KeyCtrlO:
2631		i.toggleToolExpansion()
2632		return false
2633	case tui.KeyPageUp:
2634		i.scrollBy(+i.chatPage())
2635		return false
2636	case tui.KeyPageDown:
2637		i.scrollBy(-i.chatPage())
2638		return false
2639	case tui.KeyUp:
2640		// Alt/Option+Up: pop the most recently queued ("sliding in")
2641		// message back into the editor so the user can edit and
2642		// resend it. Repeated presses keep peeling messages off the
2643		// tail of the queue; each press *replaces* the editor
2644		// contents (we don't append/push). When the queue is empty
2645		// the keypress falls through to the normal scroll behavior.
2646		if k.Alt {
2647			i.mu.Lock()
2648			var text string
2649			if i.agent != nil {
2650				text, _ = i.agent.PopQueuedMessage()
2651			}
2652			if text == "" {
2653				if n := len(i.queued); n > 0 {
2654					text = i.queued[n-1]
2655					i.queued = i.queued[:n-1]
2656				}
2657			}
2658			i.mu.Unlock()
2659			if text != "" {
2660				i.ed.SetValue(text)
2661				i.inputHistoryIndex = -1
2662				i.invalidate()
2663				return false
2664			}
2665		}
2666		// In multi-line / wrapped input, Up first moves inside the editor.
2667		// At the editor's top edge, plain Up can browse input history when
2668		// history browsing is safe/active; otherwise it falls back to chat
2669		// scrolling, preserving the old single-line scroll behavior.
2670		if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2671			if i.ed.MoveVertical(-1) {
2672				i.invalidate()
2673				return false
2674			}
2675			if !k.Alt && i.handleInputHistoryKey(k) {
2676				return false
2677			}
2678			i.scrollBy(+3)
2679			return false
2680		}
2681	case tui.KeyDown:
2682		if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2683			if i.ed.MoveVertical(+1) {
2684				i.invalidate()
2685				return false
2686			}
2687			if !k.Alt && i.handleInputHistoryKey(k) {
2688				return false
2689			}
2690			if i.scrollOffset > 0 {
2691				i.scrollBy(-3)
2692			}
2693			return false
2694		}
2695	}
2696
2697	// Note: we intentionally do NOT gate the editor on i.busy here.
2698	// Typing while the agent is working is supported — submitted
2699	// messages are queued and delivered as follow-up turns when the
2700	// current turn ends. See the submit handler below.
2701
2702	if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) {
2703		i.ed.HandleKey(k)
2704		return false
2705	}
2706
2707	// Slash suggestions: intercept up/down/tab/enter when the popup is visible.
2708	if i.suggest.Active(i.ed.Value()) {
2709		switch k.Kind {
2710		case tui.KeyUp:
2711			i.suggest.Up()
2712			return false
2713		case tui.KeyDown:
2714			i.suggest.Down()
2715			return false
2716		case tui.KeyPageUp:
2717			i.suggest.PageUp()
2718			return false
2719		case tui.KeyPageDown:
2720			i.suggest.PageDown()
2721			return false
2722		case tui.KeyTab:
2723			if name := i.suggest.Selection(i.ed.Value()); name != "" {
2724				i.ed.SetValue(name)
2725				i.suggest.Reset()
2726			}
2727			return false
2728		case tui.KeyEnter:
2729			// Enter on an ambiguous or partial slash prefix: complete to the
2730			// currently highlighted command and run it. That way typing
2731			// "/lo" + enter picks whichever of /login or /logout is selected
2732			// in the popup instead of submitting "/lo" as unknown. Also
2733			// clear the editor so the command doesn't linger after the
2734			// dialog opens/closes.
2735			if name := i.suggest.Selection(i.ed.Value()); name != "" {
2736				i.ed.Clear()
2737				i.suggest.Reset()
2738				return i.runSlash(ctx, name)
2739			}
2740		case tui.KeyEsc:
2741			i.ed.Clear()
2742			i.suggest.Reset()
2743			return false
2744		}
2745	}
2746
2747	// File suggestions: intercept up/down/tab/enter when the @-popup is visible.
2748	if i.fileSuggest.Active(i.ed.Value()) {
2749		switch k.Kind {
2750		case tui.KeyUp:
2751			i.fileSuggest.Up()
2752			return false
2753		case tui.KeyDown:
2754			i.fileSuggest.Down()
2755			return false
2756		case tui.KeyRight:
2757			// Open selected directory. The filter the user typed picked
2758			// that directory at the current level; once we descend it no
2759			// longer applies to the directory's contents, so clear it.
2760			// Otherwise typing "@eda" then right would re-filter inside
2761			// eda/ by "eda" and show nothing.
2762			if i.fileSuggest.Right() {
2763				i.clearFileSuggestQuery()
2764			}
2765			return false
2766		case tui.KeyLeft:
2767			// Go back to parent directory. Clear the filter for the same
2768			// reason as Right: it was scoped to the level we just left.
2769			if i.fileSuggest.Left() {
2770				i.clearFileSuggestQuery()
2771			}
2772			return false
2773		case tui.KeyEnter:
2774			if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
2775				var chip string
2776				if entry.isDir {
2777					chip = "[dir:" + entry.rel + "/]"
2778				} else {
2779					chip = "[file:" + entry.rel + "]"
2780				}
2781				val := i.ed.Value()
2782				if idx := strings.LastIndex(val, "@"); idx >= 0 {
2783					val = val[:idx]
2784				}
2785				i.ed.SetValue(val + chip + " ")
2786				i.fileSuggest.Reset()
2787			}
2788			return false
2789		case tui.KeyEsc:
2790			val := i.ed.Value()
2791			if idx := strings.LastIndex(val, "@"); idx >= 0 {
2792				i.ed.SetValue(val[:idx])
2793			}
2794			i.fileSuggest.Reset()
2795			return false
2796		}
2797	}
2798
2799	// Tab-complete a path token in the editor when no popup is open.
2800	// Recognises tokens that look like paths (start with ~, /, ./, ../
2801	// or contain a slash); shell-style completion expands ~, lists the
2802	// parent dir, and completes the basename to the longest common
2803	// prefix. Single match: full replace and trailing / for dirs.
2804	// No match: no-op. Plain bare words (no slash, no tilde) fall
2805	// through so Tab keeps its current no-op behaviour outside paths.
2806	if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2807		if i.tryPathTabComplete() {
2808			return false
2809		}
2810	}
2811
2812	if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
2813		i.inputHistoryIndex = -1
2814	}
2815
2816	if k.Kind == tui.KeyEsc {
2817		i.clipboardImages = nil
2818	}
2819	if submit := i.ed.HandleKey(k); submit {
2820		// SubmitValue() expands any [pasted text #N +L lines]
2821		// placeholders back into their bodies; the raw Value()
2822		// is only what the user sees on screen.
2823		text := strings.TrimRight(i.ed.SubmitValue(), "\n")
2824		// Expand [file:name] and [dir:name/] chips to full paths.
2825		text = expandFileChips(text, i.cfg.CWD)
2826		text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
2827		if text == "" && len(images) == 0 {
2828			return false
2829		}
2830		i.clipboardImages = nil
2831		i.ed.Clear()
2832		i.inputHistoryIndex = -1
2833		i.suggest.Reset()
2834		i.fileSuggest.Reset()
2835
2836		if cmd, ok := shellEscapeCommand(text); ok {
2837			i.startShellEscape(ctx, cmd)
2838			return false
2839		}
2840
2841		if looksLikeSlashCommand(text) {
2842			head := text
2843			rest := ""
2844			if idx := strings.IndexAny(text, " \t"); idx >= 0 {
2845				head = text[:idx]
2846				rest = strings.TrimSpace(text[idx:])
2847			}
2848			if !isKnownSlashCommand(text) {
2849				// Try extensions before giving up. Extensions register
2850				// commands by bare name (no leading slash); strip it here.
2851				extName := strings.TrimPrefix(head, "/")
2852				if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
2853					go i.invokeExtensionCommand(ctx, extName, rest)
2854					return false
2855				}
2856				i.mu.Lock()
2857				i.statusErr = "unknown command " + head + " — type /help to see the list"
2858				i.statusOK = ""
2859				i.mu.Unlock()
2860				return false
2861			}
2862			// Slash commands run regardless of busy state. Commands that
2863			// would mutate the transcript or replace the agent (/clear,
2864			// /compact, /logout, /login, /model) cancel the active turn
2865			// first and wait for the goroutine to wind down so they don't
2866			// race with a streaming response. Safe commands (/help,
2867			// /jump, /sessions, /jail, /unjail, /exit) run immediately
2868			// without disturbing the active turn.
2869			if slashCancelsTurn(head) {
2870				i.cancelAndWaitForIdle()
2871			}
2872			return i.runSlash(ctx, text)
2873		}
2874
2875		if i.agent == nil {
2876			i.mu.Lock()
2877			i.statusErr = "not logged in. type /login first."
2878			i.mu.Unlock()
2879			return false
2880		}
2881		// Mirror the user's typed prompt into the paired Telegram
2882		// chat (when the bridge is active) so the Telegram thread
2883		// stays a complete record of the session, not just the half
2884		// that originated on the phone. On a goroutine so the
2885		// network write doesn't delay the local turn.
2886		if i.telegramBridge != nil && i.telegramBridge.Active() {
2887			go i.telegramBridge.OnUserTyped(text)
2888		}
2889		// If a turn is already in flight, queue this prompt inside the
2890		// agent loop so it is delivered at the next safe model-call
2891		// boundary instead of waiting for the whole run to finish.
2892		i.mu.Lock()
2893		busy := i.busy
2894		compacting := i.compacting
2895		ag := i.agent
2896		i.mu.Unlock()
2897		if busy {
2898			if len(images) > 0 {
2899				i.mu.Lock()
2900				i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
2901				i.mu.Unlock()
2902				i.invalidate()
2903				return false
2904			}
2905			if ag != nil && !compacting {
2906				ag.QueueMessage(text)
2907			} else {
2908				i.mu.Lock()
2909				i.queued = append(i.queued, text)
2910				i.mu.Unlock()
2911			}
2912			i.invalidate()
2913			return false
2914		}
2915		i.startTurnWithImages(ctx, text, images)
2916	}
2917	return false
2918}
2919
2920func (i *Interactive) pasteClipboard() {
2921	data, ok, err := readClipboardImageAndCleanup(tui.ReadClipboardImagePNG)
2922	if err != nil {
2923		i.mu.Lock()
2924		i.statusErr = "clipboard paste failed: " + err.Error()
2925		i.statusOK = ""
2926		i.mu.Unlock()
2927		return
2928	}
2929	if ok {
2930		i.mu.Lock()
2931		marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
2932		i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
2933			Marker: marker,
2934			Image:  provider.ImageBlock{MimeType: "image/png", Data: data},
2935		})
2936		i.ed.Insert(marker + " ")
2937		i.statusOK = ""
2938		i.statusErr = ""
2939		i.mu.Unlock()
2940		return
2941	}
2942	key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText)
2943	i.mu.Lock()
2944	defer i.mu.Unlock()
2945	if err != nil {
2946		i.statusErr = "clipboard paste failed: " + err.Error()
2947		i.statusOK = ""
2948		return
2949	}
2950	if pastedText {
2951		i.ed.HandleKey(key)
2952		i.statusErr = ""
2953		i.statusOK = ""
2954		return
2955	}
2956	i.statusErr = "clipboard does not contain text or an image"
2957	i.statusOK = ""
2958}
2959
2960func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
2961	if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
2962		return false
2963	}
2964	// Do not steal normal vertical cursor movement. History browsing can only
2965	// start from an empty editor; once active, Up/Down keep walking
2966	// the ring so repeated presses work even though the editor now
2967	// contains the selected historical prompt.
2968	if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
2969		return false
2970	}
2971	hist := i.inputHistory()
2972	if len(hist) == 0 {
2973		return false
2974	}
2975
2976	if i.inputHistoryIndex < 0 {
2977		// Start just after the newest item so Up lands on the most
2978		// recent user prompt. A lone Down from an empty editor is not
2979		// history navigation; let the caller fall through to normal UI
2980		// behavior instead.
2981		if k.Kind != tui.KeyUp {
2982			return false
2983		}
2984		i.inputHistoryIndex = len(hist)
2985	}
2986
2987	switch k.Kind {
2988	case tui.KeyUp:
2989		if i.inputHistoryIndex > 0 {
2990			i.inputHistoryIndex--
2991		}
2992	case tui.KeyDown:
2993		if i.inputHistoryIndex < len(hist) {
2994			i.inputHistoryIndex++
2995		}
2996	}
2997
2998	if i.inputHistoryIndex >= len(hist) {
2999		i.ed.Clear()
3000	} else {
3001		i.ed.SetValue(hist[i.inputHistoryIndex])
3002	}
3003	return true
3004}
3005
3006func (i *Interactive) inputHistory() []string {
3007	if i.agent == nil {
3008		return nil
3009	}
3010	msgs := i.agent.Messages()
3011	hist := make([]string, 0, len(msgs))
3012	for _, m := range msgs {
3013		if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) {
3014			continue
3015		}
3016		text := userMessageText(m)
3017		if m.Meta[shellEscapeMetaKey] == "true" {
3018			// Older sessions lack command metadata. Never recall shell output
3019			// as input or try to recover a command from the terminal log.
3020			cmd := m.Meta[shellEscapeCommandMetaKey]
3021			if strings.TrimSpace(cmd) == "" {
3022				continue
3023			}
3024			text = "!" + cmd
3025		}
3026		if strings.TrimSpace(text) == "" {
3027			continue
3028		}
3029		hist = append(hist, text)
3030	}
3031	return hist
3032}
3033
3034func userMessageText(m provider.Message) string {
3035	var sb strings.Builder
3036	for _, c := range m.Content {
3037		if tb, ok := c.(provider.TextBlock); ok {
3038			if sb.Len() > 0 {
3039				sb.WriteByte('\n')
3040			}
3041			sb.WriteString(tb.Text)
3042		}
3043	}
3044	return sb.String()
3045}
3046
3047// invokeExtensionCommand fires an extension-registered slash command
3048// in a background goroutine, awaits the response, and applies the
3049// requested action (prompt / insert / display / noop). Errors and
3050// timeouts surface as a status_err line.
3051
3052func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
3053	resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
3054	if err != nil {
3055		i.mu.Lock()
3056		i.statusErr = "extension /" + name + ": " + err.Error()
3057		i.statusOK = ""
3058		i.mu.Unlock()
3059		i.invalidate()
3060		return
3061	}
3062	if resp.Error != "" {
3063		i.mu.Lock()
3064		i.statusErr = "extension /" + name + ": " + resp.Error
3065		i.statusOK = ""
3066		i.mu.Unlock()
3067		i.invalidate()
3068		return
3069	}
3070	switch resp.Action {
3071	case "open_panel":
3072		if resp.OpenPanel != nil {
3073			extName := name
3074			if i.cfg.Extensions != nil {
3075				if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
3076					extName = owner
3077				}
3078			}
3079			i.OpenPanel(extName, *resp.OpenPanel)
3080		}
3081	case "prompt":
3082		if strings.TrimSpace(resp.Prompt) == "" {
3083			return
3084		}
3085		i.startTurn(i.runCtx, resp.Prompt)
3086	case "insert":
3087		i.ed.Insert(resp.Insert)
3088		i.invalidate()
3089	case "display":
3090		i.appendExtensionNote(name, resp.Display, "info")
3091	case "noop", "":
3092		// nothing
3093	default:
3094		i.mu.Lock()
3095		i.statusErr = "extension /" + name + ": unknown action " + resp.Action
3096		i.mu.Unlock()
3097		i.invalidate()
3098	}
3099}
3100
3101// appendExtensionNote renders an extension-originated note in the
3102// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
3103// "success" (tool/ok green).
3104func (i *Interactive) appendExtensionNote(extName, msg, level string) {
3105	if msg == "" {
3106		return
3107	}
3108	i.mu.Lock()
3109	defer i.mu.Unlock()
3110	color := i.cfg.Theme.Muted
3111	switch level {
3112	case "warn":
3113		color = i.cfg.Theme.Warning
3114	case "error":
3115		color = i.cfg.Theme.Error
3116	case "success":
3117		color = i.cfg.Theme.Tool
3118	}
3119	prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
3120	for _, line := range strings.Split(msg, "\n") {
3121		i.statusOK = "" // clear any stale ok
3122		i.statusErr = ""
3123		i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
3124	}
3125	// Extension slash commands complete asynchronously. If their result is
3126	// displayed while confirmation is waiting, keep Esc assigned to the note
3127	// until it is dismissed rather than letting it answer the tool call.
3128	i.confirmDialog.Blur()
3129}
3130
3131// HostHooks implementation for the extension manager. The manager
3132// holds an interface, not a concrete *Interactive, so these methods
3133// are the only thing the manager sees.
3134
3135// Notify is the manager's NotifyFromExt entry point.
3136func (i *Interactive) Notify(extName, level, message string) {
3137	i.appendExtensionNote(extName, message, level)
3138	i.invalidate()
3139}
3140
3141// ClearNotes removes every note line owned by extName from the
3142// bottom-sticky ext-notes block. Extensions use this to retract a
3143// transient status line (e.g. an approval prompt) once it no longer
3144// applies, instead of leaving it stacked forever. Notes from other
3145// extensions and internal notes (auto-compact) are left untouched.
3146func (i *Interactive) ClearNotes(extName string) {
3147	marker := "[" + extName + "] "
3148	i.mu.Lock()
3149	if len(i.extNotes) == 0 {
3150		i.mu.Unlock()
3151		return
3152	}
3153	kept := i.extNotes[:0:0]
3154	changed := false
3155	for _, line := range i.extNotes {
3156		if strings.Contains(line, marker) {
3157			changed = true
3158			continue
3159		}
3160		kept = append(kept, line)
3161	}
3162	if changed {
3163		i.extNotes = kept
3164	}
3165	i.mu.Unlock()
3166	if changed {
3167		i.invalidate()
3168	}
3169}
3170
3171// Submit feeds text through the agent loop as if the user had typed it.
3172func (i *Interactive) Submit(text string) {
3173	if cmd, ok := shellEscapeCommand(text); ok {
3174		i.startShellEscape(i.runCtx, cmd)
3175		return
3176	}
3177	i.startTurn(i.runCtx, text)
3178}
3179
3180// completeStartupPre applies deferred InitialInput after entry.pre finishes.
3181// When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise
3182// it only pre-fills the editor (CLI-supplied prompts).
3183func (i *Interactive) completeStartupPre() {
3184	i.mu.Lock()
3185	if !i.awaitingStartupPre {
3186		i.mu.Unlock()
3187		return
3188	}
3189	i.awaitingStartupPre = false
3190	deferred := i.deferredInitialInput
3191	auto := i.autoSubmitDeferred
3192	i.deferredInitialInput = ""
3193	i.autoSubmitDeferred = false
3194	onDone := i.cfg.OnStartupPreDone
3195	i.mu.Unlock()
3196	if onDone != nil {
3197		onDone()
3198	}
3199	i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto}
3200	i.invalidate()
3201}
3202
3203// applyStartupPreResult runs on the TUI event loop so the editor remains
3204// single-threaded. Input entered while resources were reloading wins over the
3205// deferred prefill rather than being overwritten.
3206func (i *Interactive) applyStartupPreResult(result startupPreResult) {
3207	if result.deferred == "" {
3208		return
3209	}
3210	if result.autoSubmit {
3211		i.Submit(result.deferred)
3212		return
3213	}
3214	if i.ed.IsEmpty() {
3215		i.ed.SetValue(result.deferred)
3216	}
3217}
3218
3219// ApplyChangedCWD is called by hosts after a successful /cd hook that do
3220// not provide startup context metadata.
3221func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
3222	i.applyChangedCWD(ag, provider, model, cwd, nil)
3223}
3224
3225// ApplyChangedCWDWithStartupContext swaps a rebuilt agent and its display-only
3226// startup context into the running TUI after a successful /cd hook.
3227func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
3228	i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths)
3229}
3230
3231func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
3232	i.mu.Lock()
3233	i.agent = ag
3234	i.cfg.CWD = cwd
3235	i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
3236	i.view.StartupContextPaths = nil
3237	if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
3238		i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
3239	}
3240	// Re-report the working directory to the terminal so "new tab here"
3241	// tracks the /cd change (OSC 7, see issue #38).
3242	if i.cfg.Terminal != nil {
3243		if seq := tui.ReportCWD(cwd); seq != "" {
3244			_, _ = i.cfg.Terminal.Write([]byte(seq))
3245		}
3246	}
3247	i.cfg.Provider = provider
3248	i.cfg.Model = model
3249	i.toolCalls = map[string]*tui.ToolCallView{}
3250	i.toolOrder = nil
3251	i.toolGate = map[string]int{}
3252	i.helpBlock = nil
3253	i.parkedTurn = 0
3254	i.statusErr = ""
3255	i.mu.Unlock()
3256	i.fileSuggest.Reset()
3257	i.fileSuggest.SetCWD(cwd)
3258	i.invalidate()
3259}
3260
3261// SubmitSlash runs text as a slash command in the TUI as if the user
3262// had typed it. text must start with '/' — callers that hand it
3263// plain prose silently get a no-op so a misbehaving extension can't
3264// run a stray prompt through this path. Read-only commands run in
3265// place; commands that would mutate the transcript or replace the
3266// agent cancel the active turn first via the same path the editor
3267// uses for typed slash commands.
3268func (i *Interactive) SubmitSlash(text string) {
3269	text = strings.TrimSpace(text)
3270	if !strings.HasPrefix(text, "/") {
3271		return
3272	}
3273	head := text
3274	if idx := strings.IndexAny(text, " \t"); idx >= 0 {
3275		head = text[:idx]
3276	}
3277	if slashCancelsTurn(head) {
3278		i.cancelAndWaitForIdle()
3279	}
3280	i.runSlash(i.runCtx, text)
3281	i.invalidate()
3282}
3283
3284// SubmitOrQueue runs text immediately if the agent is idle, or
3285// appends it to the pending queue if a turn is already in flight.
3286// Used by the telegram bridge (and by the editor submit path) so
3287// both input sources share the same "queue behind an active turn"
3288// semantics. Images are ignored for now — only the text prompt is
3289// forwarded — because the queued-prompt path is text-only; a
3290// follow-up can expand the queue entry to carry images.
3291func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
3292	if cmd, ok := shellEscapeCommand(text); ok {
3293		i.startShellEscape(i.runCtx, cmd)
3294		return
3295	}
3296	i.mu.Lock()
3297	if i.agent == nil {
3298		i.statusErr = "not logged in. type /login first."
3299		i.mu.Unlock()
3300		i.invalidate()
3301		return
3302	}
3303	if i.busy {
3304		// Queue text only; images are dropped for queued prompts. Compaction
3305		// uses the host queue because it has no active agent loop to drain
3306		// Agent.QueueMessage entries.
3307		ag := i.agent
3308		compacting := i.compacting
3309		i.mu.Unlock()
3310		if ag != nil && !compacting {
3311			ag.QueueMessage(text)
3312		} else {
3313			i.mu.Lock()
3314			i.queued = append(i.queued, text)
3315			i.mu.Unlock()
3316		}
3317		i.invalidate()
3318		return
3319	}
3320	i.mu.Unlock()
3321	i.startTurnWithImages(i.runCtx, text, images)
3322}
3323
3324// CancelTurn aborts the active turn if one is running. Used by the
3325// telegram bridge when the paired user sends /stop.
3326// ChangelogVersion returns the version string of the changelog
3327// currently shown (or last shown). Used by the dismiss callback
3328// to store the correct version for dev builds.
3329func (i *Interactive) ChangelogVersion() string {
3330	if i.changelogDialog != nil {
3331		return i.changelogDialog.version
3332	}
3333	return ""
3334}
3335
3336func (i *Interactive) CancelTurn() {
3337	i.mu.Lock()
3338	cancel := i.cancelTurn
3339	i.mu.Unlock()
3340	if cancel != nil {
3341		cancel()
3342		i.confirmDialog.CancelAll("turn cancelled")
3343	}
3344}
3345
3346// Insert places text at the cursor in the editor.
3347func (i *Interactive) Insert(text string) {
3348	i.ed.Insert(text)
3349	i.invalidate()
3350}
3351
3352// Display appends a styled note from extName to the chat without a
3353// model call.
3354func (i *Interactive) Display(extName, text string) {
3355	i.appendExtensionNote(extName, text, "info")
3356	i.invalidate()
3357}
3358
3359func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
3360	i.mu.Lock()
3361	defer i.mu.Unlock()
3362	i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
3363	i.confirmDialog.Blur()
3364	if i.cfg.Extensions != nil {
3365		cols, rows := i.cfg.Terminal.Size()
3366		_ = cols
3367		_ = rows
3368	}
3369	i.invalidate()
3370}
3371
3372func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
3373	i.mu.Lock()
3374	defer i.mu.Unlock()
3375	if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
3376		i.extPanel.Update(title, lines, footer)
3377		i.invalidate()
3378	}
3379}
3380
3381func (i *Interactive) ClosePanel(extName, panelID string) {
3382	i.mu.Lock()
3383	defer i.mu.Unlock()
3384	if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
3385		i.extPanel.Close()
3386		i.invalidate()
3387	}
3388}
3389
3390func effectiveImageProtocol(override *bool) tui.ImageProtocol {
3391	detected := tui.DetectImageProtocol()
3392	if override == nil {
3393		return detected
3394	}
3395	if !*override {
3396		return tui.ImageProtocolNone
3397	}
3398	return detected
3399}
3400
3401func imageProtocolName(p tui.ImageProtocol) string {
3402	switch p {
3403	case tui.ImageProtocolKitty:
3404		return "kitty/ghostty"
3405	case tui.ImageProtocolITerm2:
3406		return "iTerm2"
3407	default:
3408		return "none"
3409	}
3410}
3411
3412func onOff(v bool) string {
3413	if v {
3414		return "enabled"
3415	}
3416	return "disabled"
3417}
3418
3419func (i *Interactive) applyInputCursorColor() {
3420	if i == nil || i.cfg.Terminal == nil {
3421		return
3422	}
3423	_, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock()))
3424}
3425
3426func reasoningSettingOptions(levels []string) []settingsOption {
3427	descriptions := map[string]string{
3428		"":        "no reasoning",
3429		"minimum": "very brief (~1k tokens)",
3430		"low":     "light (~2k tokens)",
3431		"medium":  "moderate (~8k tokens)",
3432		"high":    "deep (~16k tokens)",
3433		"xhigh":   "extra-high effort",
3434		"max":     "unconstrained effort",
3435	}
3436	options := make([]settingsOption, 0, len(levels))
3437	for _, level := range levels {
3438		label := level
3439		if label == "" {
3440			label = "off"
3441		}
3442		options = append(options, settingsOption{value: level, label: label, desc: descriptions[level]})
3443	}
3444	return options
3445}
3446
3447func (i *Interactive) reasoningSettingItem() settingsItem {
3448	levels := []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
3449	reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
3450	desc := "reasoning depth for reasoning-capable models"
3451	hint := ""
3452	if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
3453		levels = provider.AvailableReasoningLevels(m)
3454		reasoning = provider.ClampReasoningForModel(m, reasoning)
3455		if !m.Reasoning {
3456			hint = "current model does not support reasoning"
3457			desc += "; current model does not support reasoning"
3458		} else if len(levels) == 1 {
3459			hint = "current model has no adjustable reasoning levels"
3460			desc += "; current model has no adjustable reasoning levels"
3461		} else {
3462			desc += "; only levels supported by the current model are shown"
3463		}
3464	}
3465	options := reasoningSettingOptions(levels)
3466	choice := 0
3467	for idx, opt := range options {
3468		if opt.value == reasoning {
3469			choice = idx
3470			break
3471		}
3472	}
3473
3474	return settingsItem{
3475		key:     "reasoning",
3476		label:   "reasoning level",
3477		desc:    desc,
3478		options: options,
3479		choice:  choice,
3480		hint:    hint,
3481	}
3482}
3483
3484func (i *Interactive) openReasoningDialog() {
3485	i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())
3486}
3487
3488func (i *Interactive) openSettingsDialog() {
3489	detected := tui.DetectImageProtocol()
3490	imgEnabled := detected != tui.ImageProtocolNone
3491	if i.cfg.InlineImagesEnabled != nil {
3492		imgEnabled = *i.cfg.InlineImagesEnabled
3493	}
3494	imgDisabled := detected == tui.ImageProtocolNone
3495	imgHint := ""
3496	if imgDisabled {
3497		imgEnabled = false
3498		imgHint = "this terminal does not support inline images"
3499	} else {
3500		imgHint = "terminal supports " + imageProtocolName(detected)
3501	}
3502
3503	autoSwarm := false
3504	if i.cfg.AutoSwarmEnabled != nil {
3505		autoSwarm = *i.cfg.AutoSwarmEnabled
3506	}
3507	autoSwarmDisabled := i.cfg.Swarm == nil
3508	autoSwarmHint := ""
3509	if autoSwarmDisabled {
3510		autoSwarm = false
3511		autoSwarmHint = "swarm supervisor not available in this mode"
3512	}
3513
3514	jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
3515	openRouterServerTools := i.openRouterServerToolsEnabled()
3516	openRouterServerToolsDisabled := true
3517	openRouterServerToolsHint := ""
3518	switch {
3519	case i.cfg.Provider != "openrouter":
3520		openRouterServerToolsHint = "only available when the current provider is OpenRouter"
3521	case i.cfg.NoTools:
3522		openRouterServerToolsHint = "disabled for this run by --no-tools"
3523	case strings.Contains(strings.ToLower(i.cfg.Model), "@preset/"):
3524		openRouterServerToolsHint = "this preset owns its server-tool configuration"
3525	default:
3526		openRouterServerToolsDisabled = false
3527	}
3528	recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
3529	respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
3530	compactMode := i.compactModeEnabled()
3531	collapseToolCall := i.cfg.CollapseToolCall != nil && *i.cfg.CollapseToolCall
3532	showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup
3533	inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
3534	statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
3535	workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
3536	quickItems := i.quickModelSettingItems()
3537
3538	autoCompactOptions := []settingsOption{
3539		{value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"},
3540		{value: "70", label: "70%", desc: "compact earlier to keep more context headroom"},
3541		{value: "80", label: "80%", desc: "compact with moderate context headroom"},
3542		{value: "85", label: "85%", desc: "default balance between history and headroom"},
3543		{value: "90", label: "90%", desc: "retain more history before compacting"},
3544	}
3545	autoCompactThreshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
3546	autoCompactChoice := 0
3547	for idx, opt := range autoCompactOptions {
3548		if opt.value == strconv.Itoa(autoCompactThreshold) {
3549			autoCompactChoice = idx
3550			break
3551		}
3552	}
3553
3554	reasoningItem := i.reasoningSettingItem()
3555
3556	themeName := i.cfg.ThemeName
3557	if themeName == "" {
3558		themeName = "auto"
3559	}
3560	if themeName != "auto" && !tui.ThemeExists(i.cfg.ZotHome, themeName) {
3561		themeName = "auto"
3562		i.cfg.ThemeName = ""
3563		if i.cfg.SettingsStore != nil {
3564			_ = i.cfg.SettingsStore.SetTheme("auto")
3565		}
3566		i.applyThemeNow("auto")
3567	}
3568	themeOptions := []settingsOption{}
3569	themeChoice := 0
3570	availableThemes := tui.AvailableThemes(i.cfg.ZotHome)
3571	if i.cfg.ExtensionThemes != nil {
3572		availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...)
3573	}
3574	for idx, opt := range availableThemes {
3575		themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description})
3576		if opt.Value == themeName {
3577			themeChoice = idx
3578		}
3579	}
3580
3581	inputStyleOptions := []settingsOption{
3582		{value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
3583		{value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
3584		{value: tui.InputStyleBlock, label: "block", desc: "render the input as a user-bubble-style block"},
3585	}
3586	inputStyleChoice := 0
3587	for idx, opt := range inputStyleOptions {
3588		if opt.value == inputStyle {
3589			inputStyleChoice = idx
3590			break
3591		}
3592	}
3593	statusPositionOptions := []settingsOption{
3594		{value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
3595		{value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
3596	}
3597	statusPositionChoice := 0
3598	for idx, opt := range statusPositionOptions {
3599		if opt.value == statusPosition {
3600			statusPositionChoice = idx
3601			break
3602		}
3603	}
3604
3605	workingPositionOptions := []settingsOption{
3606		{value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
3607		{value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
3608	}
3609	workingPositionChoice := 0
3610	for idx, opt := range workingPositionOptions {
3611		if opt.value == workingPosition {
3612			workingPositionChoice = idx
3613			break
3614		}
3615	}
3616
3617	items := []settingsItem{
3618		{
3619			key:      "inline_images_enabled",
3620			label:    "render images when supported",
3621			desc:     "draw screenshots inline instead of showing a text placeholder",
3622			value:    imgEnabled,
3623			disabled: imgDisabled,
3624			hint:     imgHint,
3625		},
3626		{
3627			key:      "auto_swarm_enabled",
3628			label:    "auto-swarm",
3629			desc:     "let the agent spawn background sub-agents in parallel via the swarm_spawn tool",
3630			value:    autoSwarm,
3631			disabled: autoSwarmDisabled,
3632			hint:     autoSwarmHint,
3633		},
3634		{
3635			key:     "auto_compact_threshold",
3636			label:   "auto-compact threshold",
3637			desc:    "choose how full the model context can get before zot condenses conversation history",
3638			options: autoCompactOptions,
3639			choice:  autoCompactChoice,
3640		},
3641		{
3642			key:   "jail_by_default",
3643			label: "jail new sessions by default",
3644			desc:  "confine tools to the session working directory unless /unjail is used",
3645			value: jailByDefault,
3646		},
3647		{
3648			key:      "openrouter_server_tools_enabled",
3649			label:    "OpenRouter Server Tools",
3650			desc:     "advertise beta remote tools such as web search, datetime, and advisor; additional charges may apply",
3651			value:    openRouterServerTools && !openRouterServerToolsDisabled,
3652			disabled: openRouterServerToolsDisabled,
3653			hint:     openRouterServerToolsHint,
3654		},
3655		{
3656			key:   "recursive_file_suggest",
3657			label: "recursive @-file search",
3658			desc:  "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
3659			value: recursiveFiles,
3660		},
3661		{
3662			key:   "fuzzy_skill_suggest",
3663			label: "fuzzy skill suggestions",
3664			desc:  "rank /skill: suggestions by name subsequence matches and emphasize matching characters",
3665			value: i.cfg.FuzzySkillSuggest != nil && *i.cfg.FuzzySkillSuggest,
3666		},
3667		{
3668			key:   "respect_gitignore",
3669			label: "hide gitignored files in @-picker",
3670			desc:  "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
3671			value: respectGitignore,
3672		},
3673		{
3674			key:   "compact_mode",
3675			label: "compact transcript rendering",
3676			desc:  "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
3677			value: compactMode,
3678		},
3679		{
3680			key:   "collapse_tool_call",
3681			label: "collapse tool call rendering",
3682			desc:  "retain tool headers, a final preview line, and failure status; ctrl+o expands the full output",
3683			value: collapseToolCall,
3684		},
3685		{
3686			key:   "show_instructions_at_startup",
3687			label: "show loaded resources at startup",
3688			desc:  "list loaded context files, extensions, and user-installed skills above the transcript",
3689			value: showInstructions,
3690		},
3691		{
3692			key:   "tui_settings",
3693			label: "tui settings",
3694			desc:  "choose input chrome and where status information appears",
3695			children: []settingsItem{
3696				{
3697					key:     "tui_input_style",
3698					label:   "input style",
3699					desc:    "choose between the plain prompt, lines, and a block input area",
3700					options: inputStyleOptions,
3701					choice:  inputStyleChoice,
3702				},
3703				{
3704					key:     "tui_status_position",
3705					label:   "status position",
3706					desc:    "place model, usage, and working directory above or below the input",
3707					options: statusPositionOptions,
3708					choice:  statusPositionChoice,
3709				},
3710				{
3711					key:     "tui_working_position",
3712					label:   "working spinner position",
3713					desc:    "place the working spinner above or below the input",
3714					options: workingPositionOptions,
3715					choice:  workingPositionChoice,
3716				},
3717			},
3718		},
3719		reasoningItem,
3720		{
3721			key:     "theme",
3722			label:   "color theme",
3723			desc:    "choose a theme from $ZOT_HOME/themes or a loaded extension",
3724			options: themeOptions,
3725			choice:  themeChoice,
3726		},
3727	}
3728	if len(quickItems) > 0 {
3729		items = append(items, settingsItem{
3730			key:      "quick_models",
3731			label:    "model shortcuts",
3732			desc:     "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches",
3733			children: quickItems,
3734		})
3735	}
3736	items = i.appendPowerShellSetting(items, runtime.GOOS)
3737	i.settingsDialog.Open(items)
3738}
3739
3740func (i *Interactive) applySettingChange(act settingsAction) {
3741	switch {
3742	case strings.HasPrefix(act.Key, "quick_model_"):
3743		i.applyQuickModelSetting(act.Key, act.StringValue)
3744	case act.Key == "reasoning":
3745		i.applyReasoningSetting(act.StringValue)
3746	case act.Key == "theme":
3747		i.applyThemeSetting(act.StringValue)
3748	case act.Key == "auto_compact_threshold":
3749		i.applyAutoCompactThresholdSetting(act.StringValue)
3750	case act.Key == "tui_input_style":
3751		i.applyTUIInputStyleSetting(act.StringValue)
3752	case act.Key == "tui_status_position":
3753		i.applyTUIStatusPositionSetting(act.StringValue)
3754	case act.Key == "tui_working_position":
3755		i.applyTUIWorkingPositionSetting(act.StringValue)
3756	default:
3757		i.applySettingToggle(act.Key, act.Value)
3758	}
3759}
3760
3761func (i *Interactive) applyAutoCompactThresholdSetting(value string) {
3762	threshold, err := strconv.Atoi(value)
3763	if err != nil {
3764		return
3765	}
3766	threshold = normalizeAutoCompactThreshold(&threshold)
3767	if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {
3768		if err := store.SetAutoCompactThreshold(threshold); err != nil {
3769			i.mu.Lock()
3770			i.statusErr = "settings: " + err.Error()
3771			i.mu.Unlock()
3772			return
3773		}
3774	}
3775	i.mu.Lock()
3776	i.cfg.AutoCompactThreshold = &threshold
3777	if threshold == 0 {
3778		i.statusOK = "auto-compact threshold off"
3779	} else {
3780		i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold)
3781	}
3782	i.statusErr = ""
3783	i.mu.Unlock()
3784	i.invalidate()
3785}
3786
3787func (i *Interactive) quickModelSettingItems() []settingsItem {
3788	if len(i.cfg.QuickModelShortcuts) < 9 {
3789		next := make([]QuickModelShortcut, 9)
3790		copy(next, i.cfg.QuickModelShortcuts)
3791		i.cfg.QuickModelShortcuts = next
3792	}
3793	items := make([]settingsItem, 0, 9)
3794	for slot := 1; slot <= 9; slot++ {
3795		items = append(items, i.quickModelSettingItem(slot))
3796	}
3797	return items
3798}
3799
3800func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
3801	current := QuickModelShortcut{}
3802	if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot {
3803		current = i.cfg.QuickModelShortcuts[slot-1]
3804	}
3805	hint := "not assigned"
3806	if current.Provider != "" && current.Model != "" {
3807		hint = current.Provider + " / " + current.Model
3808	}
3809	return settingsItem{
3810		key:    "quick_model_" + strconv.Itoa(slot),
3811		label:  "model " + strconv.Itoa(slot),
3812		desc:   quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.",
3813		picker: true,
3814		hint:   hint,
3815	}
3816}
3817
3818func (i *Interactive) compactModeEnabled() bool {
3819	return i.cfg.CompactMode != nil && *i.cfg.CompactMode
3820}
3821
3822func quickModelShortcutSlot(k tui.Key) int {
3823	if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' {
3824		return 0
3825	}
3826	if runtime.GOOS == "darwin" {
3827		if !k.Super && !k.Ctrl {
3828			return 0
3829		}
3830	} else if !k.Ctrl {
3831		return 0
3832	}
3833	return int(k.Rune - '0')
3834}
3835
3836func quickModelShortcutPrefix() string {
3837	return "Ctrl"
3838}
3839
3840func quickModelShortcutLabel(slot int) string {
3841	return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot)
3842}
3843
3844func (i *Interactive) openQuickModelPicker(slot int) {
3845	if slot < 1 || slot > 9 {
3846		return
3847	}
3848	i.quickModelAssign = slot
3849	current := i.cfg.Model
3850	if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
3851		current = i.cfg.QuickModelShortcuts[slot-1].Model
3852	}
3853	var loggedIn []string
3854	if i.cfg.LoggedInProviders != nil {
3855		loggedIn = i.cfg.LoggedInProviders()
3856	}
3857	i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning)
3858}
3859
3860func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
3861	i.setQuickModelShortcut(slot, providerName, model)
3862}
3863
3864func (i *Interactive) applyQuickModelShortcut(slot int) {
3865	if slot < 1 || slot > 9 {
3866		return
3867	}
3868	if i.busy {
3869		i.mu.Lock()
3870		i.statusErr = "cannot switch model while a turn is running"
3871		i.statusOK = ""
3872		i.mu.Unlock()
3873		i.invalidate()
3874		return
3875	}
3876	if len(i.cfg.QuickModelShortcuts) < slot {
3877		i.mu.Lock()
3878		i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
3879		i.statusOK = ""
3880		i.mu.Unlock()
3881		i.invalidate()
3882		return
3883	}
3884	shortcut := i.cfg.QuickModelShortcuts[slot-1]
3885	if shortcut.Provider == "" || shortcut.Model == "" {
3886		i.mu.Lock()
3887		i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
3888		i.statusOK = ""
3889		i.mu.Unlock()
3890		i.invalidate()
3891		return
3892	}
3893	i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false)
3894	i.invalidate()
3895}
3896
3897func (i *Interactive) applyQuickModelSetting(key, value string) {
3898	slotText := strings.TrimPrefix(key, "quick_model_")
3899	slot, err := strconv.Atoi(slotText)
3900	if err != nil || slot < 1 || slot > 9 {
3901		return
3902	}
3903	providerName, model := "", ""
3904	if value != "" {
3905		parts := strings.SplitN(value, "\t", 2)
3906		if len(parts) == 2 {
3907			providerName, model = parts[0], parts[1]
3908		}
3909	}
3910	i.setQuickModelShortcut(slot, providerName, model)
3911}
3912
3913func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) {
3914	if len(i.cfg.QuickModelShortcuts) < slot {
3915		next := make([]QuickModelShortcut, slot)
3916		copy(next, i.cfg.QuickModelShortcuts)
3917		i.cfg.QuickModelShortcuts = next
3918	}
3919	i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model}
3920	if i.cfg.SettingsStore != nil {
3921		if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil {
3922			i.mu.Lock()
3923			i.statusErr = "settings: " + err.Error()
3924			i.mu.Unlock()
3925			return
3926		}
3927	}
3928	i.mu.Lock()
3929	if model == "" {
3930		i.statusOK = quickModelShortcutLabel(slot) + " cleared"
3931	} else {
3932		i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model
3933	}
3934	i.statusErr = ""
3935	i.mu.Unlock()
3936	i.refreshQuickModelSettingsItem(slot)
3937	i.invalidate()
3938}
3939
3940func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
3941	if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 {
3942		return
3943	}
3944	key := "quick_model_" + strconv.Itoa(slot)
3945	for idx, it := range i.settingsDialog.items {
3946		if it.key == key {
3947			i.settingsDialog.items[idx] = i.quickModelSettingItem(slot)
3948			return
3949		}
3950	}
3951}
3952
3953func (i *Interactive) applySettingToggle(key string, value bool) {
3954	// Every setting toggle forces a full repaint at the end — same
3955	// effect as the user pressing Ctrl+L — so any per-setting visual
3956	// change (image rendering, status copy, future toggles) lands
3957	// immediately instead of waiting for the next diff frame.
3958	defer func() {
3959		if i.rend != nil {
3960			i.rend.Clear()
3961		}
3962		i.invalidate()
3963	}()
3964	switch key {
3965	case "inline_images_enabled":
3966		val := value
3967		i.cfg.InlineImagesEnabled = &val
3968		if i.cfg.SettingsStore != nil {
3969			if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
3970				i.mu.Lock()
3971				i.statusErr = "settings: " + err.Error()
3972				i.mu.Unlock()
3973				return
3974			}
3975		}
3976		i.mu.Lock()
3977		i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
3978		i.view.InvalidateRenderCache()
3979		i.statusOK = "inline image rendering " + onOff(value)
3980		i.statusErr = ""
3981		i.mu.Unlock()
3982	case "auto_swarm_enabled":
3983		val := value
3984		i.cfg.AutoSwarmEnabled = &val
3985		if i.cfg.SettingsStore != nil {
3986			if err := i.cfg.SettingsStore.SetAutoSwarm(value); err != nil {
3987				i.mu.Lock()
3988				i.statusErr = "settings: " + err.Error()
3989				i.mu.Unlock()
3990				return
3991			}
3992		}
3993		// Add/remove the swarm_spawn tool on the live agent so the
3994		// model's tools[] list reflects the toggle on the next turn.
3995		// Without this the tool stays advertised after a disable and
3996		// the model keeps trying to call it.
3997		i.applyAutoSwarmTool(value)
3998		// Also swap the system-prompt addendum in/out so the model
3999		// knows to use the tool proactively (or stops referencing it
4000		// after a disable).
4001		i.applyAutoSwarmSystemPrompt(value)
4002		i.mu.Lock()
4003		i.statusOK = "auto-swarm " + onOff(value)
4004		i.statusErr = ""
4005		i.mu.Unlock()
4006	case "jail_by_default":
4007		val := value
4008		i.cfg.JailByDefault = &val
4009		if i.cfg.SettingsStore != nil {
4010			if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil {
4011				i.mu.Lock()
4012				i.statusErr = "settings: " + err.Error()
4013				i.mu.Unlock()
4014				return
4015			}
4016		}
4017		if i.cfg.Sandbox != nil {
4018			if value {
4019				i.cfg.Sandbox.Lock()
4020			} else {
4021				i.cfg.Sandbox.Unlock()
4022			}
4023		}
4024		i.mu.Lock()
4025		i.statusOK = "jail by default " + onOff(value)
4026		i.statusErr = ""
4027		i.mu.Unlock()
4028	case "powershell_enabled":
4029		i.applyPowerShellSetting(value, runtime.GOOS)
4030	case "openrouter_server_tools_enabled":
4031		val := value
4032		i.cfg.OpenRouterServerToolsEnabled = &val
4033		if i.cfg.SettingsStore != nil {
4034			if err := i.cfg.SettingsStore.SetOpenRouterServerTools(value); err != nil {
4035				i.mu.Lock()
4036				i.statusErr = "settings: " + err.Error()
4037				i.mu.Unlock()
4038				return
4039			}
4040		}
4041		i.applyOpenRouterServerTools(value)
4042		i.mu.Lock()
4043		i.statusOK = "OpenRouter server tools " + onOff(value)
4044		i.statusErr = ""
4045		i.mu.Unlock()
4046	case "fuzzy_skill_suggest":
4047		if i.cfg.SettingsStore != nil {
4048			if err := i.cfg.SettingsStore.SetFuzzySkillSuggest(value); err != nil {
4049				i.mu.Lock()
4050				i.statusErr = "settings: " + err.Error()
4051				i.mu.Unlock()
4052				return
4053			}
4054		}
4055		i.cfg.FuzzySkillSuggest = &value
4056		i.suggest.SetFuzzySkills(value)
4057		i.mu.Lock()
4058		i.statusOK = "fuzzy skill suggestions " + onOff(value)
4059		i.statusErr = ""
4060		i.mu.Unlock()
4061	case "recursive_file_suggest":
4062		val := value
4063		i.cfg.RecursiveFileSuggest = &val
4064		if i.cfg.SettingsStore != nil {
4065			if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
4066				i.mu.Lock()
4067				i.statusErr = "settings: " + err.Error()
4068				i.mu.Unlock()
4069				return
4070			}
4071		}
4072		// Flip the live picker so the next @ reflects the new mode
4073		// without restarting zot. SetRecursive drops its cache.
4074		i.fileSuggest.SetRecursive(value)
4075		i.mu.Lock()
4076		i.statusOK = "recursive @-file search " + onOff(value)
4077		i.statusErr = ""
4078		i.mu.Unlock()
4079	case "respect_gitignore":
4080		val := value
4081		i.cfg.RespectGitignore = &val
4082		if i.cfg.SettingsStore != nil {
4083			if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
4084				i.mu.Lock()
4085				i.statusErr = "settings: " + err.Error()
4086				i.mu.Unlock()
4087				return
4088			}
4089		}
4090		i.fileSuggest.SetRespectGitignore(value)
4091		i.mu.Lock()
4092		i.statusOK = "hide gitignored files in @-picker " + onOff(value)
4093		i.statusErr = ""
4094		i.mu.Unlock()
4095	case "compact_mode":
4096		val := value
4097		i.cfg.CompactMode = &val
4098		if i.cfg.SettingsStore != nil {
4099			if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
4100				i.mu.Lock()
4101				i.statusErr = "settings: " + err.Error()
4102				i.mu.Unlock()
4103				return
4104			}
4105		}
4106		i.mu.Lock()
4107		i.view.CompactMode = value
4108		i.view.InvalidateRenderCache()
4109		i.statusOK = "compact transcript rendering " + onOff(value)
4110		i.statusErr = ""
4111		i.mu.Unlock()
4112	case "collapse_tool_call":
4113		val := value
4114		i.cfg.CollapseToolCall = &val
4115		if i.cfg.SettingsStore != nil {
4116			if err := i.cfg.SettingsStore.SetCollapseToolCall(value); err != nil {
4117				i.mu.Lock()
4118				i.statusErr = "settings: " + err.Error()
4119				i.mu.Unlock()
4120				return
4121			}
4122		}
4123		i.mu.Lock()
4124		i.view.CollapseToolCall = value
4125		i.view.InvalidateRenderCache()
4126		i.statusOK = "collapse tool call rendering " + onOff(value)
4127		i.statusErr = ""
4128		i.mu.Unlock()
4129	case "show_instructions_at_startup":
4130		val := value
4131		i.cfg.ShowInstructionsAtStartup = &val
4132		if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok {
4133			if err := store.SetShowInstructionsAtStartup(value); err != nil {
4134				i.mu.Lock()
4135				i.statusErr = "settings: " + err.Error()
4136				i.mu.Unlock()
4137				return
4138			}
4139		}
4140		i.mu.Lock()
4141		i.view.StartupAgentName = ""
4142		i.view.StartupContextPaths = nil
4143		i.view.StartupExtensionNames = nil
4144		i.view.StartupSkillNames = nil
4145		if value {
4146			i.view.StartupAgentName = i.cfg.StartupAgentName
4147			i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
4148			i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...)
4149			i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...)
4150		}
4151		i.statusOK = "show loaded resources at startup " + onOff(value)
4152		i.statusErr = ""
4153		i.mu.Unlock()
4154	}
4155}
4156
4157func (i *Interactive) applyTUIInputStyleSetting(style string) {
4158	defer func() {
4159		if i.rend != nil {
4160			i.rend.Clear()
4161		}
4162		i.invalidate()
4163	}()
4164	style = tui.NormalizeInputStyle(style)
4165	i.cfg.TUIInputStyle = style
4166	i.applyInputCursorColor()
4167	if i.cfg.SettingsStore != nil {
4168		if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
4169			i.mu.Lock()
4170			i.statusErr = "settings: " + err.Error()
4171			i.mu.Unlock()
4172			return
4173		}
4174	}
4175	i.mu.Lock()
4176	i.statusOK = "input style " + style
4177	i.statusErr = ""
4178	i.mu.Unlock()
4179}
4180
4181func (i *Interactive) applyTUIStatusPositionSetting(position string) {
4182	defer func() {
4183		if i.rend != nil {
4184			i.rend.Clear()
4185		}
4186		i.invalidate()
4187	}()
4188	position = tui.NormalizeStatusPosition(position)
4189	i.cfg.TUIStatusPosition = position
4190	if i.cfg.SettingsStore != nil {
4191		if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
4192			i.mu.Lock()
4193			i.statusErr = "settings: " + err.Error()
4194			i.mu.Unlock()
4195			return
4196		}
4197	}
4198	i.mu.Lock()
4199	label := "above input"
4200	if position == tui.StatusPositionBelowInput {
4201		label = "below input"
4202	}
4203	i.statusOK = "status position " + label
4204	i.statusErr = ""
4205	i.mu.Unlock()
4206}
4207
4208func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
4209	defer func() {
4210		if i.rend != nil {
4211			i.rend.Clear()
4212		}
4213		i.invalidate()
4214	}()
4215	position = tui.NormalizeWorkingPosition(position)
4216	i.cfg.TUIWorkingPosition = position
4217	if i.cfg.SettingsStore != nil {
4218		if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
4219			i.mu.Lock()
4220			i.statusErr = "settings: " + err.Error()
4221			i.mu.Unlock()
4222			return
4223		}
4224	}
4225	i.mu.Lock()
4226	label := "above input"
4227	if position == tui.WorkingPositionBelowInput {
4228		label = "below input"
4229	}
4230	i.statusOK = "working spinner position " + label
4231	i.statusErr = ""
4232	i.mu.Unlock()
4233}
4234
4235func (i *Interactive) applyThemeSetting(name string) {
4236	if i.cfg.SettingsStore != nil {
4237		if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
4238			i.mu.Lock()
4239			i.statusErr = "settings: " + err.Error()
4240			i.mu.Unlock()
4241			return
4242		}
4243	}
4244	i.cfg.ThemeName = name
4245	if name == "auto" {
4246		i.cfg.ThemeName = ""
4247	}
4248	i.applyThemeNow(name)
4249}
4250
4251func (i *Interactive) applyThemeNow(name string) {
4252	if name == "" {
4253		name = "auto"
4254	}
4255	detected := tui.Dark
4256	if tui.IsLightTheme(i.cfg.Theme) {
4257		detected = tui.Light
4258	}
4259	th, applied, err := tui.LoadThemeFromHome(i.cfg.ZotHome, name, detected)
4260	if err != nil {
4261		if i.cfg.SettingsStore != nil {
4262			_ = i.cfg.SettingsStore.SetTheme("auto")
4263		}
4264		i.cfg.ThemeName = ""
4265		th, applied, _ = tui.LoadThemeFromHome(i.cfg.ZotHome, "auto", detected)
4266		i.mu.Lock()
4267		i.statusErr = "theme missing; reset to default"
4268		i.mu.Unlock()
4269	} else {
4270		i.mu.Lock()
4271		label := applied
4272		if label == "" {
4273			label = "auto"
4274		}
4275		i.statusOK = "theme " + label
4276		i.statusErr = ""
4277		i.mu.Unlock()
4278	}
4279	i.cfg.Theme = th
4280	i.view.Theme = th
4281	i.view.InvalidateRenderCache()
4282	i.ed.Prompt = th.AccentBar(th.Accent)
4283	i.applyInputCursorColor()
4284	i.spin.Configure(th)
4285	if i.rend != nil {
4286		i.rend.SetTheme(th)
4287		i.rend.Clear()
4288	}
4289	i.invalidate()
4290}
4291
4292func (i *Interactive) applyReasoningSetting(level string) {
4293	defer func() {
4294		if i.rend != nil {
4295			i.rend.Clear()
4296		}
4297		i.invalidate()
4298	}()
4299	level = provider.NormalizeReasoning(level)
4300	i.cfg.Reasoning = level
4301	if i.cfg.SettingsStore != nil {
4302		if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
4303			i.mu.Lock()
4304			i.statusErr = "settings: " + err.Error()
4305			i.mu.Unlock()
4306			return
4307		}
4308	}
4309	i.mu.Lock()
4310	if i.agent != nil {
4311		i.agent.Reasoning = level
4312	}
4313	label := level
4314	if label == "" {
4315		label = "off"
4316	}
4317	i.statusOK = "reasoning level " + label
4318	i.statusErr = ""
4319	i.mu.Unlock()
4320	if i.cfg.OnReasoningChanged != nil {
4321		i.cfg.OnReasoningChanged(level)
4322	}
4323}
4324
4325// buildStudyPrompt returns the canned prompt the /study command
4326// submits to the agent.
4327//
4328// With no argument, /study targets the current directory — the
4329// historical behaviour. With an argument, /study targets that path
4330// instead; either a directory ("read every file in here") or a
4331// single file ("read this file"). The argument can be:
4332//
4333//   - a relative path (resolved against cwd)
4334//   - an absolute path
4335//   - an @-picker chip, which has already been expanded to an
4336//     absolute path by expandFileChips before runSlash sees it
4337//
4338// The path is stat'd to pick the right wording ("directory" vs
4339// "file"). If the path doesn't exist, we still build a sensible
4340// prompt rather than erroring — the agent will surface the
4341// missing-file failure itself when it tries to read it, which is
4342// more useful than a refusal here.
4343func buildStudyPrompt(arg, cwd string) string {
4344	arg = strings.TrimSpace(arg)
4345	if arg == "" {
4346		return "Read and understand everything in the current directory."
4347	}
4348	abs := arg
4349	if !filepath.IsAbs(abs) {
4350		abs = filepath.Join(cwd, abs)
4351	}
4352	display := arg
4353	if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
4354		display = rel
4355	}
4356	if info, err := os.Stat(abs); err == nil && !info.IsDir() {
4357		return "Read and understand the file " + display + "."
4358	}
4359	return "Read and understand everything in the directory " + display + "."
4360}
4361
4362// tryPathTabCompleteEditor looks at ed's current value, finds the
4363// path-like token immediately before the cursor (the cursor is always
4364// at the end of the buffer after a keystroke, so "before the cursor"
4365// is the trailing non-whitespace run), and rewrites it to its shell-
4366// style completion against the filesystem.
4367//
4368// Returns true when it consumed the Tab keystroke (token recognised,
4369// completion attempted — even if no candidates matched, the keystroke
4370// is still consumed so it doesn't insert a literal tab character).
4371// Returns false when the token doesn't look like a path; callers then
4372// let Tab fall through to its normal no-op.
4373//
4374// Recognised path shapes:
4375//   - ~ or ~/foo                  expanded via os.UserHomeDir()
4376//   - /abs/path or /abs/path/foo  absolute
4377//   - ./foo, ../foo, foo/bar      relative to cwd
4378//
4379// A bare word like "hello" is not treated as a path so plain text
4380// keeps Tab as a literal no-op.
4381//
4382// Free function (not a method) so the same logic runs against the
4383// editor instances owned by btwDialog and swarmDialog without each
4384// dialog needing its own copy.
4385func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
4386	if ed == nil {
4387		return false
4388	}
4389	val := ed.Value()
4390	// Find the trailing run of non-whitespace.
4391	start := len(val)
4392	for start > 0 {
4393		r := val[start-1]
4394		if r == ' ' || r == '\t' || r == '\n' {
4395			break
4396		}
4397		start--
4398	}
4399	token := val[start:]
4400	if token == "" {
4401		return false
4402	}
4403	if !looksLikePathToken(token) {
4404		return false
4405	}
4406
4407	// Resolve the absolute parent directory + base prefix to match.
4408	parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
4409	if !ok {
4410		return true
4411	}
4412	entries, err := os.ReadDir(parentAbs)
4413	if err != nil {
4414		return true
4415	}
4416	var names []string
4417	var isDir []bool
4418	for _, e := range entries {
4419		name := e.Name()
4420		if !strings.HasPrefix(name, basePrefix) {
4421			continue
4422		}
4423		// Hide dotfiles unless the user explicitly typed a leading dot,
4424		// mirroring bash's default behaviour.
4425		if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
4426			continue
4427		}
4428		names = append(names, name)
4429		isDir = append(isDir, e.IsDir())
4430	}
4431	if len(names) == 0 {
4432		return true
4433	}
4434
4435	var completed string
4436	var completedIsDir bool
4437	if len(names) == 1 {
4438		completed = names[0]
4439		completedIsDir = isDir[0]
4440	} else {
4441		completed = longestCommonPrefix(names)
4442		if completed == basePrefix {
4443			// Already at the deepest unambiguous prefix; nothing to add.
4444			return true
4445		}
4446	}
4447
4448	// Build the replacement token in the same display form the user
4449	// typed (preserve ~ vs absolute vs relative).
4450	newToken := displayParent + completed
4451	if len(names) == 1 && completedIsDir {
4452		newToken += "/"
4453	}
4454
4455	ed.SetValue(val[:start] + newToken)
4456	return true
4457}
4458
4459// tryPathTabComplete is the Interactive-bound convenience wrapper.
4460// It calls the free helper against the main editor and invalidates
4461// the frame on a successful rewrite.
4462func (i *Interactive) tryPathTabComplete() bool {
4463	if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
4464		i.invalidate()
4465		return true
4466	}
4467	return false
4468}
4469
4470// looksLikePathToken reports whether tok is shaped like a filesystem
4471// path. Paths must either start with ~, /, ./, ../ or contain a /.
4472// Plain words are excluded so Tab on "hello" stays a no-op.
4473func looksLikePathToken(tok string) bool {
4474	if tok == "" {
4475		return false
4476	}
4477	if tok[0] == '~' || tok[0] == '/' {
4478		return true
4479	}
4480	if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
4481		return true
4482	}
4483	return strings.Contains(tok, "/")
4484}
4485
4486// resolvePathTabToken splits tok into (absolute parent dir, basename
4487// prefix to match, display-form parent the user typed). ok is false
4488// when the parent dir can't be resolved (e.g. ~ with no $HOME).
4489func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
4490	// Detect ~ expansion.
4491	expanded := tok
4492	homePrefix := ""
4493	if tok == "~" {
4494		home, err := os.UserHomeDir()
4495		if err != nil || home == "" {
4496			return "", "", "", false
4497		}
4498		// "~" alone: complete in $HOME. parent = home, base = "".
4499		return home, "", "~/", true
4500	}
4501	if strings.HasPrefix(tok, "~/") {
4502		home, err := os.UserHomeDir()
4503		if err != nil || home == "" {
4504			return "", "", "", false
4505		}
4506		expanded = home + tok[1:]
4507		homePrefix = "~"
4508	}
4509
4510	dir, base := splitDirBase(expanded)
4511	if !filepath.IsAbs(dir) {
4512		dir = filepath.Join(cwd, dir)
4513	}
4514
4515	// Reconstruct the display form the user typed for the parent,
4516	// keeping ~ when they used it. The base is dropped — the caller
4517	// substitutes the completed name.
4518	display := tok[:len(tok)-len(base)]
4519	if homePrefix != "" && !strings.HasPrefix(display, "~") {
4520		display = homePrefix + display[len(homePrefix):]
4521	}
4522	return dir, base, display, true
4523}
4524
4525// splitDirBase is like filepath.Split but preserves the trailing
4526// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
4527// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
4528// the trailing separator when non-empty so callers can rebuild paths
4529// by concatenation.
4530func splitDirBase(p string) (dir, base string) {
4531	if p == "" {
4532		return ".", ""
4533	}
4534	i := strings.LastIndex(p, "/")
4535	if i < 0 {
4536		return ".", p
4537	}
4538	return p[:i+1], p[i+1:]
4539}
4540
4541func longestCommonPrefix(ss []string) string {
4542	if len(ss) == 0 {
4543		return ""
4544	}
4545	prefix := ss[0]
4546	for _, s := range ss[1:] {
4547		n := 0
4548		for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
4549			n++
4550		}
4551		prefix = prefix[:n]
4552		if prefix == "" {
4553			return ""
4554		}
4555	}
4556	return prefix
4557}
4558
4559func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4560	parts := strings.Fields(cmd)
4561	if len(parts) == 0 {
4562		return false
4563	}
4564
4565	if isSkillCommand(cmd) {
4566		var available []*skills.Skill
4567		if i.cfg.SkillSnapshot != nil {
4568			available = i.cfg.SkillSnapshot()
4569		}
4570		prompt, _, err := expandSkillCommand(cmd, available)
4571		if err != nil {
4572			i.mu.Lock()
4573			i.statusErr = err.Error()
4574			i.statusOK = ""
4575			i.mu.Unlock()
4576			return false
4577		}
4578		i.submitOrQueuePrompt(ctx, prompt)
4579		return false
4580	}
4581
4582	switch strings.ToLower(parts[0]) {
4583	case "/exit":
4584		return true
4585	case "/clear":
4586		if i.agent != nil {
4587			i.agent.SetMessages(nil)
4588		}
4589		i.mu.Lock()
4590		i.toolCalls = map[string]*tui.ToolCallView{}
4591		i.toolOrder = nil
4592		i.toolGate = map[string]int{}
4593		i.statusErr = ""
4594		i.statusOK = ""
4595		i.helpBlock = nil
4596		i.parkedTurn = 0
4597		i.parkedTotal = 0
4598		i.scrollOffset = 0
4599		i.extNotes = nil
4600		i.reloadErrors = nil
4601		i.view.InvalidateRenderCache()
4602		i.mu.Unlock()
4603	case "/help":
4604		i.mu.Lock()
4605		i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap)
4606		i.statusErr = ""
4607		i.statusOK = ""
4608		// Pin the viewport to the newest content so the help block,
4609		// which we just appended to the end of the transcript, is
4610		// what the user actually sees.
4611		i.scrollOffset = 0
4612		i.mu.Unlock()
4613	case "/login":
4614		i.dialog.Open(i.cfg.ZotHome)
4615	case "/logout":
4616		if len(parts) >= 2 {
4617			// Explicit target: /logout anthropic | openai | all
4618			i.doLogout(parts[1])
4619			break
4620		}
4621		// No arg: open the picker over whichever providers are
4622		// currently logged in. If nothing's logged in, bail with a
4623		// status line.
4624		i.openLogoutDialog()
4625	case "/model":
4626		if len(parts) >= 2 {
4627			i.applyModelSelection("", parts[1])
4628		} else if i.modelRefreshNeeded() {
4629			i.mu.Lock()
4630			i.statusOK = "refreshing models"
4631			i.statusErr = ""
4632			i.mu.Unlock()
4633			go func() {
4634				refresh := func(fn func(context.Context) error) error {
4635					refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
4636					defer cancel()
4637					return fn(refreshCtx)
4638				}
4639				var err error
4640				if i.cfg.RefreshLMStudioModels != nil {
4641					err = refresh(i.cfg.RefreshLMStudioModels)
4642				}
4643				if i.cfg.RefreshLlamaCPPModels != nil {
4644					if llamaErr := refresh(i.cfg.RefreshLlamaCPPModels); err == nil {
4645						err = llamaErr
4646					}
4647				}
4648				if i.cfg.RefreshCustomProviderModels != nil && i.cfg.CustomDiscoveryConfigured != nil && i.cfg.CustomDiscoveryConfigured() {
4649					if customErr := refresh(i.cfg.RefreshCustomProviderModels); err == nil {
4650						err = customErr
4651					}
4652				}
4653				select {
4654				case i.modelRefresh <- modelRefreshResult{err: err}:
4655				case <-ctx.Done():
4656				}
4657			}()
4658		} else {
4659			i.openModelPickerAfterRefresh(nil)
4660		}
4661	case "/llama":
4662		if i.cfg.LlamaCPPConfig == nil {
4663			i.mu.Lock()
4664			i.statusErr = "llama.cpp configuration is unavailable"
4665			i.statusOK = ""
4666			i.mu.Unlock()
4667			break
4668		}
4669		baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
4670		if configErr == nil {
4671			configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
4672		}
4673		if configErr != nil {
4674			i.mu.Lock()
4675			i.statusErr = configErr.Error()
4676			i.statusOK = ""
4677			i.mu.Unlock()
4678		}
4679	case "/reasoning":
4680		i.openReasoningDialog()
4681	case "/settings":
4682		i.openSettingsDialog()
4683	case "/sessions":
4684		i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)
4685	case "/jump":
4686		i.openJumpDialog(parts[1:])
4687	case "/btw":
4688		i.openBtwDialog(parts[1:])
4689	case "/skills":
4690		i.openSkillsDialog()
4691	case "/compact":
4692		i.runCompact(ctx, false)
4693	case "/study":
4694		// Canned prompt that tells the agent to read every file
4695		// in some target so its later turns have the whole thing
4696		// in context. With no argument, the target is the current
4697		// directory. With an argument, the target is whatever the
4698		// user passed — typed by hand, drag-dropped, or selected
4699		// via the @ file picker (which is why we accept both files
4700		// and directories; the @-picker chips for both have already
4701		// been expanded to absolute paths by expandFileChips above).
4702		// Dispatched through the normal queue-or-start path so it
4703		// behaves identically to typing the prompt by hand.
4704		studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
4705		i.mu.Lock()
4706		busy := i.busy
4707		compacting := i.compacting
4708		ag := i.agent
4709		i.mu.Unlock()
4710		if busy {
4711			if ag != nil && !compacting {
4712				ag.QueueMessage(studyPrompt)
4713			} else {
4714				i.mu.Lock()
4715				i.queued = append(i.queued, studyPrompt)
4716				i.mu.Unlock()
4717			}
4718			i.invalidate()
4719			break
4720		}
4721		i.startTurn(ctx, studyPrompt)
4722	case "/cd":
4723		// Hidden command: switch the running session's cwd. Not in
4724		// slash_suggest, not in /help. Used by the workspaces
4725		// extension's panel-key Enter handler so picking a row
4726		// jumps zot into that directory without relaunching.
4727		//
4728		// Recovers the raw argument (path) from the original cmd
4729		// string rather than parts, so paths with spaces survive.
4730		// The host's ChangeCWD hook handles validation, session
4731		// close + reopen, agent rebuild, sandbox re-rooting, and
4732		// re-jail-if-jailed semantics.
4733		if i.cfg.ChangeCWD == nil {
4734			i.mu.Lock()
4735			i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
4736			i.mu.Unlock()
4737			break
4738		}
4739		path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
4740		if path == "" {
4741			i.mu.Lock()
4742			i.statusErr = "/cd: missing path"
4743			i.mu.Unlock()
4744			break
4745		}
4746		if err := i.cfg.ChangeCWD(path); err != nil {
4747			i.mu.Lock()
4748			i.statusErr = "/cd: " + err.Error()
4749			i.statusOK = ""
4750			i.mu.Unlock()
4751			break
4752		}
4753		// ChangeCWD has already updated i.cfg.CWD and swapped the
4754		// agent + session. Reset transient TUI state so the new
4755		// session opens clean.
4756		i.mu.Lock()
4757		i.toolCalls = map[string]*tui.ToolCallView{}
4758		i.toolOrder = nil
4759		i.toolGate = map[string]int{}
4760		i.helpBlock = nil
4761		i.parkedTurn = 0
4762		i.statusOK = "cwd " + i.cfg.CWD
4763		i.statusErr = ""
4764		i.mu.Unlock()
4765		i.fileSuggest.Reset()
4766		i.fileSuggest.SetCWD(i.cfg.CWD)
4767		i.invalidate()
4768	case "/jail":
4769		if i.cfg.Sandbox == nil {
4770			i.mu.Lock()
4771			i.statusErr = "sandbox not available in this build"
4772			i.mu.Unlock()
4773			break
4774		}
4775		i.cfg.Sandbox.Lock()
4776		i.mu.Lock()
4777		i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
4778		i.statusErr = ""
4779		i.mu.Unlock()
4780	case "/unjail":
4781		if i.cfg.Sandbox == nil {
4782			i.mu.Lock()
4783			i.statusErr = "sandbox not available in this build"
4784			i.mu.Unlock()
4785			break
4786		}
4787		i.cfg.Sandbox.Unlock()
4788		i.mu.Lock()
4789		i.statusOK = "unjailed"
4790		i.statusErr = ""
4791		i.mu.Unlock()
4792	case "/reload-ext":
4793		i.runReloadExt(ctx)
4794	case "/telegram", "/tg":
4795		if len(parts) >= 2 {
4796			i.doTelegram(parts[1])
4797			break
4798		}
4799		i.openTelegramDialog()
4800	case "/session":
4801		if len(parts) >= 2 {
4802			action := parts[1]
4803			arg := ""
4804			if len(parts) >= 3 {
4805				arg = strings.Join(parts[2:], " ")
4806			}
4807			i.doSessionOp(action, arg)
4808			break
4809		}
4810		i.openSessionOpsDialog()
4811	case "/swarm":
4812		i.runSwarm(ctx, parts[1:])
4813	default:
4814		// Last-resort fallback: try the extension manager. Built-in
4815		// cases above always win; this branch only fires for slash
4816		// commands the extension manager registered. Same routing as
4817		// the editor's submit-handler dispatch path so the autocomplete
4818		// "enter on highlighted suggestion" flow also works.
4819		extName := strings.TrimPrefix(parts[0], "/")
4820		if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
4821			rest := ""
4822			if len(parts) > 1 {
4823				rest = strings.Join(parts[1:], " ")
4824			}
4825			go i.invokeExtensionCommand(ctx, extName, rest)
4826			return false
4827		}
4828		i.mu.Lock()
4829		i.statusErr = "unknown command: " + parts[0]
4830		i.mu.Unlock()
4831	}
4832	return false
4833}
4834
4835// modelRefreshNeeded reports whether /model must synchronize transient
4836// local catalogs before the picker opens.
4837func (i *Interactive) modelRefreshNeeded() bool {
4838	if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
4839		return true
4840	}
4841	if i.cfg.LMStudioConfigured != nil && i.cfg.LMStudioConfigured() && i.cfg.RefreshLMStudioModels != nil {
4842		return true
4843	}
4844	return i.cfg.CustomDiscoveryConfigured != nil && i.cfg.CustomDiscoveryConfigured() && i.cfg.RefreshCustomProviderModels != nil
4845}
4846
4847func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
4848	var loggedIn []string
4849	if i.cfg.LoggedInProviders != nil {
4850		loggedIn = i.cfg.LoggedInProviders()
4851	}
4852	i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
4853	i.mu.Lock()
4854	if refreshErr != nil {
4855		i.statusErr = "model refresh: " + refreshErr.Error()
4856		i.statusOK = ""
4857	} else if i.statusOK == "refreshing models" {
4858		i.statusOK = ""
4859	}
4860	i.mu.Unlock()
4861}
4862
4863// openLogoutDialog shows the provider picker for `/logout` with no
4864// argument. Only providers the user is currently logged into are
4865// listed, plus an "all" entry when more than one is present. If
4866// nothing's logged in, writes a status line instead of opening an
4867// empty dialog.
4868func (i *Interactive) openLogoutDialog() {
4869	if i.cfg.AuthManager == nil {
4870		i.mu.Lock()
4871		i.statusErr = "no auth manager configured"
4872		i.mu.Unlock()
4873		i.invalidate()
4874		return
4875	}
4876	store := i.cfg.AuthManager.Store()
4877	if store == nil {
4878		i.mu.Lock()
4879		i.statusErr = "auth store is not available"
4880		i.mu.Unlock()
4881		i.invalidate()
4882		return
4883	}
4884	creds, err := store.Load()
4885	if err != nil {
4886		i.mu.Lock()
4887		i.statusErr = "read auth store: " + err.Error()
4888		i.mu.Unlock()
4889		i.invalidate()
4890		return
4891	}
4892
4893	var items []logoutItem
4894	for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
4895		if creds.Has(p) {
4896			method := creds.Method(p)
4897			if method == "oauth" {
4898				method = "subscription"
4899			}
4900			items = append(items, logoutItem{
4901				label:  providerLabel(p),
4902				target: p,
4903				method: method,
4904			})
4905		}
4906	}
4907	if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
4908		items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
4909	}
4910	if creds.OpenAI.OAuth != nil {
4911		items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
4912	}
4913	for p, c := range creds.AdditionalAPIKeyCreds {
4914		if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
4915			items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
4916		}
4917	}
4918	if len(items) == 0 {
4919		i.mu.Lock()
4920		i.statusOK = "no credentials stored; already logged out"
4921		i.statusErr = ""
4922		i.mu.Unlock()
4923		i.invalidate()
4924		return
4925	}
4926	if len(items) > 1 {
4927		items = append(items, logoutItem{label: "all", target: "all"})
4928	}
4929
4930	i.logoutDialog.Open(items)
4931	i.invalidate()
4932}
4933
4934// doLogout clears credentials for the given provider (or all providers)
4935// from auth.json. If the active agent was using those credentials, it
4936// is torn down so the user is forced through /login before their next
4937// prompt.
4938//
4939// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
4940func (i *Interactive) doLogout(target string) {
4941	if i.cfg.AuthManager == nil {
4942		i.mu.Lock()
4943		i.statusErr = "no auth manager configured"
4944		i.mu.Unlock()
4945		return
4946	}
4947	store := i.cfg.AuthManager.Store()
4948	if store == nil {
4949		i.mu.Lock()
4950		i.statusErr = "auth store is not available"
4951		i.mu.Unlock()
4952		return
4953	}
4954
4955	var providers []string
4956	switch target {
4957	case "", "all":
4958		providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
4959	case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
4960		providers = []string{target}
4961	default:
4962		known := false
4963		for _, p := range auth.APIKeyProviders() {
4964			if target == p {
4965				known = true
4966				break
4967			}
4968		}
4969		if !known {
4970			i.mu.Lock()
4971			i.statusErr = "unknown provider: " + target
4972			i.mu.Unlock()
4973			return
4974		}
4975		providers = []string{target}
4976	}
4977
4978	var errs []string
4979	clearedCurrent := false
4980	for _, p := range providers {
4981		var err error
4982		switch p {
4983		case "openai":
4984			err = store.ClearAPIKey("openai")
4985		case "openai-codex":
4986			err = store.ClearOAuth("openai")
4987		default:
4988			err = store.Clear(p)
4989		}
4990		if err != nil {
4991			errs = append(errs, p+": "+err.Error())
4992			continue
4993		}
4994		if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
4995			if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
4996				errs = append(errs, p+": "+err.Error())
4997				continue
4998			}
4999		}
5000		if p == provider.LMStudioProviderID {
5001			provider.SetManagedModelsForProvider(provider.LMStudioProviderID, nil)
5002		}
5003		if p == i.cfg.Provider {
5004			clearedCurrent = true
5005		}
5006	}
5007
5008	llamaConfigured := i.llamaConfigured
5009	if i.cfg.LlamaCPPConfig != nil {
5010		baseURL, _, err := i.cfg.LlamaCPPConfig()
5011		llamaConfigured = err == nil && baseURL != ""
5012	}
5013
5014	i.mu.Lock()
5015	defer i.mu.Unlock()
5016	i.llamaConfigured = llamaConfigured
5017	if len(errs) > 0 {
5018		i.statusErr = "logout errors: " + strings.Join(errs, "; ")
5019		return
5020	}
5021	i.statusErr = ""
5022	if clearedCurrent {
5023		// The running agent was using a credential we just wiped. Drop
5024		// it so prompts can't go out with the stale client, and hint at
5025		// /login.
5026		i.agent = nil
5027		i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
5028	} else {
5029		i.statusOK = "logged out of " + strings.Join(providers, ", ")
5030	}
5031}
5032
5033func providerSetupInfo(provider string) (string, []string, bool) {
5034	const docsURL = "https://raw.githubusercontent.com/patriceckhart/zot/main/docs/providers.md"
5035	switch provider {
5036	case "amazon-bedrock":
5037		return "Amazon Bedrock setup", []string{
5038			"Amazon Bedrock uses AWS credentials instead of a generic zot API-key entry.",
5039			"Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
5040			"",
5041			"For Bedrock API keys, set:",
5042			"  AWS_BEARER_TOKEN_BEDROCK=...",
5043			"  AWS_REGION=us-east-1",
5044			"",
5045			"Docs:",
5046			"  " + docsURL,
5047		}, true
5048	case "google-vertex":
5049		return "Google Vertex AI setup", []string{
5050			"Google Vertex AI usually uses Google Cloud credentials and project settings.",
5051			"Set a Google API key, application-default credentials, or a service account.",
5052			"",
5053			"Common environment:",
5054			"  GOOGLE_CLOUD_API_KEY=...",
5055			"  GOOGLE_CLOUD_PROJECT=...",
5056			"  GOOGLE_CLOUD_LOCATION=us-central1",
5057			"",
5058			"Docs:",
5059			"  " + docsURL,
5060		}, true
5061	case "cloudflare-workers-ai":
5062		return "Cloudflare Workers AI setup", []string{
5063			"Cloudflare Workers AI needs both an API token and an account ID.",
5064			"",
5065			"Set:",
5066			"  CLOUDFLARE_API_KEY=...",
5067			"  CLOUDFLARE_ACCOUNT_ID=...",
5068			"",
5069			"Docs:",
5070			"  " + docsURL,
5071		}, true
5072	case "cloudflare-ai-gateway":
5073		return "Cloudflare AI Gateway setup", []string{
5074			"Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
5075			"",
5076			"Set:",
5077			"  CLOUDFLARE_API_KEY=...",
5078			"  CLOUDFLARE_ACCOUNT_ID=...",
5079			"  CLOUDFLARE_GATEWAY_ID=...",
5080			"",
5081			"Docs:",
5082			"  " + docsURL,
5083		}, true
5084	case "azure-openai-responses":
5085		return "Azure OpenAI Responses setup", []string{
5086			"Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
5087			"",
5088			"Set:",
5089			"  AZURE_OPENAI_API_KEY=...",
5090			"  AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
5091			"  AZURE_OPENAI_API_VERSION=2024-02-01",
5092			"",
5093			"Docs:",
5094			"  " + docsURL,
5095		}, true
5096	default:
5097		return "", nil, false
5098	}
5099}
5100
5101func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
5102	if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
5103		i.dialog.ShowResult(false, "auth store is unavailable")
5104		return
5105	}
5106	if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
5107		i.dialog.ShowResult(false, err.Error())
5108		return
5109	}
5110	i.mu.Lock()
5111	i.llamaConfigured = true
5112	i.statusErr = ""
5113	i.statusOK = "configured llama.cpp router"
5114	i.mu.Unlock()
5115	i.dialog.ShowResult(true, "")
5116}
5117
5118func (i *Interactive) startAPIKeyFlow(provider string) {
5119	if title, lines, ok := providerSetupInfo(provider); ok {
5120		i.dialog.ShowInfo(title, lines)
5121		return
5122	}
5123	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5124		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
5125	}
5126	url, err := i.cfg.AuthManager.StartAPIKey(provider)
5127	if err != nil {
5128		i.dialog.ShowResult(false, err.Error())
5129		return
5130	}
5131	i.dialog.ShowWaiting(url)
5132}
5133
5134func (i *Interactive) startOAuthFlow(provider string) {
5135	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5136		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
5137	}
5138	// Device-code providers already support headless login and must only
5139	// start one polling flow.
5140	if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
5141		loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
5142		if err != nil {
5143			i.dialog.ShowResult(false, err.Error())
5144			return
5145		}
5146		i.dialog.ShowWaiting(loginURL)
5147		return
5148	}
5149	// Always run the manual/copy-code flow in parallel with the local
5150	// callback server so headless environments (docker, SSH) can paste
5151	// the authorization code directly without first pressing 'p'.
5152	_, err := i.cfg.AuthManager.StartOAuth(provider)
5153	if err != nil {
5154		i.dialog.ShowResult(false, err.Error())
5155		return
5156	}
5157	manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
5158	if mErr == nil {
5159		i.dialog.ShowWaiting(manualURL)
5160	} else {
5161		i.dialog.ShowResult(false, mErr.Error())
5162	}
5163}
5164
5165func (i *Interactive) startManualOAuthFlow(provider string) {
5166	if i.cfg.AuthManager == nil {
5167		return
5168	}
5169	i.cfg.AuthManager.CancelOAuth()
5170	url, err := i.cfg.AuthManager.StartManualOAuth(provider)
5171	if err != nil {
5172		i.dialog.ShowResult(false, err.Error())
5173		return
5174	}
5175	i.dialog.url = url
5176	i.invalidate()
5177}
5178
5179func (i *Interactive) submitManualOAuthCode(code string) {
5180	if i.cfg.AuthManager == nil {
5181		return
5182	}
5183	go func() {
5184		if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
5185			i.dialog.ShowResult(false, err.Error())
5186			i.invalidate()
5187		}
5188	}()
5189}
5190
5191// applyModelSelection switches the active model (and provider, if the
5192// new model belongs to a different one). It rebuilds the underlying
5193// client when needed so the provider wire-protocol matches.
5194// cancelAndWaitForIdle cancels the active turn (if any) and blocks
5195// briefly until the turn goroutine has updated i.busy = false. Used
5196// before destructive slash commands so transcript-mutating work
5197// (/clear, /compact, /logout, /login completion, cross-provider
5198// /model swap) doesn't race with the still-running stream.
5199//
5200// The wait is bounded; if the turn doesn't release within the timeout
5201// we proceed anyway. Worst case is a brief overlap that the agent's
5202// own mutex protects against.
5203func (i *Interactive) cancelAndWaitForIdle() {
5204	i.mu.Lock()
5205	busy := i.busy
5206	cancel := i.cancelTurn
5207	i.mu.Unlock()
5208	if !busy {
5209		return
5210	}
5211	if cancel != nil {
5212		cancel()
5213	}
5214	// ConfirmToolCall waits on its response channel rather than the turn
5215	// context, so cancellation must also resolve every pending prompt.
5216	i.confirmDialog.CancelAll("turn cancelled")
5217	deadline := time.Now().Add(2 * time.Second)
5218	for time.Now().Before(deadline) {
5219		i.mu.Lock()
5220		done := !i.busy
5221		i.mu.Unlock()
5222		if done {
5223			return
5224		}
5225		time.Sleep(10 * time.Millisecond)
5226	}
5227}
5228
5229// openBtwDialog opens the side-chat overlay with a frozen snapshot
5230// of the current main session. The optional argument is auto-
5231// submitted as the first question, so '/btw does X work?' fires the
5232// model call immediately instead of just opening an empty dialog.
5233func (i *Interactive) openBtwDialog(args []string) {
5234	if i.agent == nil {
5235		i.mu.Lock()
5236		i.statusErr = "not logged in. type /login first."
5237		i.mu.Unlock()
5238		return
5239	}
5240	seed := strings.TrimSpace(strings.Join(args, " "))
5241	i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), i.cfg.FlatTools, tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines, i.invalidate)
5242	i.invalidate()
5243}
5244
5245// submitOrQueuePrompt submits a slash command's expanded prompt immediately,
5246// or queues it behind the active turn using the normal text-only queue.
5247func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
5248	i.mu.Lock()
5249	if i.agent == nil {
5250		i.statusErr = "not logged in. type /login first."
5251		i.statusOK = ""
5252		i.mu.Unlock()
5253		i.invalidate()
5254		return
5255	}
5256	busy := i.busy
5257	compacting := i.compacting
5258	ag := i.agent
5259	i.mu.Unlock()
5260
5261	if busy {
5262		if !compacting {
5263			ag.QueueMessage(prompt)
5264		} else {
5265			i.mu.Lock()
5266			i.queued = append(i.queued, prompt)
5267			i.mu.Unlock()
5268		}
5269		i.invalidate()
5270		return
5271	}
5272	i.startTurn(ctx, prompt)
5273}
5274
5275// openSkillsDialog opens the skill inspector. The picker reflects
5276// whatever SkillSnapshot returns at call time, so edits to a
5277// SKILL.md made during a session show up on the next /skills.
5278func (i *Interactive) openSkillsDialog() {
5279	var list []*skills.Skill
5280	if i.cfg.SkillSnapshot != nil {
5281		list = i.cfg.SkillSnapshot()
5282	}
5283	i.skillsDialog.Open(list)
5284	i.invalidate()
5285}
5286
5287// openJumpDialog builds a /jump picker from the current transcript.
5288// If the user typed "/jump foo" with a filter and it matches exactly
5289// one turn, jump there directly without showing the dialog.
5290func (i *Interactive) openJumpDialog(args []string) {
5291	if i.view == nil || len(i.view.Messages) == 0 {
5292		i.mu.Lock()
5293		i.statusErr = "nothing to jump to \u2014 the session is empty"
5294		i.mu.Unlock()
5295		return
5296	}
5297	filter := strings.TrimSpace(strings.Join(args, " "))
5298	i.jumpDialog.Open(i.view.Messages, filter)
5299	// Shortcut: with a filter argument that matches exactly one turn,
5300	// jump immediately and skip the picker.
5301	if filter != "" {
5302		if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
5303			t := tgts[0]
5304			i.jumpDialog.Close()
5305			i.applyJumpSelection(t.MessageIdx, t.TurnNo)
5306		}
5307	}
5308}
5309
5310// applyJumpSelection scrolls the chat viewport so the user message at
5311// msgIdx is visible at (or near) the top of the chat area. Uses the
5312// anchor slice returned by view.BuildWithAnchors so the mapping from
5313// message index to row is exact, regardless of variable-height tool
5314// blocks above the target.
5315func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
5316	i.mu.Lock()
5317	defer i.mu.Unlock()
5318	cols := i.lastCols()
5319	// Older resumed messages otherwise have zero-row placeholder anchors.
5320	i.view.TailLimit = 0
5321	i.chatCacheValid = false
5322	_, anchors := i.view.BuildWithAnchors(cols)
5323	var row int
5324	found := false
5325	for _, a := range anchors {
5326		if a.MessageIdx == msgIdx {
5327			row = a.Row
5328			found = true
5329			break
5330		}
5331	}
5332	if !found {
5333		i.statusErr = "could not resolve jump target"
5334		return
5335	}
5336	if i.updateInfo.Available {
5337		row += len(renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols))
5338	}
5339	// Resolve against the final chat and bottom-band heights on redraw,
5340	// not the previous picker layout or the approximate keyboard page size.
5341	i.jumpRow = &row
5342	i.parkedTurn = turnNo
5343	i.parkedTotal = totalTurnsLocked(i.view.Messages)
5344	i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
5345	i.statusErr = ""
5346}
5347
5348// totalTurnsLocked counts user messages in the transcript. Caller is
5349// assumed to hold i.mu (the name is a mild reminder; this function
5350// itself doesn't touch shared state beyond the slice it's handed).
5351func totalTurnsLocked(msgs []provider.Message) int {
5352	n := 0
5353	for _, m := range msgs {
5354		if m.Role == provider.RoleUser {
5355			n++
5356		}
5357	}
5358	return n
5359}
5360
5361// applySessionDeletion permanently removes a session selected in /sessions.
5362// The active session is protected because unlinking its open file would lose
5363// subsequent transcript writes on Unix and behaves differently on Windows.
5364func (i *Interactive) applySessionDeletion(path string) {
5365	if i.cfg.CurrentSessionPath == nil {
5366		i.mu.Lock()
5367		i.statusErr = "session deletion is not wired in this build"
5368		i.statusOK = ""
5369		i.mu.Unlock()
5370		return
5371	}
5372	current := i.cfg.CurrentSessionPath()
5373	if current != "" && filepath.Clean(current) == filepath.Clean(path) {
5374		i.mu.Lock()
5375		i.statusErr = "cannot delete the active session; resume another session first"
5376		i.statusOK = ""
5377		i.mu.Unlock()
5378		return
5379	}
5380	if err := core.DeleteSession(path); err != nil {
5381		i.mu.Lock()
5382		i.statusErr = "delete session: " + err.Error()
5383		i.statusOK = ""
5384		i.mu.Unlock()
5385		return
5386	}
5387	i.sessionDialog.Remove(path)
5388	i.mu.Lock()
5389	i.statusOK = "deleted session: " + friendlyPath(path)
5390	i.statusErr = ""
5391	i.mu.Unlock()
5392}
5393
5394// applySessionSelection loads the given session via the cli-provided
5395// callback and snaps the viewport to the bottom (the latest message)
5396// so the user lands at the live tail of the resumed conversation.
5397func (i *Interactive) applySessionSelection(path string) {
5398	if i.cfg.LoadSession == nil {
5399		i.mu.Lock()
5400		i.statusErr = "session loading is not wired in this build"
5401		i.mu.Unlock()
5402		return
5403	}
5404	i.mu.Lock()
5405	if i.sessionLoading {
5406		i.statusErr = "already resuming a session"
5407		i.mu.Unlock()
5408		i.invalidate()
5409		return
5410	}
5411	i.sessionLoading = true
5412	i.statusOK = "resuming session: " + path
5413	i.statusErr = ""
5414	i.mu.Unlock()
5415	i.invalidate()
5416
5417	go func() {
5418		err := i.cfg.LoadSession(path)
5419		i.mu.Lock()
5420		defer i.mu.Unlock()
5421		i.sessionLoading = false
5422		if err != nil {
5423			i.statusErr = err.Error()
5424			i.statusOK = ""
5425			i.invalidate()
5426			return
5427		}
5428		i.statusOK = "resumed session: " + path
5429		i.statusErr = ""
5430		i.parkedTurn = 0
5431		i.parkedTotal = 0
5432		i.scrollOffset = 0
5433		// Fresh transcript swapped in: drop the auto-follow baseline so
5434		// the next render's follow guard doesn't see the wholesale
5435		// length change as a delta and jump the viewport.
5436		i.prevChatLen = 0
5437		i.prevChatCols = 0
5438		i.extNotes = nil
5439		i.view.InvalidateRenderCache()
5440		if i.agent != nil {
5441			i.view.Messages = i.agent.Messages()
5442			i.cumUsage = i.agent.Cost()
5443			if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
5444				i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
5445			} else {
5446				i.lastCtxInput = 0
5447			}
5448			// Snap to the tail again — the swap brought in a fresh
5449			// transcript whose markdown / chroma cost we don't want
5450			// blocking the redraw.
5451			if len(i.view.Messages) > initialResumeTailLimit {
5452				i.view.TailLimit = initialResumeTailLimit
5453			} else {
5454				i.view.TailLimit = 0
5455			}
5456		}
5457		i.invalidate()
5458	}()
5459}
5460
5461// scrollToLastTurn parks the viewport at the most recent user turn,
5462// or at the top if the transcript has no user messages. Used after
5463// resume so the user lands looking at where they left off.
5464func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
5465	if len(msgs) == 0 {
5466		i.mu.Lock()
5467		i.scrollOffset = 0
5468		i.mu.Unlock()
5469		return
5470	}
5471	// Find the last user message index.
5472	lastUser := -1
5473	turnNo, totalTurns := 0, 0
5474	for idx, m := range msgs {
5475		if m.Role == provider.RoleUser {
5476			totalTurns++
5477			lastUser = idx
5478		}
5479	}
5480	if lastUser < 0 {
5481		i.mu.Lock()
5482		i.scrollOffset = 0
5483		i.mu.Unlock()
5484		return
5485	}
5486	turnNo = totalTurns
5487
5488	cols := i.lastCols()
5489	chat, anchors := i.view.BuildWithAnchors(cols)
5490	var row int
5491	found := false
5492	for _, a := range anchors {
5493		if a.MessageIdx == lastUser {
5494			row = a.Row
5495			found = true
5496			break
5497		}
5498	}
5499	if !found {
5500		i.mu.Lock()
5501		i.scrollOffset = 0
5502		i.mu.Unlock()
5503		return
5504	}
5505
5506	chatLen := len(chat)
5507	page := i.chatPage()
5508	if page < 1 {
5509		page = 1
5510	}
5511	offset := chatLen - (row + page)
5512	if offset < 0 {
5513		offset = 0
5514	}
5515	maxOffset := chatLen - page
5516	if maxOffset < 0 {
5517		maxOffset = 0
5518	}
5519	if offset > maxOffset {
5520		offset = maxOffset
5521	}
5522
5523	i.mu.Lock()
5524	i.scrollOffset = offset
5525	// Mark the parked-turn footer so the user sees "viewing turn N of
5526	// M - pgdn to catch up" — same affordance as /jump. Tells them at
5527	// a glance that they're looking at history, not the live tail.
5528	if offset > 0 {
5529		i.parkedTurn = turnNo
5530		i.parkedTotal = totalTurns
5531	}
5532	i.mu.Unlock()
5533	i.invalidate()
5534}
5535
5536func (i *Interactive) applyModelSelection(prov, model string) {
5537	i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
5538}
5539
5540// applyRescueModelSelection is like applyModelSelection but routes
5541// through BuildAgentForRescue so launch-time --api-key / --base-url
5542// overrides are dropped before the new agent is built. Falls back to
5543// the regular builder when the host doesn't wire a rescue builder.
5544func (i *Interactive) applyRescueModelSelection(prov, model string) {
5545	builder := i.cfg.BuildAgentForRescue
5546	if builder == nil {
5547		builder = i.cfg.BuildAgentFor
5548	}
5549	i.swapModel(prov, model, builder, true)
5550}
5551
5552// swapModel applies a /model selection (or a rescue selection) using
5553// the supplied builder. rescue=true tags the success message so the
5554// user can see that launch-time overrides were ignored.
5555func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
5556	if model == "" {
5557		return
5558	}
5559	m, err := provider.FindModel(prov, model)
5560	if err != nil {
5561		i.mu.Lock()
5562		i.statusErr = err.Error()
5563		i.mu.Unlock()
5564		return
5565	}
5566	// Same provider AND not a rescue retry: just swap the model on
5567	// the existing agent. Mixed-API providers dispatch from model metadata,
5568	// so the client remains reusable. Rescue retries always rebuild so a stale
5569	// auth header / base URL can't carry over.
5570	if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
5571		i.mu.Lock()
5572		i.cfg.Model = m.ID
5573		i.agent.Model = m.ID
5574		i.statusOK = "model: " + m.ID
5575		i.statusErr = ""
5576		i.mu.Unlock()
5577		if i.cfg.PersistModel != nil {
5578			i.cfg.PersistModel(i.cfg.Provider, m.ID)
5579		}
5580		return
5581	}
5582	if builder == nil {
5583		i.mu.Lock()
5584		i.statusErr = "cannot switch provider: no builder configured"
5585		i.mu.Unlock()
5586		return
5587	}
5588	// Snapshot the current transcript and cumulative usage BEFORE we
5589	// build the replacement agent so we can hand them off. Without
5590	// this the user perceives the entire session as wiped on a
5591	// cross-provider /model swap.
5592	var carryMsgs []provider.Message
5593	var carryCost provider.Usage
5594	if i.agent != nil {
5595		carryMsgs = i.agent.Messages()
5596		carryCost = i.agent.Cost()
5597	}
5598
5599	ag, p, md, err := builder(m.Provider, m.ID)
5600	if err != nil {
5601		i.mu.Lock()
5602		i.statusErr = err.Error()
5603		i.mu.Unlock()
5604		return
5605	}
5606
5607	// Replay the transcript and seed the cost on the freshly-built
5608	// agent. Messages travel cleanly between providers because they
5609	// use the same provider.Message shape; tool-call ids are local
5610	// to a turn so cross-provider continuation never confuses the
5611	// new model (it just sees the assistant's reply, no orphan
5612	// tool_use blocks because /model swaps are gated to idle state).
5613	if len(carryMsgs) > 0 {
5614		ag.SetMessages(carryMsgs)
5615	}
5616	ag.SeedCost(carryCost)
5617
5618	i.mu.Lock()
5619	i.agent = ag
5620	i.cfg.Provider = p
5621	i.cfg.Model = md
5622	if rescue {
5623		i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
5624	} else {
5625		i.statusOK = "switched to " + p + " / " + md
5626	}
5627	i.statusErr = ""
5628	// Render cache keys are width+content based, so the new agent's
5629	// identical messages will reuse the existing entries. Nothing
5630	// to invalidate.
5631	i.mu.Unlock()
5632	// The new agent was built off the base tool registry, so any
5633	// dynamically-registered tools need to be reattached. The apply
5634	// helpers are no-ops when their feature is inactive, so the
5635	// cross-provider path still works on a vanilla setup.
5636	i.applyAutoSwarmTool(i.autoSwarmEnabled())
5637	i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
5638	i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5639	if i.cfg.PersistModel != nil {
5640		i.cfg.PersistModel(p, md)
5641	}
5642}
5643
5644func (i *Interactive) handleAuthEvent(ev auth.Event) {
5645	switch ev.Kind {
5646	case "started":
5647		i.dialog.ShowWaiting(ev.URL)
5648	case "browser_open":
5649		// no-op
5650	case "error":
5651		i.dialog.ShowResult(false, ev.Message)
5652	case "success":
5653		// Rebuild the agent with the fresh credential.
5654		ag, prov, model, err := i.cfg.BuildAgent()
5655		if err != nil {
5656			i.dialog.ShowResult(false, err.Error())
5657			return
5658		}
5659		i.mu.Lock()
5660		i.agent = ag
5661		i.cfg.Provider = prov
5662		i.cfg.Model = model
5663		i.statusErr = ""
5664		i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
5665		i.mu.Unlock()
5666		i.applyAutoSwarmTool(i.autoSwarmEnabled())
5667		i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
5668		i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5669		i.dialog.ShowResult(true, "")
5670	}
5671}
5672
5673// clearPendingCompactTurnLocked drops work that only makes sense after the
5674// current compaction. The caller must hold i.mu.
5675func (i *Interactive) clearPendingCompactTurnLocked() {
5676	i.pendingCompactPrompt = ""
5677	i.pendingCompactImages = nil
5678	i.hasPendingCompactPrompt = false
5679	i.continueAfterCompact = false
5680	i.pendingPostCompactNote = ""
5681}
5682
5683// runCompact invokes core.Agent.Compact and reflects the progress in
5684// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
5685// cancel via the same cancelTurn channel used for normal turns.
5686//
5687// When auto is true the spinner message is pinned to "condensing
5688// history" and the status bar surfaces "(auto)" next to the context
5689// percentage so it's obvious the system triggered this, not the user.
5690func (i *Interactive) runCompact(parent context.Context, auto bool) {
5691	if i.agent == nil {
5692		i.mu.Lock()
5693		i.statusErr = "not logged in. type /login first."
5694		i.mu.Unlock()
5695		return
5696	}
5697	ctx, cancel := context.WithCancel(parent)
5698	i.mu.Lock()
5699	i.busy = true
5700	i.compacting = true
5701	if auto {
5702		i.spin.StartFixed("condensing history")
5703		i.autoCompacting = true
5704	} else {
5705		i.spin.StartFixed("compacting")
5706	}
5707	i.cancelTurn = cancel
5708	i.statusErr = ""
5709	i.statusOK = ""
5710	// Do NOT set streamOn: the summary text should not be visible
5711	// in the chat while compacting. The user just sees the spinner
5712	// and can keep typing / queue prompts.
5713	i.scrollOffset = 0
5714	i.helpBlock = nil
5715	i.mu.Unlock()
5716	i.invalidate()
5717
5718	go func() {
5719		// Sink discards deltas — we don't stream the summary to the UI.
5720		sink := func(delta string) {}
5721		summary, err := i.agent.Compact(ctx, 4, sink)
5722		_ = summary
5723		i.mu.Lock()
5724		i.busy = false
5725		i.compacting = false
5726		i.resetStreamingStateLocked()
5727		i.cancelTurn = nil
5728		i.autoCompacting = false
5729
5730		// Drain pending work after the transcript is clean. Overflow recovery
5731		// continues the user message already in the transcript. Pre-turn
5732		// compaction preserves its complete text-and-images request. Regular
5733		// prompts typed during compaction remain in the host queue.
5734		var next string
5735		var nextImages []provider.ImageBlock
5736		var hasNext bool
5737		var continueExisting bool
5738
5739		switch {
5740		case err != nil && ctx.Err() != nil:
5741			i.statusErr = ""
5742			if auto {
5743				i.statusOK = "auto-condense cancelled"
5744			} else {
5745				i.statusOK = "compaction cancelled"
5746			}
5747			i.queued = nil // drop queue on cancel
5748			i.clearPendingCompactTurnLocked()
5749			if i.agent != nil {
5750				i.agent.DrainQueuedMessages()
5751			}
5752		case err != nil:
5753			i.statusErr = "compaction failed: " + err.Error()
5754			i.statusOK = ""
5755			i.queued = nil // drop queue on error
5756			i.clearPendingCompactTurnLocked()
5757			if i.agent != nil {
5758				i.agent.DrainQueuedMessages()
5759			}
5760		default:
5761			i.statusErr = ""
5762			// Read token count from the compaction message meta.
5763			tokens := ""
5764			msgs := i.agent.Messages()
5765			if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
5766				tokens = msgs[0].Meta["tokens_before"]
5767			}
5768			switch {
5769			case i.pendingPostCompactNote != "":
5770				i.statusOK = i.pendingPostCompactNote
5771			case tokens != "":
5772				i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
5773			default:
5774				i.statusOK = "compacted (ctrl+o to expand)"
5775			}
5776			i.pendingPostCompactNote = ""
5777			i.extNotes = stripAutoCompactNotes(i.extNotes)
5778			i.lastCtxInput = estimateTimelineMessageTokens(msgs)
5779			i.toolCalls = map[string]*tui.ToolCallView{}
5780			i.toolOrder = nil
5781			i.toolGate = map[string]int{}
5782			i.view.InvalidateRenderCache()
5783			switch {
5784			case i.continueAfterCompact:
5785				continueExisting = true
5786				i.continueAfterCompact = false
5787			case i.hasPendingCompactPrompt:
5788				next = i.pendingCompactPrompt
5789				nextImages = i.pendingCompactImages
5790				i.pendingCompactPrompt = ""
5791				i.pendingCompactImages = nil
5792				i.hasPendingCompactPrompt = false
5793				hasNext = true
5794			case len(i.queued) > 0:
5795				next, i.queued = i.queued[0], i.queued[1:]
5796				hasNext = true
5797			}
5798			// A prompt resumed immediately after compaction must not trigger
5799			// the pre-turn threshold guard again before fresh provider usage
5800			// replaces this estimate.
5801			if hasNext {
5802				i.lastCtxInput = 0
5803			}
5804		}
5805		i.mu.Unlock()
5806		i.invalidate()
5807
5808		if hasNext || continueExisting {
5809			p := i.runCtx
5810			if p == nil {
5811				p = context.Background()
5812			}
5813			if continueExisting {
5814				i.startTurnRequest(p, "", nil, true)
5815			} else {
5816				i.startTurnWithImages(p, next, nextImages)
5817			}
5818		}
5819	}()
5820}
5821
5822// shellEscapeCommand reports whether text is a "!command" shell
5823// escape and, if so, returns the command with the leading '!' (and
5824// surrounding whitespace) stripped. A bare "!" with no command is
5825// treated as not an escape so it falls through to the normal prompt
5826// path rather than running an empty shell.
5827func shellEscapeCommand(text string) (string, bool) {
5828	return ShellEscapeCommand(text)
5829}
5830
5831// ShellEscapeCommand reports whether text is a "!command" shell
5832// escape and, if so, returns the command with the leading '!' (and
5833// surrounding whitespace) stripped. A bare "!" with no command is
5834// treated as not an escape so it falls through to the normal prompt
5835// path rather than running an empty shell.
5836func ShellEscapeCommand(text string) (string, bool) {
5837	trimmed := strings.TrimLeft(text, " \t")
5838	if !strings.HasPrefix(trimmed, "!") {
5839		return "", false
5840	}
5841	cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
5842	if cmd == "" {
5843		return "", false
5844	}
5845	return cmd, true
5846}
5847
5848// startShellEscape runs a "!command" in the same shell the bash tool
5849// uses, in the session working directory, honoring the /jail sandbox.
5850// It shares the busy/cancel state with the agent: esc cancels it, and
5851// it refuses to start while a turn or another shell escape is already
5852// in flight. Its terminal-log output is appended to the transcript as
5853// user context without automatically starting a model turn.
5854func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5855	i.mu.Lock()
5856	if i.busy || i.shellRunning {
5857		i.statusErr = "busy — wait for the current turn to finish before running a shell command"
5858		i.statusOK = ""
5859		i.mu.Unlock()
5860		i.invalidate()
5861		return
5862	}
5863	if parent == nil {
5864		parent = i.runCtx
5865	}
5866	if parent == nil {
5867		parent = context.Background()
5868	}
5869	ctx, cancel := context.WithCancel(parent)
5870	i.busy = true
5871	i.shellRunning = true
5872	i.shellLive = "$ " + cmd + "\n\n"
5873	i.cancelTurn = cancel
5874	i.statusErr = ""
5875	i.statusOK = ""
5876	i.spin.StartFixed("running shell command")
5877	// Clear stale extension notes the same way a new turn would so the
5878	// screen doesn't accumulate transient state.
5879	i.extNotes = nil
5880	i.scrollOffset = 0
5881	i.parkedTurn = 0
5882	i.parkedTotal = 0
5883	i.helpBlock = nil
5884	sandbox := i.cfg.Sandbox
5885	cwd := i.cfg.CWD
5886	i.mu.Unlock()
5887	i.invalidate()
5888
5889	go func() {
5890		defer cancel()
5891		raw, _ := json.Marshal(map[string]any{"command": cmd})
5892		bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
5893		progress := func(chunk string) {
5894			i.mu.Lock()
5895			i.shellLive += chunk
5896			i.mu.Unlock()
5897			i.invalidate()
5898		}
5899		res, err := bash.Execute(ctx, raw, progress)
5900
5901		var out string
5902		if err != nil {
5903			out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
5904		} else {
5905			for _, c := range res.Content {
5906				if tb, ok := c.(provider.TextBlock); ok {
5907					out += tb.Text
5908				}
5909			}
5910		}
5911		cancelled := ctx.Err() != nil
5912		failed := err != nil || res.IsError || cancelled
5913		if cancelled {
5914			out += "\n\n[cancelled]"
5915		}
5916
5917		if i.agent != nil {
5918			i.agent.AppendUserContext(out, map[string]string{
5919				shellEscapeMetaKey:        "true",
5920				shellEscapeCommandMetaKey: cmd,
5921			})
5922		}
5923
5924		i.mu.Lock()
5925		i.shellRunning = false
5926		i.shellLive = ""
5927		i.busy = false
5928		i.cancelTurn = nil
5929		awaitingPre := i.awaitingStartupPre
5930		if failed {
5931			if cancelled {
5932				i.statusErr = "shell command cancelled"
5933			} else {
5934				i.statusErr = "shell command failed"
5935			}
5936			i.statusOK = ""
5937		} else {
5938			i.statusOK = "shell command finished"
5939			i.statusErr = ""
5940		}
5941		i.mu.Unlock()
5942		i.invalidate()
5943		if awaitingPre {
5944			i.completeStartupPre()
5945		}
5946	}()
5947}
5948
5949func (i *Interactive) startTurn(parent context.Context, prompt string) {
5950	i.startTurnWithImages(parent, prompt, nil)
5951}
5952
5953func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
5954	i.startTurnRequest(parent, prompt, images, false)
5955}
5956
5957func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
5958	if i.agent == nil {
5959		// Text startup pre cannot run without credentials; continue so
5960		// deferred InitialInput (pre-fill or auto-submit) still applies.
5961		i.mu.Lock()
5962		awaitingPre := i.awaitingStartupPre
5963		i.mu.Unlock()
5964		if awaitingPre {
5965			i.completeStartupPre()
5966		}
5967		return
5968	}
5969	// Pre-turn safety: if the most recent context measurement is
5970	// already past the auto-compact threshold, condense before
5971	// sending so the next outbound request stays under the limit.
5972	// The condense flow re-fires the user's queued prompt for us, so
5973	// we just hand it off and exit.
5974	i.mu.Lock()
5975	needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
5976	if needsPreCompact {
5977		i.pendingCompactPrompt = prompt
5978		i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
5979		i.hasPendingCompactPrompt = true
5980		i.statusErr = ""
5981		i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
5982		i.pendingPostCompactNote = "context auto-compacted; sending your last message"
5983		i.mu.Unlock()
5984		i.invalidate()
5985		i.runCompact(parent, true)
5986		return
5987	}
5988	i.mu.Unlock()
5989
5990	ctx, cancel := context.WithCancel(parent)
5991	i.mu.Lock()
5992	i.busy = true
5993	i.spin.Start()
5994	i.cancelTurn = cancel
5995	i.statusErr = ""
5996	i.statusOK = ""
5997	i.streaming.Reset()
5998	i.streamOn = true
5999	i.toolCalls = map[string]*tui.ToolCallView{}
6000	i.toolOrder = nil
6001	i.toolGate = map[string]int{}
6002	i.extNotes = nil   // ext notes are one-shot; a new prompt clears them
6003	i.scrollOffset = 0 // jump back to the bottom on new turn
6004	// Lift the resume tail cap once the user starts interacting. The
6005	// cap is purely a first-paint optimization (don't markdown the
6006	// whole history before showing anything). Keeping it active during
6007	// a turn makes the rendered chat a sliding window: appended
6008	// messages push older ones off the TOP of the buffer, which the
6009	// renderer must treat as a change above the viewport and repaint
6010	// fully, snapping the terminal's native scrollback to the bottom on
6011	// every streamed chunk. A fresh session has no cap (append-only),
6012	// which is why the jump only shows up in resumed sessions. Dropping
6013	// the cap here makes resumed turns append-only too.
6014	i.view.TailLimit = 0
6015	// Reset the auto-follow baseline so the very next render at
6016	// interactive.go:1053 doesn't see a synthetic shrink between
6017	// "last frame had the previous turn's tool overlay" and
6018	// "this frame had it cleared above". Without this, the guard
6019	// reads delta = -(rows in cleared overlay) and decrements
6020	// scrollOffset, which on terminals that mirror zot's pane
6021	// scroll into the host scrollbar visibly yanks the viewport.
6022	// See autofollow_shrink_test.go for the exact arithmetic.
6023	i.prevChatLen = 0
6024	i.prevChatCols = 0
6025	i.parkedTurn = 0 // starting a turn clears the /jump parked state
6026	i.parkedTotal = 0
6027	i.helpBlock = nil // hide the help block once the user asks something
6028	i.mu.Unlock()
6029	i.invalidate()
6030
6031	sink := func(ev core.AgentEvent) {
6032		i.handleEvent(ev)
6033		i.invalidate()
6034	}
6035
6036	go func() {
6037		var err error
6038		if overflowRecoveryAttempted {
6039			err = i.agent.Continue(ctx, sink)
6040		} else {
6041			err = i.agent.Prompt(ctx, prompt, images, sink)
6042		}
6043		i.mu.Lock()
6044		i.busy = false
6045		// Don't touch streamPending / streamFlushPending here — the
6046		// pacer may still be draining the final deltas and needs to
6047		// paint them even though Prompt has returned. It will reset
6048		// streamOn on its own once the buffer empties.
6049		if len(i.streamPending) == 0 {
6050			i.streamOn = false
6051		}
6052		i.cancelTurn = nil
6053		if err != nil && ctx.Err() == nil {
6054			i.statusErr = err.Error()
6055		}
6056		// Decide whether to offer a model rescue picker for recoverable
6057		// provider failures (auth/rate/temporary). The picker opens after
6058		// the mutex is released so it can take its own locks freely.
6059		var (
6060			offer       bool
6061			rescueWhy   string
6062			rescueImgs  []provider.ImageBlock
6063			rescueModel string
6064			rescueProv  string
6065			rescueFprov string
6066		)
6067		if err != nil && ctx.Err() == nil {
6068			if ok, reason := classifyRescueError(err); ok {
6069				offer = true
6070				rescueWhy = reason
6071				rescueImgs = images
6072				rescueModel = i.cfg.Model
6073				rescueProv = i.cfg.Provider
6074				rescueFprov = extractFailedProvider(err)
6075				if rescueFprov == "" {
6076					rescueFprov = i.cfg.Provider
6077				}
6078				// Suppress the red banner — the rescue dialog already
6079				// surfaces the failure.
6080				i.statusErr = ""
6081			}
6082		}
6083		// Detect responses that reject the current context, either as an
6084		// HTTP 413 payload limit or a model context-window error. Token-
6085		// based auto-compact can miss both when metadata is stale or the
6086		// limit is measured in raw bytes. Compact once, then continue the
6087		// user message already present in the transcript.
6088		contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)
6089		recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
6090		if recoverContextOverflow {
6091			i.statusErr = ""
6092			i.continueAfterCompact = true
6093			i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
6094			i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
6095		}
6096		// Persist the assistant's reply (and every tool row before
6097		// it) to the session file while the turn memory is hot.
6098		// Without this, WriteNewTranscript only fires at zot exit,
6099		// meaning a crash or ungraceful kill drops the whole
6100		// conversation. FlushSession is idempotent (it advances the
6101		// baseline so subsequent flushes only write new rows).
6102		flush := i.cfg.FlushSession
6103		i.mu.Unlock()
6104		if flush != nil {
6105			flush()
6106		}
6107		i.mu.Lock()
6108		awaitingPre := i.awaitingStartupPre
6109		// Pop the next queued message, if any, and relaunch.
6110		var next string
6111		var hasNext bool
6112		if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
6113			next, i.queued = i.queued[0], i.queued[1:]
6114			hasNext = true
6115		}
6116		// If the turn was cancelled or errored, drop the queue so the
6117		// user isn't bombarded with stale messages after an interrupt.
6118		if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
6119			i.queued = nil
6120			if i.agent != nil {
6121				i.agent.DrainQueuedMessages()
6122			}
6123		}
6124		// Decide whether the next thing to do is an auto-compaction.
6125		// Only fires when the turn completed cleanly AND no host-side
6126		// or agent-side queued messages are waiting (otherwise a queued
6127		// message would race the condense).
6128		agentQueued := 0
6129		if i.agent != nil {
6130			agentQueued = i.agent.QueuedMessageCount()
6131		}
6132		shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
6133		i.mu.Unlock()
6134		i.invalidate()
6135		parent := i.runCtx
6136		if parent == nil {
6137			parent = context.Background()
6138		}
6139		if awaitingPre {
6140			i.completeStartupPre()
6141			return
6142		}
6143		switch {
6144		case hasNext:
6145			i.startTurn(parent, next)
6146		case offer:
6147			i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
6148		case recoverContextOverflow:
6149			i.runCompact(parent, true)
6150		case shouldAutoCompact:
6151			i.runCompact(parent, true)
6152		}
6153	}()
6154}
6155
6156// openRescueDialog surfaces the rescue model picker after a recoverable
6157// provider failure. The pending prompt + images are stashed on the
6158// Interactive so a later applyRescueSelection can re-run the same turn
6159// against the freshly-picked model. activeProvider/failedProvider are
6160// usually the same, but some clients embed different prefixes in their
6161// errors than the configured provider id, so we accept both.
6162func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
6163	if i.rescueDialog == nil {
6164		return
6165	}
6166	loggedIn := []string{}
6167	if i.cfg.LoggedInProviders != nil {
6168		loggedIn = i.cfg.LoggedInProviders()
6169	}
6170	fprov := failedProvider
6171	if fprov == "" {
6172		fprov = activeProvider
6173	}
6174	i.mu.Lock()
6175	i.pendingRescuePrompt = prompt
6176	i.pendingRescueImages = images
6177	i.mu.Unlock()
6178	i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
6179	i.invalidate()
6180}
6181
6182// applyRescueSelection switches model (cross-provider if needed) and
6183// re-runs the same prompt+images that just failed. Mirrors
6184// applyModelSelection's transcript-carry logic so the user keeps full
6185// session continuity across the swap.
6186func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
6187	if model == "" {
6188		return
6189	}
6190	i.applyRescueModelSelection(prov, model)
6191	i.mu.Lock()
6192	images := i.pendingRescueImages
6193	if prompt == "" {
6194		prompt = i.pendingRescuePrompt
6195	}
6196	i.pendingRescuePrompt = ""
6197	i.pendingRescueImages = nil
6198	i.mu.Unlock()
6199	parent := i.runCtx
6200	if parent == nil {
6201		parent = context.Background()
6202	}
6203	i.startTurnWithImages(parent, prompt, images)
6204}
6205
6206func stripAutoCompactNotes(notes []string) []string {
6207	if len(notes) == 0 {
6208		return notes
6209	}
6210	out := notes[:0]
6211	for _, n := range notes {
6212		if strings.Contains(n, "condensing history") {
6213			continue
6214		}
6215		out = append(out, n)
6216	}
6217	return out
6218}
6219
6220// autoCompactNoteLine returns a styled chat-area note for the
6221// inline auto-compact heads-up. Lives in extNotes so it survives
6222// the busy-spinner overwrite of the status row.
6223func autoCompactNoteLine(th tui.Theme, msg string) string {
6224	return "  " + th.FG256(th.Warning, "⚠ "+msg)
6225}
6226
6227// isContextOverflowError matches provider responses that reject the
6228// current request because its raw payload or tokenized input is too
6229// large. Providers surface this as either HTTP 413 or semantic context
6230// errors with differing codes and messages.
6231func isContextOverflowError(err error) bool {
6232	if err == nil {
6233		return false
6234	}
6235	msg := strings.ToLower(err.Error())
6236	return strings.Contains(msg, "http 413") ||
6237		strings.Contains(msg, " 413") ||
6238		strings.HasPrefix(msg, "413 ") ||
6239		strings.Contains(msg, "payload too large") ||
6240		strings.Contains(msg, "request entity too large") ||
6241		strings.Contains(msg, "input exceeds the context window") ||
6242		strings.Contains(msg, "context window exceeded") ||
6243		strings.Contains(msg, "maximum context length") ||
6244		strings.Contains(msg, "context_length_exceeded") ||
6245		(strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
6246}
6247
6248const defaultAutoCompactThreshold = 85
6249
6250func normalizeAutoCompactThreshold(threshold *int) int {
6251	if threshold == nil {
6252		return defaultAutoCompactThreshold
6253	}
6254	switch *threshold {
6255	case 0, 70, 80, 85, 90:
6256		return *threshold
6257	default:
6258		return defaultAutoCompactThreshold
6259	}
6260}
6261
6262func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
6263	if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
6264		return false
6265	}
6266	return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
6267}
6268
6269// shouldAutoCompactLocked reports whether the last turn pushed context
6270// usage past the auto-compact threshold. Must be called with i.mu
6271// held; it reads lastCtxInput and the current model's context window.
6272func (i *Interactive) shouldAutoCompactLocked() bool {
6273	if i.agent == nil {
6274		return false
6275	}
6276	if i.autoCompacting {
6277		return false
6278	}
6279	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
6280	if err != nil || m.ContextWindow <= 0 {
6281		return false
6282	}
6283	threshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
6284	return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
6285}
6286
6287func (i *Interactive) handleEvent(ev core.AgentEvent) {
6288	i.mu.Lock()
6289	defer i.mu.Unlock()
6290	switch e := ev.(type) {
6291	case core.EvAssistantStart:
6292		// Fires at the top of every oneTurn, including follow-up
6293		// turns after tool use. Without this, the streaming buffer
6294		// is still marked off from the previous assistant message
6295		// and the final summary text pops in all at once instead
6296		// of typewriter-streaming delta by delta.
6297		i.streaming.Reset()
6298		i.streamPending = i.streamPending[:0]
6299		i.streamFlushPending = false
6300		i.streamOn = true
6301		// Clear the live tool-call overlay. Any tools from the
6302		// previous round are now fully folded into the transcript
6303		// (assistant tool_use block + tool role message with the
6304		// result), so keeping them in the overlay would duplicate
6305		// them in the view — once inside the finalised transcript
6306		// and once below the streaming block, with the streaming
6307		// summary sandwiched in between. The next EvToolUseStart
6308		// will populate fresh entries for this turn's tools.
6309		i.toolCalls = map[string]*tui.ToolCallView{}
6310		i.toolOrder = nil
6311		i.toolGate = map[string]int{}
6312	case core.EvTextDelta:
6313		// Buffer into streamPending; the paintPace ticker drains
6314		// it into i.streaming a few runes at a time for a smooth
6315		// typewriter effect independent of upstream chunk size.
6316		i.streamPending = append(i.streamPending, []rune(e.Delta)...)
6317		i.streamOn = true
6318	case core.EvAssistantMessage:
6319		// OnAssistant + telegram mirroring always fire on message
6320		// arrival — they read the FINAL message content, which is
6321		// complete regardless of what's still in the pacer.
6322		i.assistantMessageSideEffects(e.Message)
6323		// If the pacer still has characters to drain, keep streamOn
6324		// true and mark flush pending; the paintPace ticker will
6325		// drain the remainder and reset streaming state when done.
6326		// Otherwise (rare: full-replay sessions, abort paths) clear
6327		// synchronously so a later render doesn't show stale text.
6328		if len(i.streamPending) > 0 {
6329			i.streamFlushPending = true
6330			return
6331		}
6332		i.resetStreamingStateLocked()
6333	case core.EvToolUseStart:
6334		// Live streaming: pre-create the view so the user sees the
6335		// tool call being composed in real time. Any subsequent
6336		// EvToolCall for the same ID updates the same struct (the
6337		// final parsed args + name are already known here).
6338		if _, exists := i.toolCalls[e.ID]; !exists {
6339			i.toolCalls[e.ID] = &tui.ToolCallView{
6340				ID:        e.ID,
6341				Name:      e.Name,
6342				Streaming: true,
6343			}
6344			i.toolOrder = append(i.toolOrder, e.ID)
6345			i.gateToolLocked(e.ID)
6346		}
6347	case core.EvToolUseArgs:
6348		if tc, ok := i.toolCalls[e.ID]; ok {
6349			tc.RawJSONBuf += e.Delta
6350			// Refresh the live path as soon as it parses; used in
6351			// the header (write /Users/pat/Desktop/demo.ts)
6352			// while the content is still streaming.
6353			if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
6354				tc.LivePath = p
6355			} else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
6356				tc.LivePath = p
6357			}
6358		}
6359	case core.EvToolUseEnd:
6360		if tc, ok := i.toolCalls[e.ID]; ok {
6361			tc.Streaming = false
6362		}
6363	case core.EvToolCall:
6364		// If we already pre-created the view during streaming, just
6365		// refresh the final Args summary. Otherwise create a new one
6366		// (non-streaming providers or legacy paths).
6367		if tc, ok := i.toolCalls[e.ID]; ok {
6368			tc.Args = tui.ShortArgs(e.Name, e.Args)
6369			if tc.RawJSONBuf == "" {
6370				tc.RawJSONBuf = string(e.Args)
6371			}
6372			tc.Streaming = false
6373		} else {
6374			i.toolCalls[e.ID] = &tui.ToolCallView{
6375				ID:         e.ID,
6376				Name:       e.Name,
6377				Args:       tui.ShortArgs(e.Name, e.Args),
6378				RawJSONBuf: string(e.Args),
6379			}
6380			i.toolOrder = append(i.toolOrder, e.ID)
6381			i.gateToolLocked(e.ID)
6382		}
6383	case core.EvToolResult:
6384		if tc, ok := i.toolCalls[e.ID]; ok {
6385			tc.Done = true
6386			tc.Error = e.Result.IsError
6387			tc.Preview = ""
6388			var text strings.Builder
6389			for _, c := range e.Result.Content {
6390				if tb, ok := c.(provider.TextBlock); ok {
6391					if text.Len() > 0 {
6392						text.WriteString("\n")
6393					}
6394					text.WriteString(tb.Text)
6395				}
6396			}
6397			tc.Result = text.String()
6398		}
6399		if i.cfg.OnToolResult != nil {
6400			i.cfg.OnToolResult(e.ID, e.Result)
6401		}
6402	case core.EvUsage:
6403		i.cumUsage = e.Cumulative
6404		if e.Usage.InputTokens > 0 {
6405			i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
6406		}
6407	case core.EvTurnEnd:
6408		if e.Stop == provider.StopAborted {
6409			i.resetStreamingStateLocked()
6410			i.statusErr = ""
6411			i.statusOK = "cancelled"
6412			return
6413		}
6414		if e.Stop == provider.StopLength {
6415			// The model hit its output-token cap mid-response, so the
6416			// reply (often a long write/edit) is truncated. Surface it
6417			// explicitly, otherwise the turn just ends and reads like
6418			// the UI gave up. The agent already requests the model's
6419			// full MaxOutput budget, so this means the response genuinely
6420			// exceeded that ceiling; ask the user to continue.
6421			i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
6422			i.statusOK = ""
6423			return
6424		}
6425		// Don't surface mid-loop stream errors as a red banner here.
6426		// EvTurnEnd fires after every step in a multi-step tool loop,
6427		// so a transient 503 / network blip would briefly paint a red
6428		// banner over the still-streaming chat before the agent loop
6429		// either retries or exits. The final error (if any) is set by
6430		// startTurnWithImages once Prompt() returns, and recoverable
6431		// failures are routed to the rescue picker instead — which
6432		// keeps the chat clean while the agent is working.
6433		_ = e.Err
6434	}
6435}
6436
6437// Agent returns the current agent, if any. Used by cli.go to flush the
6438// final transcript to the session file.
6439func (i *Interactive) Agent() *core.Agent {
6440	i.mu.Lock()
6441	defer i.mu.Unlock()
6442	return i.agent
6443}
6444
6445// silence unused import in some build configs
6446var _ = fmt.Sprintf
6447
6448// runReloadExt triggers a live reload of every extension (discovered
6449// + explicit). Runs on a goroutine so the TUI stays responsive; the
6450// Manager.Reload takes a couple of hundred ms to shut down subprocs
6451// and respawn them. Shows a temporary status line throughout.
6452func (i *Interactive) runReloadExt(ctx context.Context) {
6453	if i.cfg.Extensions == nil {
6454		i.mu.Lock()
6455		i.statusErr = "no extension manager in this build"
6456		i.mu.Unlock()
6457		i.invalidate()
6458		return
6459	}
6460	i.mu.Lock()
6461	i.statusOK = "reloading extensions..."
6462	i.statusErr = ""
6463	i.mu.Unlock()
6464	i.invalidate()
6465
6466	go func() {
6467		stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
6468		msg, failed := formatReloadStatus(stats)
6469		seq := i.setReloadStatus(msg, failed)
6470		i.invalidate()
6471		go i.dismissReloadStatus(ctx, seq, msg, failed)
6472	}()
6473}
6474
6475// Confirm implements core.Confirmer. The agent goroutine calls
6476// this synchronously before every tool invocation when --no-yolo is
6477// active. We push the request onto the confirmDialog queue, trigger
6478// a redraw, and block the caller until the user answers.
6479//
6480// If the session is cancelled or the TUI exits mid-prompt, any
6481// pending request is refused via CancelAll so the agent doesn't
6482// deadlock.
6483func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
6484	return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
6485}
6486
6487// ConfirmToolCall attaches a side-effect-free preview to the matching live
6488// tool panel, then blocks until the user approves or refuses the call.
6489func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
6490	resp := make(chan core.ConfirmDecision, 1)
6491	returnToChild := false
6492	if call.ID != "" {
6493		i.mu.Lock()
6494		if tc, ok := i.toolCalls[call.ID]; ok {
6495			tc.Args = call.Summary
6496			tc.Preview = call.Content
6497		}
6498		i.mu.Unlock()
6499		if i.btwDialog != nil && i.btwDialog.Active() {
6500			returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content)
6501		}
6502	}
6503	i.confirmDialog.Enqueue(&confirmRequest{
6504		toolName:      call.Name,
6505		preview:       call.Summary,
6506		resp:          resp,
6507		returnToChild: returnToChild,
6508	})
6509	i.invalidate()
6510	return <-resp
6511}
6512
6513// openTelegramDialog shows the picker for `/telegram` with no arg.
6514// Items depend on current state: disconnect + status when running,
6515// connect + status when stopped.
6516func (i *Interactive) openTelegramDialog() {
6517	items := i.telegramMenuItems()
6518	if len(items) == 0 {
6519		i.mu.Lock()
6520		i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
6521		i.mu.Unlock()
6522		i.invalidate()
6523		return
6524	}
6525	i.telegramDialog.Open(items)
6526	i.invalidate()
6527}
6528
6529// telegramMenuItems builds the dialog entries for the current
6530// bridge state. Returns empty when no bot.json exists so the
6531// caller can show a helpful status line instead of an empty menu.
6532func (i *Interactive) telegramMenuItems() []telegramItem {
6533	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
6534	if err != nil || cfg.BotToken == "" {
6535		return nil
6536	}
6537	var items []telegramItem
6538	if i.telegramBridge != nil && i.telegramBridge.Active() {
6539		items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
6540		st := i.telegramBridge.State()
6541		hint := "active"
6542		if st.Username != "" {
6543			hint += " as @" + st.Username
6544		}
6545		items = append(items, telegramItem{label: "status", action: "status", hint: hint})
6546	} else {
6547		label := "connect"
6548		hint := "start mirroring dms into this session"
6549		if cfg.AllowedUserID == 0 {
6550			hint = "awaiting pairing (send /start to the bot once connected)"
6551		}
6552		items = append(items, telegramItem{label: label, action: "connect", hint: hint})
6553		items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
6554	}
6555	return items
6556}
6557
6558// doTelegram dispatches one of the three explicit actions. Called
6559// from /telegram <action> or after the picker selects a row.
6560func (i *Interactive) doTelegram(action string) {
6561	switch action {
6562	case "connect":
6563		i.telegramConnect()
6564	case "disconnect":
6565		i.telegramDisconnect()
6566	case "status":
6567		i.telegramStatus()
6568	default:
6569		i.mu.Lock()
6570		i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
6571		i.mu.Unlock()
6572		i.invalidate()
6573	}
6574}
6575
6576// telegramConnect starts the bridge. Refuses if it's already
6577// running or if the on-disk bot.json is missing a token.
6578func (i *Interactive) telegramConnect() {
6579	if i.telegramBridge != nil && i.telegramBridge.Active() {
6580		i.mu.Lock()
6581		i.statusOK = "telegram already connected"
6582		i.statusErr = ""
6583		i.mu.Unlock()
6584		i.invalidate()
6585		return
6586	}
6587	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
6588	if err != nil {
6589		i.mu.Lock()
6590		i.statusErr = "telegram: " + err.Error()
6591		i.mu.Unlock()
6592		i.invalidate()
6593		return
6594	}
6595	if cfg.BotToken == "" {
6596		i.mu.Lock()
6597		i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
6598		i.mu.Unlock()
6599		i.invalidate()
6600		return
6601	}
6602	// Refuse to start when a background daemon is already polling
6603	// the same bot. Two concurrent long-poll consumers race each
6604	// update and one always loses, so DMs get half-delivered. The
6605	// user can `zot telegram-bot stop` first, then /telegram connect.
6606	if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
6607		i.mu.Lock()
6608		i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
6609		i.mu.Unlock()
6610		i.invalidate()
6611		return
6612	}
6613	i.telegramBridge = &telegram.Bridge{
6614		Client: telegram.NewClient(cfg.BotToken),
6615		Config: cfg,
6616		Save: func(next telegram.Config) error {
6617			return telegram.SaveConfig(i.cfg.ZotHome, next)
6618		},
6619		Host: &telegramHost{iv: i},
6620	}
6621	if err := i.telegramBridge.Start(i.runCtx); err != nil {
6622		i.telegramBridge = nil
6623		i.mu.Lock()
6624		i.statusErr = "telegram connect failed: " + err.Error()
6625		i.mu.Unlock()
6626		i.invalidate()
6627		return
6628	}
6629	i.applyTelegramTools(true)
6630	state := i.telegramBridge.State()
6631	label := "telegram connected"
6632	if state.Username != "" {
6633		label += " as @" + state.Username
6634	}
6635	if state.PairedID == 0 {
6636		label += " — send /start to the bot from your phone to claim it"
6637	}
6638	i.mu.Lock()
6639	i.statusOK = label
6640	i.statusErr = ""
6641	i.mu.Unlock()
6642	i.invalidate()
6643}
6644
6645// telegramDisconnect stops the bridge. No-op when already stopped.
6646func (i *Interactive) telegramDisconnect() {
6647	if i.telegramBridge == nil || !i.telegramBridge.Active() {
6648		i.mu.Lock()
6649		i.statusOK = "telegram already disconnected"
6650		i.statusErr = ""
6651		i.mu.Unlock()
6652		i.invalidate()
6653		return
6654	}
6655	i.telegramBridge.Stop()
6656	i.applyTelegramTools(false)
6657	i.mu.Lock()
6658	i.statusOK = "telegram disconnected"
6659	i.statusErr = ""
6660	i.mu.Unlock()
6661	i.invalidate()
6662}
6663
6664// telegramSenderAdapter wraps the bridge so the tools package can
6665// drive it without importing telegram directly. The Active() check
6666// is forwarded to the bridge so the tool can fail clearly with a
6667// model-readable error when the user disconnected mid-turn.
6668type telegramSenderAdapter struct {
6669	bridge *telegram.Bridge
6670}
6671
6672func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
6673	if a.bridge == nil {
6674		return fmt.Errorf("telegram bridge is not connected")
6675	}
6676	return a.bridge.SendImage(ctx, path, caption)
6677}
6678
6679func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
6680	if a.bridge == nil {
6681		return fmt.Errorf("telegram bridge is not connected")
6682	}
6683	return a.bridge.SendDocument(ctx, path, caption)
6684}
6685
6686func (a telegramSenderAdapter) Active() bool {
6687	return a.bridge != nil && a.bridge.Active()
6688}
6689
6690// swarmWatchEntry is one tracked auto-swarm sub-agent. It records the
6691// delegated turn's outcome separately from the long-lived daemon status.
6692type swarmWatchEntry struct {
6693	agent   *swarm.Agent
6694	task    string
6695	done    bool
6696	outcome string
6697	err     string
6698}
6699
6700// TrackSwarmAgent is the exported entry point used by the cli to
6701// hand a freshly-spawned auto-swarm agent off to the watcher.
6702func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) {
6703	i.trackSwarmAgent(a, task)
6704}
6705
6706// trackSwarmAgent records a freshly-spawned auto-swarm agent and
6707// subscribes to both ways its delegated task can finish. Successful
6708// long-lived agents report a prompt-level turn_end and keep listening;
6709// startup failures and unexpected daemon exits only unblock Agent.Wait.
6710// Whichever signal arrives first completes the entry exactly once.
6711//
6712// Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto-
6713// swarm is enabled, so this is a no-op when the feature is off.
6714func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) {
6715	if i == nil || a == nil {
6716		return
6717	}
6718	entry := &swarmWatchEntry{agent: a, task: task}
6719	i.swarmWatchMu.Lock()
6720	i.swarmWatch = append(i.swarmWatch, entry)
6721	i.swarmWatchMu.Unlock()
6722
6723	a.SetOnTurnEnd(func(step int, errMsg string) {
6724		outcome := "completed"
6725		if errMsg != "" {
6726			outcome = "failed"
6727		}
6728		i.completeSwarmWatchEntry(entry, outcome, errMsg)
6729	})
6730	go func() {
6731		a.Wait()
6732		snap := a.Snapshot()
6733		outcome := string(snap.Status)
6734		if snap.Status == swarm.StatusDone {
6735			outcome = "completed"
6736		}
6737		i.completeSwarmWatchEntry(entry, outcome, snap.Err)
6738	}()
6739}
6740
6741func (i *Interactive) completeSwarmWatchEntry(entry *swarmWatchEntry, outcome, errMsg string) {
6742	i.swarmWatchMu.Lock()
6743	if entry.done {
6744		i.swarmWatchMu.Unlock()
6745		return
6746	}
6747	entry.done = true
6748	entry.outcome = outcome
6749	entry.err = errMsg
6750	allDone := true
6751	for _, e := range i.swarmWatch {
6752		if !e.done {
6753			allDone = false
6754			break
6755		}
6756	}
6757	var batch []*swarmWatchEntry
6758	if allDone {
6759		batch = i.swarmWatch
6760		i.swarmWatch = nil
6761	}
6762	i.swarmWatchMu.Unlock()
6763	if len(batch) != 0 {
6764		i.flushSwarmSummary(batch)
6765	}
6766}
6767
6768// flushSwarmSummary composes a synthetic user turn describing every
6769// sub-agent's outcome and injects it via SubmitOrQueue so the main
6770// agent picks it up at the next safe boundary. The summary is
6771// phrased as a system update ("Auto-swarm finished: ...") so the
6772// model treats it as observed state, not as a fresh user request.
6773func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) {
6774	if len(batch) == 0 {
6775		return
6776	}
6777	var sb strings.Builder
6778	fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch))
6779	for idx, e := range batch {
6780		snap := e.agent.Snapshot()
6781		status := e.outcome
6782		if status == "" {
6783			status = string(snap.Status)
6784		}
6785		task := snap.Task
6786		if task == "" {
6787			task = e.task
6788		}
6789		fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)
6790		fmt.Fprintf(&sb, "   task: %s\n", truncateForSummary(task, 240))
6791		if snap.Err != "" {
6792			fmt.Fprintf(&sb, "   error: %s\n", truncateForSummary(snap.Err, 240))
6793		} else if e.err != "" {
6794			fmt.Fprintf(&sb, "   turn error: %s\n", truncateForSummary(e.err, 240))
6795		}
6796		if snap.LastAssistant != "" {
6797			sb.WriteString("   final response:\n")
6798			sb.WriteString(snap.LastAssistant)
6799			sb.WriteString("\n")
6800		} else if tail := strings.TrimSpace(snap.Tail); tail != "" {
6801			fmt.Fprintf(&sb, "   tail: %s\n", truncateForSummary(tail, 600))
6802		}
6803		sb.WriteString("\n")
6804	}
6805	sb.WriteString("Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks.")
6806	i.SubmitOrQueue(sb.String(), nil)
6807}
6808
6809func truncateForSummary(s string, n int) string {
6810	s = strings.TrimSpace(s)
6811	if len(s) <= n {
6812		return s
6813	}
6814	return s[:n-3] + "..."
6815}
6816
6817// applyAutoSwarmSystemPrompt appends (active=true) or strips
6818// (active=false) the auto-swarm system-prompt block on the running
6819// agent so the model proactively considers swarm_spawn when the user
6820// flips the toggle. Edit the base prompt so extension replacements are not
6821// applied twice when the next turn prepares the rebuilt prompt.
6822func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
6823	if i.agent == nil {
6824		return
6825	}
6826	addendum := i.cfg.AutoSwarmSystemAddendum
6827	if addendum == "" {
6828		return
6829	}
6830	sys := i.agent.BaseSystem()
6831	has := strings.Contains(sys, addendum)
6832	switch {
6833	case active && !has:
6834		if sys != "" && !strings.HasSuffix(sys, "\n\n") {
6835			sys += "\n\n"
6836		}
6837		i.agent.SetSystem(sys + addendum)
6838	case !active && has:
6839		i.agent.SetSystem(strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n")
6840	}
6841}
6842
6843// applyAutoSwarmTool registers (active=true) or removes (active=false)
6844// the swarm_spawn tool on the running agent so the model only sees it
6845// when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'
6846// snapshot+mutate pattern so extension tools and /reload-ext additions
6847// survive a toggle.
6848func (i *Interactive) autoSwarmEnabled() bool {
6849	return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled
6850}
6851
6852func (i *Interactive) openRouterServerToolsEnabled() bool {
6853	return i.cfg.OpenRouterServerToolsEnabled != nil && *i.cfg.OpenRouterServerToolsEnabled
6854}
6855
6856func (i *Interactive) applyOpenRouterServerTools(active bool) {
6857	if i.agent == nil {
6858		return
6859	}
6860	current := i.agent.Tools
6861	next := core.Registry{}
6862	for name, t := range current {
6863		if tools.IsOpenRouterServerTool(name) {
6864			continue
6865		}
6866		next[name] = t
6867	}
6868	i.agent.MaxToolCalls = 0
6869	if active && !i.cfg.NoTools && i.cfg.Provider == "openrouter" && !strings.Contains(strings.ToLower(i.cfg.Model), "@preset/") {
6870		for _, t := range tools.OpenRouterServerTools() {
6871			next[t.Name()] = t
6872		}
6873		i.agent.MaxToolCalls = tools.ResolveToolCallLimit(i.cfg.OpenRouterServerToolCallLimit)
6874	}
6875	i.agent.SetTools(next)
6876}
6877
6878func (i *Interactive) applyAutoSwarmTool(active bool) {
6879	if i.agent == nil {
6880		return
6881	}
6882	current := i.agent.Tools
6883	next := core.Registry{}
6884	for name, t := range current {
6885		if name == "swarm_spawn" {
6886			continue
6887		}
6888		next[name] = t
6889	}
6890	if active && i.cfg.Swarm != nil {
6891		next["swarm_spawn"] = &tools.SwarmSpawnTool{
6892			Swarm:           i.cfg.Swarm,
6893			Enabled:         func() bool { return true },
6894			DefaultModel:    func() string { return i.cfg.Model },
6895			DefaultProvider: func() string { return i.cfg.Provider },
6896			OnSpawned:       i.trackSwarmAgent,
6897		}
6898	}
6899	i.agent.SetTools(next)
6900}
6901
6902// applyTelegramTools registers (active=true) or removes (active=false)
6903// the telegram_send_image and telegram_send_file tools on the running
6904// agent so the model only sees them while the bridge is connected.
6905// Snapshots and mutates the live tool registry so any extension or
6906// /reload-ext additions made while Telegram is connected survive a
6907// later /telegram disconnect (we only add or strip the two telegram
6908// entries, never the rest).
6909func (i *Interactive) applyTelegramTools(active bool) {
6910	if i.agent == nil {
6911		return
6912	}
6913	current := i.agent.Tools
6914	next := core.Registry{}
6915	for name, t := range current {
6916		if name == "telegram_send_image" || name == "telegram_send_file" {
6917			continue
6918		}
6919		next[name] = t
6920	}
6921	if active {
6922		sender := telegramSenderAdapter{bridge: i.telegramBridge}
6923		next["telegram_send_image"] = &tools.TelegramSendImageTool{
6924			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
6925		}
6926		next["telegram_send_file"] = &tools.TelegramSendFileTool{
6927			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
6928		}
6929	}
6930	i.agent.SetTools(next)
6931}
6932
6933// telegramStatus writes a one-liner describing the bridge state.
6934// Reports on both the in-tui bridge and the background daemon so
6935// the user isn't confused when the daemon owns the poll loop.
6936func (i *Interactive) telegramStatus() {
6937	var msg string
6938	if i.telegramBridge != nil && i.telegramBridge.Active() {
6939		s := i.telegramBridge.State()
6940		msg = "telegram: connected (tui bridge)"
6941		if s.Username != "" {
6942			msg += " as @" + s.Username
6943		}
6944		if s.PairedID != 0 {
6945			msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
6946		} else {
6947			msg += " - awaiting pairing"
6948		}
6949	} else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
6950		msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
6951	} else {
6952		cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
6953		if cfg.BotToken == "" {
6954			msg = "telegram: not configured. run `zot telegram-bot setup` first."
6955		} else {
6956			msg = "telegram: disconnected"
6957			if cfg.BotUsername != "" {
6958				msg += " (@" + cfg.BotUsername + " ready to connect)"
6959			}
6960		}
6961	}
6962	i.mu.Lock()
6963	i.statusOK = msg
6964	i.statusErr = ""
6965	i.mu.Unlock()
6966	i.invalidate()
6967}
6968
6969// telegramHost adapts *Interactive to telegram.Host so the bridge
6970// can call back into the TUI without importing modes directly.
6971type telegramHost struct{ iv *Interactive }
6972
6973func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
6974	h.iv.SubmitOrQueue(prompt, images)
6975}
6976
6977func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
6978
6979func (h *telegramHost) Status() string {
6980	h.iv.mu.Lock()
6981	providerName := h.iv.cfg.Provider
6982	model := h.iv.cfg.Model
6983	cwd := h.iv.cfg.CWD
6984	usage := h.iv.cumUsage
6985	subscription := h.iv.cfg.AuthMethod == "oauth"
6986	ctxUsed := h.iv.lastCtxInput
6987	busy := h.iv.busy
6988	queued := len(h.iv.queued)
6989	h.iv.mu.Unlock()
6990
6991	ctxMax := 0
6992	if m, err := provider.FindModel(providerName, model); err == nil {
6993		ctxMax = m.ContextWindow
6994	}
6995	return telegram.FormatStatus(telegram.StatusSnapshot{
6996		Provider:     providerName,
6997		Model:        model,
6998		CWD:          cwd,
6999		Usage:        usage,
7000		Subscription: subscription,
7001		ContextUsed:  ctxUsed,
7002		ContextMax:   ctxMax,
7003		Busy:         busy,
7004		Queued:       queued,
7005	})
7006}
7007
7008func (h *telegramHost) Notify(level, message string) {
7009	h.iv.mu.Lock()
7010	switch level {
7011	case "error", "warn":
7012		h.iv.statusErr = message
7013		h.iv.statusOK = ""
7014	default:
7015		h.iv.statusOK = message
7016		h.iv.statusErr = ""
7017	}
7018	h.iv.mu.Unlock()
7019	h.iv.invalidate()
7020}
7021
7022// openSessionOpsDialog shows the picker for `/session` with no arg.
7023// Always offers timeline, export, import, fork, and tree. The handlers bail
7024// with a clear status message when a precondition is not met (empty transcript
7025// on fork; no parent/siblings on tree).
7026func (i *Interactive) openSessionOpsDialog() {
7027	items := []sessionOpsItem{
7028		{label: "timeline", action: "timeline", hint: "inspect context, messages, and tool calls"},
7029		{label: "export", action: "export", hint: "write the current session to a .zotsession file"},
7030		{label: "import", action: "import", hint: "load a .zotsession file into this directory"},
7031		{label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
7032		{label: "tree", action: "tree", hint: "switch between branches in this directory"},
7033	}
7034	i.sessionOpsDialog.Open(items)
7035	i.invalidate()
7036}
7037
7038// doSessionOp dispatches timeline, export, import, fork, or tree. arg is the
7039// optional positional argument from e.g. /session export <path>
7040// or /session import <path>; timeline, fork, and tree ignore it.
7041func (i *Interactive) doSessionOp(action, arg string) {
7042	switch action {
7043	case "timeline":
7044		i.openTimeline()
7045	case "export":
7046		i.doSessionExport(arg)
7047	case "import":
7048		i.doSessionImport(arg)
7049	case "fork":
7050		i.doSessionFork()
7051	case "tree":
7052		i.doSessionTree()
7053	default:
7054		i.mu.Lock()
7055		i.statusErr = "unknown /session action: " + action + " (use timeline, export, import, fork, or tree)"
7056		i.mu.Unlock()
7057		i.invalidate()
7058	}
7059}
7060
7061func (i *Interactive) openTimeline() {
7062	i.mu.Lock()
7063	if i.timeline == nil {
7064		i.timeline = newTimelineView()
7065	}
7066	i.timeline.Open(i.timelineDataLocked())
7067	i.chatCacheValid = false
7068	i.scrollOffset = 0
7069	i.statusErr = ""
7070	i.statusOK = ""
7071	i.mu.Unlock()
7072	i.invalidate()
7073}
7074
7075// doSessionExport writes the live session file to destination path
7076// dst. When dst is empty we default to ~/Downloads (falling back to
7077// the user's home directory if it doesn't exist). The helper
7078// expands a leading `~` and creates any missing parent directories.
7079func (i *Interactive) doSessionExport(dst string) {
7080	if i.cfg.CurrentSessionPath == nil {
7081		i.mu.Lock()
7082		i.statusErr = "export: no session is active (running with --no-session?)"
7083		i.mu.Unlock()
7084		i.invalidate()
7085		return
7086	}
7087	src := i.cfg.CurrentSessionPath()
7088	if src == "" {
7089		i.mu.Lock()
7090		i.statusErr = "export: no session is active (running with --no-session?)"
7091		i.mu.Unlock()
7092		i.invalidate()
7093		return
7094	}
7095	// Persist any in-memory agent messages to the session file so
7096	// the export carries the full conversation. Without this, the
7097	// default lazy-flush-at-exit strategy leaves most of a running
7098	// session unwritten and the export ends up with only the meta.
7099	if i.cfg.FlushSession != nil {
7100		i.cfg.FlushSession()
7101	}
7102	dst = unquotePath(dst)
7103	if dst == "" {
7104		dst = defaultExportDir()
7105	} else {
7106		dst = expandTilde(dst)
7107	}
7108	out, err := core.ExportSession(src, dst)
7109	if err != nil {
7110		i.mu.Lock()
7111		i.statusErr = "export: " + err.Error()
7112		i.mu.Unlock()
7113		i.invalidate()
7114		return
7115	}
7116	i.mu.Lock()
7117	i.statusOK = "exported session to " + friendlyPath(out)
7118	i.statusErr = ""
7119	i.mu.Unlock()
7120	i.invalidate()
7121}
7122
7123// doSessionImport copies the .zotsession file at src into the
7124// running cwd's sessions directory and loads it as the active
7125// session, same as `/sessions` -> pick. When src is empty we ask
7126// the user to pass a path (no usable default here).
7127func (i *Interactive) doSessionImport(src string) {
7128	src = unquotePath(src)
7129	if src == "" {
7130		i.mu.Lock()
7131		i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
7132		i.mu.Unlock()
7133		i.invalidate()
7134		return
7135	}
7136	src = expandTilde(src)
7137	if _, err := os.Stat(src); err != nil {
7138		i.mu.Lock()
7139		i.statusErr = "import: " + err.Error()
7140		i.mu.Unlock()
7141		i.invalidate()
7142		return
7143	}
7144	newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
7145	if err != nil {
7146		i.mu.Lock()
7147		i.statusErr = "import: " + err.Error()
7148		i.mu.Unlock()
7149		i.invalidate()
7150		return
7151	}
7152	if i.cfg.LoadSession == nil {
7153		i.mu.Lock()
7154		i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
7155		i.statusErr = ""
7156		i.mu.Unlock()
7157		i.invalidate()
7158		return
7159	}
7160	if err := i.cfg.LoadSession(newPath); err != nil {
7161		i.mu.Lock()
7162		i.statusErr = "import: load failed: " + err.Error()
7163		i.mu.Unlock()
7164		i.invalidate()
7165		return
7166	}
7167	i.mu.Lock()
7168	i.statusOK = "imported and switched to session " + friendlyPath(newPath)
7169	i.statusErr = ""
7170	i.mu.Unlock()
7171	i.invalidate()
7172}
7173
7174// defaultExportDir returns ~/Downloads when it exists, or ~ as a
7175// fallback, or /tmp on exotic machines with no home dir.
7176func defaultExportDir() string {
7177	home, err := os.UserHomeDir()
7178	if err != nil || home == "" {
7179		return os.TempDir()
7180	}
7181	downloads := filepath.Join(home, "Downloads")
7182	if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
7183		return downloads
7184	}
7185	return home
7186}
7187
7188// expandTilde turns a leading ~ into the user's home directory.
7189// Returns the input unchanged when there's no tilde or no home.
7190func expandTilde(p string) string {
7191	if p == "" || p[0] != '~' {
7192		return p
7193	}
7194	home, err := os.UserHomeDir()
7195	if err != nil || home == "" {
7196		return p
7197	}
7198	if len(p) == 1 {
7199		return home
7200	}
7201	if p[1] == '/' || p[1] == filepath.Separator {
7202		return filepath.Join(home, p[2:])
7203	}
7204	return p
7205}
7206
7207// unquotePath strips a matching pair of surrounding single or
7208// double quotes. Drag-drop paste in the tui auto-quotes dropped
7209// file paths so the shell-like `/session import 'foo bar.zs'`
7210// stays well-formed; when the TUI's own slash handler consumes
7211// the arg, we want the raw path back.
7212func unquotePath(p string) string {
7213	p = strings.TrimSpace(p)
7214	if len(p) >= 2 {
7215		first := p[0]
7216		last := p[len(p)-1]
7217		if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
7218			p = p[1 : len(p)-1]
7219		}
7220	}
7221	return p
7222}
7223
7224// friendlyPath collapses the user's home directory to a leading ~
7225// so status messages read cleanly. Falls back to the raw path when
7226// the home dir is unknown.
7227func friendlyPath(p string) string {
7228	home, err := os.UserHomeDir()
7229	if err != nil || home == "" {
7230		return p
7231	}
7232	if strings.HasPrefix(p, home+string(filepath.Separator)) {
7233		return "~" + p[len(home):]
7234	}
7235	return p
7236}
7237
7238// doSessionFork opens the /jump turn picker in "fork mode". The
7239// next selection branches the current session at that user turn
7240// instead of scrolling the viewport to it.
7241func (i *Interactive) doSessionFork() {
7242	if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
7243		i.mu.Lock()
7244		i.statusErr = "fork: no session is active (running with --no-session?)"
7245		i.mu.Unlock()
7246		i.invalidate()
7247		return
7248	}
7249	msgs := []provider.Message{}
7250	if i.agent != nil {
7251		msgs = i.agent.Messages()
7252	}
7253	if len(msgs) == 0 {
7254		i.mu.Lock()
7255		i.statusErr = "fork: transcript is empty; nothing to fork from"
7256		i.mu.Unlock()
7257		i.invalidate()
7258		return
7259	}
7260	i.pendingFork = true
7261	i.jumpDialog.Open(msgs, "")
7262	i.invalidate()
7263}
7264
7265// doSessionTree shows the current session family as an inline branch tree.
7266// Pick an entry to check out that point into a new branch. Subsequent
7267// prompts use only context up to the selected message.
7268func (i *Interactive) doSessionTree() {
7269	current := ""
7270	if i.cfg.CurrentSessionPath != nil {
7271		current = i.cfg.CurrentSessionPath()
7272	}
7273	if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) {
7274		i.invalidate()
7275		return
7276	}
7277	msgs := []provider.Message{}
7278	if i.agent != nil {
7279		msgs = i.agent.Messages()
7280	}
7281	if len(msgs) == 0 {
7282		i.mu.Lock()
7283		i.statusErr = "tree: no messages in this session yet"
7284		i.mu.Unlock()
7285		i.invalidate()
7286		return
7287	}
7288	if !i.sessionTreeDialog.OpenMessages(msgs) {
7289		i.mu.Lock()
7290		i.statusErr = "tree: no messages to show"
7291		i.mu.Unlock()
7292	}
7293	i.invalidate()
7294}
7295
7296// applySessionTreeMessageSelection checks out the selected message into a
7297// new branch, then switches the running agent to that branch. Unlike /jump,
7298// this changes future model context: only messages up to the selected row are
7299// sent on subsequent turns.
7300func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
7301	if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
7302		i.mu.Lock()
7303		i.statusErr = "tree: session branching is not available in this build"
7304		i.mu.Unlock()
7305		i.invalidate()
7306		return
7307	}
7308	if src == "" {
7309		src = i.cfg.CurrentSessionPath()
7310	}
7311	if src == "" {
7312		i.mu.Lock()
7313		i.statusErr = "tree: no session is active"
7314		i.mu.Unlock()
7315		i.invalidate()
7316		return
7317	}
7318	if i.cfg.FlushSession != nil {
7319		i.cfg.FlushSession()
7320	}
7321	upTo := msgIdx + 1
7322	if role == provider.RoleUser {
7323		upTo = msgIdx
7324	}
7325	newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
7326	if err != nil {
7327		i.mu.Lock()
7328		i.statusErr = "tree: " + err.Error()
7329		i.mu.Unlock()
7330		i.invalidate()
7331		return
7332	}
7333	if err := i.cfg.LoadSession(newPath); err != nil {
7334		i.mu.Lock()
7335		i.statusErr = "tree: checkout failed: " + err.Error()
7336		i.mu.Unlock()
7337		i.invalidate()
7338		return
7339	}
7340	i.scrollToBottom()
7341	i.mu.Lock()
7342	if role == provider.RoleUser {
7343		i.ed.SetValue(prompt)
7344		i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
7345	} else {
7346		i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
7347	}
7348	i.statusErr = ""
7349	i.mu.Unlock()
7350	i.invalidate()
7351}
7352
7353// applyForkSelection branches the current session at msgIdx+1 (so
7354// the selected user message and everything before it is included
7355// in the new branch), then switches the running agent to the new
7356// file. Called from the jump-dialog handler when pendingFork=true.
7357func (i *Interactive) applyForkSelection(msgIdx int) {
7358	i.pendingFork = false
7359	if i.cfg.CurrentSessionPath == nil {
7360		i.mu.Lock()
7361		i.statusErr = "fork: no session is active"
7362		i.mu.Unlock()
7363		i.invalidate()
7364		return
7365	}
7366	src := i.cfg.CurrentSessionPath()
7367	if src == "" {
7368		i.mu.Lock()
7369		i.statusErr = "fork: no session is active"
7370		i.mu.Unlock()
7371		i.invalidate()
7372		return
7373	}
7374	if i.cfg.FlushSession != nil {
7375		i.cfg.FlushSession()
7376	}
7377	// msgIdx is 0-indexed message position; copy msgIdx+1 rows so
7378	// the selected user message is included.
7379	upTo := msgIdx + 1
7380	newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
7381	if err != nil {
7382		i.mu.Lock()
7383		i.statusErr = "fork: " + err.Error()
7384		i.mu.Unlock()
7385		i.invalidate()
7386		return
7387	}
7388	if i.cfg.LoadSession == nil {
7389		i.mu.Lock()
7390		i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
7391		i.statusErr = ""
7392		i.mu.Unlock()
7393		i.invalidate()
7394		return
7395	}
7396	if err := i.cfg.LoadSession(newPath); err != nil {
7397		i.mu.Lock()
7398		i.statusErr = "fork: switch failed: " + err.Error()
7399		i.mu.Unlock()
7400		i.invalidate()
7401		return
7402	}
7403	i.mu.Lock()
7404	i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
7405	i.statusErr = ""
7406	i.mu.Unlock()
7407	i.invalidate()
7408}
7409
7410// formatInt is a tiny strconv.Itoa shim; keeps the handler above
7411// from needing a strconv import just for one call.
7412func formatInt(n int) string {
7413	return fmt.Sprintf("%d", n)
7414}
7415
7416// assistantText returns the concatenated text of every TextBlock in
7417// m. Used by the streaming-view dedupe guard to tell when a live
7418// streamed reply has already been promoted into the transcript.
7419func assistantText(m provider.Message) string {
7420	var sb strings.Builder
7421	for _, c := range m.Content {
7422		if tb, ok := c.(provider.TextBlock); ok {
7423			if sb.Len() > 0 {
7424				sb.WriteByte('\n')
7425			}
7426			sb.WriteString(tb.Text)
7427		}
7428	}
7429	return sb.String()
7430}
7431
7432// resetStreamingStateLocked clears every piece of streaming state
7433// in one shot. Used by abort paths (turn cancel, compact finish,
7434// queue drain) so the pacer doesn't keep draining stale runes from
7435// a prior turn. Must be called with i.mu held.
7436func (i *Interactive) resetStreamingStateLocked() {
7437	i.streaming.Reset()
7438	i.streamPending = i.streamPending[:0]
7439	i.streamFlushPending = false
7440	i.streamOn = false
7441	i.openAllToolGatesLocked()
7442}
7443
7444// openAllToolGatesLocked drops every pending tool gate so that any
7445// tool registered during this turn renders unconditionally from now
7446// on. Called when streaming finalizes (the paced text has fully
7447// drained and `streaming` is about to reset to length 0): without
7448// this, the gate comparison against a freshly-reset streaming buffer
7449// would wrongly re-hide tools that had already cleared their gate.
7450// Must be called with i.mu held.
7451func (i *Interactive) openAllToolGatesLocked() {
7452	for id := range i.toolGate {
7453		i.toolGate[id] = 0
7454	}
7455}
7456
7457// gateToolLocked records the stream position at which a tool call may
7458// become visible. The gate is the total length the streaming buffer
7459// will reach once the pacer has drained everything currently queued
7460// (already painted + still pending). Holding the tool block back
7461// until the pacer crosses that mark guarantees the prose emitted
7462// before the tool call finishes typing out above it, instead of the
7463// tool block snapping in while the paragraph is still filling in.
7464//
7465// We only gate while text is actively streaming. If no stream is in
7466// flight (gate 0), the tool shows immediately, which is the correct
7467// behaviour for tool-only turns and replayed sessions. First
7468// registration wins so a later EvToolCall can't move an existing
7469// gate. Must be called with i.mu held.
7470func (i *Interactive) gateToolLocked(id string) {
7471	if _, ok := i.toolGate[id]; ok {
7472		return
7473	}
7474	if !i.streamOn {
7475		i.toolGate[id] = 0
7476		return
7477	}
7478	i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
7479}
7480
7481// toolGateOpenLocked reports whether the gated tool block may render
7482// yet, i.e. the pacer has drained enough text to reach the position
7483// recorded when the tool call arrived. Must be called with i.mu held.
7484func (i *Interactive) toolGateOpenLocked(id string) bool {
7485	gate, ok := i.toolGate[id]
7486	if !ok || gate == 0 {
7487		return true
7488	}
7489	return i.streaming.Len() >= gate
7490}
7491
7492// assistantMessageSideEffects runs the non-visual hooks attached to
7493// EvAssistantMessage: the host-provided OnAssistant callback and the
7494// telegram-bridge mirror. Called with i.mu held.
7495//
7496// Factored out of handleEvent because the streaming pacer may defer
7497// visual reset until after the last buffered rune has painted, but
7498// the callbacks themselves must fire on message arrival so
7499// downstream observers (session persistence, telegram, cost panels)
7500// don't wait on a UI animation to catch up.
7501func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
7502	if i.cfg.OnAssistant != nil {
7503		i.cfg.OnAssistant(m)
7504	}
7505	if i.telegramBridge != nil && i.telegramBridge.Active() {
7506		var sb strings.Builder
7507		for _, c := range m.Content {
7508			if tb, ok := c.(provider.TextBlock); ok {
7509				if sb.Len() > 0 {
7510					sb.WriteString("\n")
7511				}
7512				sb.WriteString(tb.Text)
7513			}
7514		}
7515		if text := sb.String(); strings.TrimSpace(text) != "" {
7516			go i.telegramBridge.OnAssistantText(text)
7517		}
7518	}
7519}
7520
7521// paintPaceRate is how many runes the streaming pacer releases per
7522// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
7523// that a 500-rune summary finishes in ~1.3s, slow enough to look
7524// like a human typing. Empirically matches the feel of provider
7525// paths that already drip-stream natively.
7526const paintPaceRate = 6
7527
7528// paintPaceInterval is the tick interval for the streaming pacer.
7529// 16ms lines up with the redraw throttle so we never paint faster
7530// than the terminal can keep up.
7531const paintPaceInterval = 16 * time.Millisecond
7532
7533// runStreamPacer drains buffered deltas from streamPending into
7534// streaming a small batch per tick, invalidating after each move.
7535// It stops when the context cancels (tui shutdown).
7536//
7537// Why a pacer: providers differ wildly in how they chunk their
7538// text_delta events. The API-key path on Anthropic emits ~30 drips
7539// for a 400-token summary; the OAuth path can coalesce the same
7540// summary into 3 fat chunks, visually indistinguishable from "the
7541// whole reply just appeared". The pacer normalizes that so every
7542// path looks the same on screen.
7543func (i *Interactive) runStreamPacer(ctx context.Context) {
7544	t := time.NewTicker(paintPaceInterval)
7545	defer t.Stop()
7546	for {
7547		select {
7548		case <-ctx.Done():
7549			return
7550		case <-t.C:
7551			i.mu.Lock()
7552			if len(i.streamPending) == 0 {
7553				// EvAssistantMessage already fired but the pacer
7554				// was still draining a tick ago. Everything is now
7555				// painted; clear the streaming flags so the next
7556				// redraw shows the finalised transcript message
7557				// and hides the streaming overlay.
7558				if i.streamFlushPending {
7559					i.streamFlushPending = false
7560					i.streaming.Reset()
7561					i.streamOn = false
7562					i.openAllToolGatesLocked()
7563					i.mu.Unlock()
7564					i.invalidate()
7565					continue
7566				}
7567				i.mu.Unlock()
7568				continue
7569			}
7570			n := paintPaceRate
7571			if n > len(i.streamPending) {
7572				n = len(i.streamPending)
7573			}
7574			i.streaming.WriteString(string(i.streamPending[:n]))
7575			i.streamPending = i.streamPending[n:]
7576			i.mu.Unlock()
7577			i.invalidate()
7578		}
7579	}
7580}

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