v0.3.76 1866c03

interactive.go RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close