v0.3.63 0d096bd

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close