v0.3.84 97ae0a2

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

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