v0.3.57 169b025

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

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close