v0.3.80 f3f644f

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	shellEscapeCommandMetaKey     = "shell_escape_command"
1843)
1844
1845func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
1846	if len(msgs) == 0 {
1847		return nil
1848	}
1849	out := make([]provider.Message, 0, len(msgs))
1850	for _, m := range msgs {
1851		if isHiddenTranscriptMessage(m) {
1852			continue
1853		}
1854		out = append(out, m)
1855	}
1856	return out
1857}
1858
1859func isHiddenTranscriptMessage(m provider.Message) bool {
1860	if m.Role != provider.RoleUser || len(m.Content) == 0 {
1861		return false
1862	}
1863	tb, ok := m.Content[0].(provider.TextBlock)
1864	if !ok {
1865		return false
1866	}
1867	return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
1868}
1869
1870func clipBottomClippedImages(lines []string) []string {
1871	if len(lines) == 0 {
1872		return lines
1873	}
1874	out := append([]string(nil), lines...)
1875	for i, line := range out {
1876		if !hasImageEscape(line) {
1877			continue
1878		}
1879		// Image blocks render as: image escape, zero or more blank
1880		// reservation rows, then the muted "image - ..." info line,
1881		// then one trailing blank. If the info line isn't visible in
1882		// the current chat slice, the image would paint down into the
1883		// fixed status bar area. Suppress that image for this frame.
1884		//
1885		// When the image lives inside a tool box, the reservation rows
1886		// are wrapped in vertical box edges ("│  ...  │"); those rows
1887		// look non-blank under a naive whitespace check but are still
1888		// reservation rows for this scan, so treat them as blank.
1889		foundInfo := false
1890		for j := i + 1; j < len(out); j++ {
1891			if strings.Contains(out[j], "image - ") {
1892				foundInfo = true
1893				break
1894			}
1895			if !isBoxBlankLine(out[j]) {
1896				break
1897			}
1898		}
1899		if !foundInfo {
1900			out[i] = ""
1901		}
1902	}
1903	return out
1904}
1905
1906// isBoxBlankLine reports whether line is visually empty after
1907// stripping ANSI escape sequences, surrounding whitespace, and the
1908// vertical box edges drawn by the tool-box renderer. Used by
1909// clipBottomClippedImages so an image's reservation rows still count
1910// as blank when those rows are wrapped in "│  ...  │" inside a tool box.
1911func isBoxBlankLine(line string) bool {
1912	stripped := stripANSIBytes(line)
1913	stripped = strings.TrimSpace(stripped)
1914	stripped = strings.Trim(stripped, "│")
1915	stripped = strings.TrimSpace(stripped)
1916	return stripped == ""
1917}
1918
1919// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final
1920// byte) from s without pulling in the regexp package. Mirrors the
1921// internal helper in package tui; the duplicated copy avoids exporting
1922// it just for one caller.
1923func stripANSIBytes(s string) string {
1924	if !strings.Contains(s, "\x1b") {
1925		return s
1926	}
1927	var b strings.Builder
1928	b.Grow(len(s))
1929	i := 0
1930	for i < len(s) {
1931		if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1932			end := i + 2
1933			for end < len(s) {
1934				c := s[end]
1935				end++
1936				if c >= 0x40 && c <= 0x7e {
1937					break
1938				}
1939			}
1940			i = end
1941			continue
1942		}
1943		b.WriteByte(s[i])
1944		i++
1945	}
1946	return b.String()
1947}
1948
1949// truncateLine shortens s so it fits within n display cells, with an
1950// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
1951// novel doesn't blow past the status line.
1952func panelKeyName(k tui.Key) string {
1953	switch k.Kind {
1954	case tui.KeyUp:
1955		return "up"
1956	case tui.KeyDown:
1957		return "down"
1958	case tui.KeyLeft:
1959		return "left"
1960	case tui.KeyRight:
1961		return "right"
1962	case tui.KeyEnter:
1963		return "enter"
1964	case tui.KeyEsc:
1965		return "esc"
1966	case tui.KeyTab:
1967		return "tab"
1968	case tui.KeyBackspace:
1969		return "backspace"
1970	case tui.KeyDelete:
1971		return "delete"
1972	case tui.KeyHome:
1973		return "home"
1974	case tui.KeyEnd:
1975		return "end"
1976	case tui.KeyPageUp:
1977		return "pageup"
1978	case tui.KeyPageDown:
1979		return "pagedown"
1980	case tui.KeyRune:
1981		return "rune"
1982	default:
1983		return "unknown"
1984	}
1985}
1986
1987func panelKeyText(k tui.Key) string {
1988	if k.Kind == tui.KeyRune {
1989		return string(k.Rune)
1990	}
1991	return ""
1992}
1993
1994func truncateLine(s string, n int) string {
1995	if n <= 0 {
1996		return ""
1997	}
1998	// Collapse newlines — chips are single line.
1999	s = strings.ReplaceAll(s, "\n", " ↩ ")
2000	runes := []rune(s)
2001	if len(runes) <= n {
2002		return s
2003	}
2004	if n <= 3 {
2005		return strings.Repeat(".", n)
2006	}
2007	return string(runes[:n-3]) + "..."
2008}
2009
2010// ctrlCExitWindow is how long after a ctrl+c press a *second* press
2011// will exit instead of just clearing input. Long enough to be
2012// deliberate (rules out accidental key chord), short enough that the
2013// hint stays meaningful.
2014const ctrlCExitWindow = 2 * time.Second
2015
2016// armCtrlCExit records the timestamp of the current ctrl+c so the next
2017// one within ctrlCExitWindow exits.
2018func (i *Interactive) armCtrlCExit() {
2019	i.mu.Lock()
2020	i.lastCtrlC = time.Now()
2021	i.mu.Unlock()
2022}
2023
2024// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
2025// that another press should now exit.
2026func (i *Interactive) ctrlCExitArmed() bool {
2027	i.mu.Lock()
2028	t := i.lastCtrlC
2029	i.mu.Unlock()
2030	return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
2031}
2032
2033// clearFileSuggestQuery strips the filter the user typed after the
2034// last "@", leaving the bare "@" so the picker stays open. Called when
2035// navigating between directory levels (Right/Left): the filter applied
2036// to the level the user was on, not the one being entered, so carrying
2037// it forward would wrongly hide the new directory's contents.
2038func (i *Interactive) clearFileSuggestQuery() {
2039	val := i.ed.Value()
2040	if idx := strings.LastIndex(val, "@"); idx >= 0 {
2041		i.ed.SetValue(val[:idx+1])
2042	}
2043}
2044
2045// setToolExpansion updates long tool result expansion and replays the
2046// transcript so already-emitted rows reflect the new state.
2047func (i *Interactive) setToolExpansion(expanded bool) {
2048	i.mu.Lock()
2049	i.view.ExpandAll = expanded
2050	if i.rend != nil {
2051		i.rend.Clear()
2052	}
2053	i.mu.Unlock()
2054	i.invalidate()
2055}
2056
2057func (i *Interactive) toggleToolExpansion() {
2058	i.mu.Lock()
2059	expanded := !i.view.ExpandAll
2060	i.mu.Unlock()
2061	i.setToolExpansion(expanded)
2062}
2063
2064func (i *Interactive) toggleBtwToolExpansion() {
2065	i.btwDialog.ToggleToolExpansion()
2066	i.mu.Lock()
2067	if i.rend != nil {
2068		i.rend.Clear()
2069	}
2070	i.mu.Unlock()
2071	i.invalidate()
2072}
2073
2074// confirmChildActive reports whether an interaction opened from slash input
2075// currently owns the keyboard while a tool confirmation remains pending.
2076func (i *Interactive) confirmChildActive() bool {
2077	return len(i.helpBlock) > 0 ||
2078		len(i.extNotes) > 0 ||
2079		i.dialog.Active() ||
2080		i.modelDialog.Active() ||
2081		i.llamaDialog.Active() ||
2082		i.rescueDialog.Active() ||
2083		i.sessionDialog.Active() ||
2084		i.swarmDialog.Active() ||
2085		i.jumpDialog.Active() ||
2086		i.btwDialog.Active() ||
2087		i.skillsDialog.Active() ||
2088		i.changelogDialog.Active() ||
2089		i.logoutDialog.Active() ||
2090		i.telegramDialog.Active() ||
2091		i.settingsDialog.Active() ||
2092		i.sessionOpsDialog.Active() ||
2093		i.sessionTreeDialog.Active() ||
2094		i.timeline.Active() ||
2095		i.extPanel.Active()
2096}
2097
2098// restoreConfirmFocus returns input to confirmation after slash input is
2099// cleared or a child interaction closes. A non-empty editor keeps command
2100// input focused, and an active child continues to own its keys.
2101func (i *Interactive) restoreConfirmFocus() {
2102	if !i.confirmDialog.Active() || i.confirmDialog.Focused() {
2103		return
2104	}
2105	if !i.ed.IsEmpty() || i.confirmChildActive() {
2106		return
2107	}
2108	i.confirmDialog.Focus()
2109	i.invalidate()
2110}
2111
2112func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2113	defer i.restoreConfirmFocus()
2114
2115	// Dialogs route keys before the main clipboard handler below. Resolve text
2116	// here when a child interaction owns input so every editor and filter sees
2117	// the same KeyPaste event as terminal-native bracketed paste. Main chat is
2118	// left image-first to preserve macOS clipboard image attachments.
2119	if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() {
2120		resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText)
2121		if err != nil {
2122			i.mu.Lock()
2123			i.statusErr = "clipboard paste failed: " + err.Error()
2124			i.statusOK = ""
2125			i.mu.Unlock()
2126			return false
2127		}
2128		if pastedText {
2129			k = resolved
2130			i.mu.Lock()
2131			i.statusErr = ""
2132			i.statusOK = ""
2133			i.mu.Unlock()
2134		}
2135	}
2136
2137	// Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
2138	// pressing ctrl+c then typing then ctrl+c much later doesn't quit
2139	// unexpectedly. The hint message also goes stale; clear it.
2140	if k.Kind != tui.KeyCtrlC {
2141		i.mu.Lock()
2142		if !i.lastCtrlC.IsZero() {
2143			i.lastCtrlC = time.Time{}
2144			if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
2145				i.statusOK = ""
2146			}
2147		}
2148		i.mu.Unlock()
2149	}
2150
2151	// Dialogs consume keys while open (except ctrl+c, which always closes them).
2152
2153	// Confirmation owns input by default. Pressing / deliberately moves
2154	// focus to the main editor without answering the pending request, so
2155	// slash commands and the dialogs they open can run first.
2156	if i.confirmDialog.Focused() {
2157		if k.Kind == tui.KeyRune && k.Rune == '/' {
2158			i.confirmDialog.Blur()
2159			i.ed.Insert("/")
2160			i.suggest.Reset()
2161			i.invalidate()
2162			return false
2163		}
2164		if k.Kind == tui.KeyCtrlO {
2165			if i.btwDialog != nil && i.btwDialog.Active() {
2166				i.toggleBtwToolExpansion()
2167			} else {
2168				i.toggleToolExpansion()
2169			}
2170			return false
2171		}
2172		i.confirmDialog.HandleKey(k)
2173		i.invalidate()
2174		return false
2175	}
2176	if i.confirmDialog.Active() && !i.confirmChildActive() && k.Kind == tui.KeyEsc {
2177		i.ed.Clear()
2178		i.suggest.Reset()
2179		i.fileSuggest.Reset()
2180		i.confirmDialog.Focus()
2181		i.invalidate()
2182		return false
2183	}
2184	if i.dialog.Active() {
2185		if k.Kind == tui.KeyCtrlC {
2186			i.dialog.Close()
2187			if i.cfg.AuthManager != nil {
2188				i.cfg.AuthManager.CancelOAuth()
2189			}
2190			return false
2191		}
2192		act := i.dialog.HandleKey(k)
2193		if act.StartAPIKey {
2194			i.startAPIKeyFlow(act.Provider)
2195		}
2196		if act.StartOAuth {
2197			i.startOAuthFlow(act.Provider)
2198		}
2199		if act.StartManual {
2200			i.startManualOAuthFlow(act.Provider)
2201		}
2202		if act.SubmitCode != "" {
2203			i.submitManualOAuthCode(act.SubmitCode)
2204		}
2205		if act.SaveLlama {
2206			if act.Provider == provider.LMStudioProviderID {
2207				i.saveLMStudioLogin(act.LlamaURL, act.LlamaAPIKey)
2208			} else {
2209				i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey)
2210			}
2211		}
2212		return false
2213	}
2214	if i.modelDialog.Active() {
2215		if k.Kind == tui.KeyCtrlC {
2216			i.modelDialog.Close()
2217			i.quickModelAssign = 0
2218			return false
2219		}
2220		act := i.modelDialog.HandleKey(k)
2221		if act.Close {
2222			i.quickModelAssign = 0
2223		}
2224		if act.Select {
2225			if i.quickModelAssign > 0 {
2226				i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model)
2227				i.quickModelAssign = 0
2228			} else {
2229				i.applyModelSelection(act.Provider, act.Model)
2230			}
2231		}
2232		return false
2233	}
2234	if i.llamaDialog.Active() {
2235		i.llamaDialog.HandleKey(k)
2236		i.invalidate()
2237		return false
2238	}
2239	if i.rescueDialog.Active() {
2240		if k.Kind == tui.KeyCtrlC {
2241			i.rescueDialog.Close()
2242			i.invalidate()
2243			return false
2244		}
2245		act := i.rescueDialog.HandleKey(k)
2246		if act.Select {
2247			i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
2248		}
2249		i.invalidate()
2250		return false
2251	}
2252	if i.sessionDialog.Active() {
2253		if k.Kind == tui.KeyCtrlC {
2254			i.sessionDialog.Close()
2255			return false
2256		}
2257		act := i.sessionDialog.HandleKey(k)
2258		if act.Select {
2259			i.applySessionSelection(act.Path)
2260		}
2261		if act.Delete {
2262			i.applySessionDeletion(act.Path)
2263		}
2264		// Always request a redraw after handling a key here: when esc
2265		// closes the picker, the overlay-close detection in the render
2266		// pass needs to run so the tall dialog rows get repainted from
2267		// the chat (otherwise VS Code's retained scrollback leaves a
2268		// duplicate frame on screen).
2269		i.invalidate()
2270		return false
2271	}
2272	if i.swarmDialog.Active() {
2273		if k.Kind == tui.KeyCtrlC {
2274			i.swarmDialog.Close()
2275			i.invalidate()
2276			return false
2277		}
2278		_, msg, errMsg := i.swarmDialog.HandleKey(k)
2279		if msg != "" || errMsg != "" {
2280			i.mu.Lock()
2281			i.statusOK = msg
2282			i.statusErr = errMsg
2283			i.mu.Unlock()
2284		}
2285		i.invalidate()
2286		return false
2287	}
2288	if i.logoutDialog.Active() {
2289		if k.Kind == tui.KeyCtrlC {
2290			i.logoutDialog.Close()
2291			i.invalidate()
2292			return false
2293		}
2294		act := i.logoutDialog.HandleKey(k)
2295		if act.Select {
2296			i.doLogout(act.Target)
2297		}
2298		i.invalidate()
2299		return false
2300	}
2301	if i.telegramDialog.Active() {
2302		if k.Kind == tui.KeyCtrlC {
2303			i.telegramDialog.Close()
2304			i.invalidate()
2305			return false
2306		}
2307		act := i.telegramDialog.HandleKey(k)
2308		if act.Select {
2309			i.doTelegram(act.Action)
2310		}
2311		i.invalidate()
2312		return false
2313	}
2314	if i.settingsDialog.Active() {
2315		if k.Kind == tui.KeyCtrlC {
2316			i.settingsDialog.Close()
2317			i.invalidate()
2318			return false
2319		}
2320		act := i.settingsDialog.HandleKey(k)
2321		if act.ModelShortcutSlot > 0 {
2322			i.openQuickModelPicker(act.ModelShortcutSlot)
2323		}
2324		if act.Toggle {
2325			i.applySettingChange(act)
2326		}
2327		i.invalidate()
2328		return false
2329	}
2330	if i.sessionOpsDialog.Active() {
2331		if k.Kind == tui.KeyCtrlC {
2332			i.sessionOpsDialog.Close()
2333			i.invalidate()
2334			return false
2335		}
2336		act := i.sessionOpsDialog.HandleKey(k)
2337		if act.Select {
2338			i.doSessionOp(act.Action, "")
2339		}
2340		i.invalidate()
2341		return false
2342	}
2343	if i.sessionTreeDialog.Active() {
2344		if k.Kind == tui.KeyCtrlC {
2345			i.sessionTreeDialog.Close()
2346			i.invalidate()
2347			return false
2348		}
2349		act := i.sessionTreeDialog.HandleKey(k)
2350		if act.Select {
2351			i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
2352		}
2353		i.invalidate()
2354		return false
2355	}
2356	if i.extPanel.Active() {
2357		if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
2358			if i.cfg.Extensions != nil {
2359				_ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
2360			}
2361			i.extPanel.Close()
2362			i.invalidate()
2363			return false
2364		}
2365		if i.cfg.Extensions != nil {
2366			_ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
2367		}
2368		return false
2369	}
2370	if i.timeline.Active() {
2371		data := i.timelineData()
2372		act := i.timeline.HandleKey(k, data)
2373		if act.Export {
2374			path, err := i.timeline.Export("", data)
2375			if err != nil {
2376				i.timeline.SetNotice("export failed: " + err.Error())
2377			} else {
2378				i.timeline.SetNotice("exported to " + friendlyPath(path))
2379			}
2380		}
2381		i.mu.Lock()
2382		i.chatCacheValid = false
2383		i.scrollOffset = 0
2384		i.mu.Unlock()
2385		i.invalidate()
2386		return false
2387	}
2388	if i.jumpDialog.Active() {
2389		if k.Kind == tui.KeyCtrlC {
2390			i.jumpDialog.Close()
2391			i.pendingFork = false
2392			return false
2393		}
2394		act := i.jumpDialog.HandleKey(k)
2395		if act.Select {
2396			if i.pendingFork {
2397				i.applyForkSelection(act.MessageIdx)
2398			} else {
2399				i.applyJumpSelection(act.MessageIdx, act.TurnNo)
2400			}
2401		}
2402		// If the user dismissed the dialog without selecting, also
2403		// clear the pending-fork flag so a later plain /jump isn't
2404		// hijacked.
2405		if act.Close {
2406			i.pendingFork = false
2407		}
2408		return false
2409	}
2410	if i.btwDialog.Active() {
2411		if k.Kind == tui.KeyCtrlC {
2412			i.btwDialog.Close()
2413			i.invalidate()
2414			return false
2415		}
2416		if k.Kind == tui.KeyCtrlO {
2417			i.toggleBtwToolExpansion()
2418			return false
2419		}
2420		i.btwDialog.HandleKey(k, i.invalidate)
2421		return false
2422	}
2423	if i.skillsDialog.Active() {
2424		if k.Kind == tui.KeyCtrlC {
2425			i.skillsDialog.Close()
2426			i.invalidate()
2427			return false
2428		}
2429		i.skillsDialog.HandleKey(k)
2430		i.invalidate()
2431		return false
2432	}
2433	if i.changelogDialog.Active() {
2434		if closed := i.changelogDialog.HandleKey(k); closed {
2435			// User dismissed; let the parent persist the
2436			// LastChangelogShown marker via the close callback.
2437			if i.cfg.OnChangelogDismiss != nil {
2438				i.cfg.OnChangelogDismiss()
2439			}
2440		}
2441		i.invalidate()
2442		return false
2443	}
2444
2445	if slot := quickModelShortcutSlot(k); slot > 0 {
2446		i.applyQuickModelShortcut(slot)
2447		return false
2448	}
2449
2450	// Global keys.
2451	switch k.Kind {
2452	case tui.KeyCtrlC:
2453		i.mu.Lock()
2454		loadingSession := i.sessionLoading
2455		i.mu.Unlock()
2456		if loadingSession {
2457			return true
2458		}
2459		// While busy: do NOT cancel the turn. ctrl+c during a
2460		// running turn is almost always reflex muscle memory
2461		// ("be quiet" in a shell) rather than a deliberate
2462		// decision to kill a multi-minute model call that's
2463		// already cost tokens. Use esc to interrupt a turn; use
2464		// a deliberate double-ctrl+c to exit zot entirely. First
2465		// press arms the exit hint, second press within
2466		// ctrlCExitWindow quits.
2467		if i.busy {
2468			if i.ctrlCExitArmed() {
2469				return true
2470			}
2471			i.mu.Lock()
2472			i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
2473			i.statusErr = ""
2474			i.mu.Unlock()
2475			i.armCtrlCExit()
2476			return false
2477		}
2478		// Idle: first press clears the editor (and any queued
2479		// follow-up messages); a second press within ctrlCExitWindow
2480		// exits. With both an empty editor and no queue the first
2481		// press still just arms — require a deliberate double-tap.
2482		ag := i.agent
2483		pending := 0
2484		if ag != nil {
2485			pending = ag.QueuedMessageCount()
2486		}
2487		hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
2488		if hadInput {
2489			i.ed.Clear()
2490			i.clipboardImages = nil
2491			i.suggest.Reset()
2492			if ag != nil {
2493				ag.DrainQueuedMessages()
2494			}
2495			i.mu.Lock()
2496			i.queued = nil
2497			i.statusOK = "input cleared"
2498			i.statusErr = ""
2499			i.mu.Unlock()
2500			i.armCtrlCExit()
2501			return false
2502		}
2503		if i.ctrlCExitArmed() {
2504			return true
2505		}
2506		i.mu.Lock()
2507		i.statusOK = "press ctrl+c again to exit"
2508		i.statusErr = ""
2509		i.mu.Unlock()
2510		i.armCtrlCExit()
2511		return false
2512	case tui.KeyEsc:
2513		// Esc interrupts a running turn — but only when nothing
2514		// else on screen wants to consume the key first. The slash
2515		// popup has its own Esc behaviour (close + clear editor),
2516		// and transient overlays like the /help block and extension
2517		// notes should dismiss on Esc before we even consider the
2518		// turn. Without these guards, a casual Esc press after
2519		// running /help on a busy turn rips the turn away.
2520		if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
2521			break
2522		}
2523		i.mu.Lock()
2524		hadHelp := len(i.helpBlock) > 0
2525		hadNotes := len(i.extNotes) > 0
2526		if hadHelp {
2527			i.helpBlock = nil
2528		}
2529		if hadNotes {
2530			i.extNotes = nil
2531		}
2532		i.mu.Unlock()
2533		if hadHelp || hadNotes {
2534			i.invalidate()
2535			return false
2536		}
2537		if i.busy && i.cancelTurn != nil {
2538			i.cancelTurn()
2539			// If a confirm dialog is pending, refuse it so the agent
2540			// goroutine unblocks and the context cancellation can
2541			// actually take effect.
2542			i.confirmDialog.CancelAll("turn cancelled")
2543			return false
2544		}
2545	case tui.KeyCtrlD:
2546		if i.ed.IsEmpty() && !i.busy {
2547			return true
2548		}
2549	case tui.KeyCtrlL:
2550		i.rend.Clear()
2551		i.invalidate()
2552		return false
2553	case tui.KeyPasteClipboard:
2554		i.pasteClipboard()
2555		return false
2556	case tui.KeyCtrlO:
2557		i.toggleToolExpansion()
2558		return false
2559	case tui.KeyPageUp:
2560		i.scrollBy(+i.chatPage())
2561		return false
2562	case tui.KeyPageDown:
2563		i.scrollBy(-i.chatPage())
2564		return false
2565	case tui.KeyUp:
2566		// Alt/Option+Up: pop the most recently queued ("sliding in")
2567		// message back into the editor so the user can edit and
2568		// resend it. Repeated presses keep peeling messages off the
2569		// tail of the queue; each press *replaces* the editor
2570		// contents (we don't append/push). When the queue is empty
2571		// the keypress falls through to the normal scroll behavior.
2572		if k.Alt {
2573			i.mu.Lock()
2574			var text string
2575			if i.agent != nil {
2576				text, _ = i.agent.PopQueuedMessage()
2577			}
2578			if text == "" {
2579				if n := len(i.queued); n > 0 {
2580					text = i.queued[n-1]
2581					i.queued = i.queued[:n-1]
2582				}
2583			}
2584			i.mu.Unlock()
2585			if text != "" {
2586				i.ed.SetValue(text)
2587				i.inputHistoryIndex = -1
2588				i.invalidate()
2589				return false
2590			}
2591		}
2592		// In multi-line / wrapped input, Up first moves inside the editor.
2593		// At the editor's top edge, plain Up can browse input history when
2594		// history browsing is safe/active; otherwise it falls back to chat
2595		// scrolling, preserving the old single-line scroll behavior.
2596		if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2597			if i.ed.MoveVertical(-1) {
2598				i.invalidate()
2599				return false
2600			}
2601			if !k.Alt && i.handleInputHistoryKey(k) {
2602				return false
2603			}
2604			i.scrollBy(+3)
2605			return false
2606		}
2607	case tui.KeyDown:
2608		if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2609			if i.ed.MoveVertical(+1) {
2610				i.invalidate()
2611				return false
2612			}
2613			if !k.Alt && i.handleInputHistoryKey(k) {
2614				return false
2615			}
2616			if i.scrollOffset > 0 {
2617				i.scrollBy(-3)
2618			}
2619			return false
2620		}
2621	}
2622
2623	// Note: we intentionally do NOT gate the editor on i.busy here.
2624	// Typing while the agent is working is supported — submitted
2625	// messages are queued and delivered as follow-up turns when the
2626	// current turn ends. See the submit handler below.
2627
2628	if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) {
2629		i.ed.HandleKey(k)
2630		return false
2631	}
2632
2633	// Slash suggestions: intercept up/down/tab/enter when the popup is visible.
2634	if i.suggest.Active(i.ed.Value()) {
2635		switch k.Kind {
2636		case tui.KeyUp:
2637			i.suggest.Up()
2638			return false
2639		case tui.KeyDown:
2640			i.suggest.Down()
2641			return false
2642		case tui.KeyPageUp:
2643			i.suggest.PageUp()
2644			return false
2645		case tui.KeyPageDown:
2646			i.suggest.PageDown()
2647			return false
2648		case tui.KeyTab:
2649			if name := i.suggest.Selection(i.ed.Value()); name != "" {
2650				i.ed.SetValue(name)
2651				i.suggest.Reset()
2652			}
2653			return false
2654		case tui.KeyEnter:
2655			// Enter on an ambiguous or partial slash prefix: complete to the
2656			// currently highlighted command and run it. That way typing
2657			// "/lo" + enter picks whichever of /login or /logout is selected
2658			// in the popup instead of submitting "/lo" as unknown. Also
2659			// clear the editor so the command doesn't linger after the
2660			// dialog opens/closes.
2661			if name := i.suggest.Selection(i.ed.Value()); name != "" {
2662				i.ed.Clear()
2663				i.suggest.Reset()
2664				return i.runSlash(ctx, name)
2665			}
2666		case tui.KeyEsc:
2667			i.ed.Clear()
2668			i.suggest.Reset()
2669			return false
2670		}
2671	}
2672
2673	// File suggestions: intercept up/down/tab/enter when the @-popup is visible.
2674	if i.fileSuggest.Active(i.ed.Value()) {
2675		switch k.Kind {
2676		case tui.KeyUp:
2677			i.fileSuggest.Up()
2678			return false
2679		case tui.KeyDown:
2680			i.fileSuggest.Down()
2681			return false
2682		case tui.KeyRight:
2683			// Open selected directory. The filter the user typed picked
2684			// that directory at the current level; once we descend it no
2685			// longer applies to the directory's contents, so clear it.
2686			// Otherwise typing "@eda" then right would re-filter inside
2687			// eda/ by "eda" and show nothing.
2688			if i.fileSuggest.Right() {
2689				i.clearFileSuggestQuery()
2690			}
2691			return false
2692		case tui.KeyLeft:
2693			// Go back to parent directory. Clear the filter for the same
2694			// reason as Right: it was scoped to the level we just left.
2695			if i.fileSuggest.Left() {
2696				i.clearFileSuggestQuery()
2697			}
2698			return false
2699		case tui.KeyEnter:
2700			if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
2701				var chip string
2702				if entry.isDir {
2703					chip = "[dir:" + entry.rel + "/]"
2704				} else {
2705					chip = "[file:" + entry.rel + "]"
2706				}
2707				val := i.ed.Value()
2708				if idx := strings.LastIndex(val, "@"); idx >= 0 {
2709					val = val[:idx]
2710				}
2711				i.ed.SetValue(val + chip + " ")
2712				i.fileSuggest.Reset()
2713			}
2714			return false
2715		case tui.KeyEsc:
2716			val := i.ed.Value()
2717			if idx := strings.LastIndex(val, "@"); idx >= 0 {
2718				i.ed.SetValue(val[:idx])
2719			}
2720			i.fileSuggest.Reset()
2721			return false
2722		}
2723	}
2724
2725	// Tab-complete a path token in the editor when no popup is open.
2726	// Recognises tokens that look like paths (start with ~, /, ./, ../
2727	// or contain a slash); shell-style completion expands ~, lists the
2728	// parent dir, and completes the basename to the longest common
2729	// prefix. Single match: full replace and trailing / for dirs.
2730	// No match: no-op. Plain bare words (no slash, no tilde) fall
2731	// through so Tab keeps its current no-op behaviour outside paths.
2732	if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2733		if i.tryPathTabComplete() {
2734			return false
2735		}
2736	}
2737
2738	if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
2739		i.inputHistoryIndex = -1
2740	}
2741
2742	if k.Kind == tui.KeyEsc {
2743		i.clipboardImages = nil
2744	}
2745	if submit := i.ed.HandleKey(k); submit {
2746		// SubmitValue() expands any [pasted text #N +L lines]
2747		// placeholders back into their bodies; the raw Value()
2748		// is only what the user sees on screen.
2749		text := strings.TrimRight(i.ed.SubmitValue(), "\n")
2750		// Expand [file:name] and [dir:name/] chips to full paths.
2751		text = expandFileChips(text, i.cfg.CWD)
2752		text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
2753		if text == "" && len(images) == 0 {
2754			return false
2755		}
2756		i.clipboardImages = nil
2757		i.ed.Clear()
2758		i.inputHistoryIndex = -1
2759		i.suggest.Reset()
2760		i.fileSuggest.Reset()
2761
2762		if cmd, ok := shellEscapeCommand(text); ok {
2763			i.startShellEscape(ctx, cmd)
2764			return false
2765		}
2766
2767		if looksLikeSlashCommand(text) {
2768			head := text
2769			rest := ""
2770			if idx := strings.IndexAny(text, " \t"); idx >= 0 {
2771				head = text[:idx]
2772				rest = strings.TrimSpace(text[idx:])
2773			}
2774			if !isKnownSlashCommand(text) {
2775				// Try extensions before giving up. Extensions register
2776				// commands by bare name (no leading slash); strip it here.
2777				extName := strings.TrimPrefix(head, "/")
2778				if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
2779					go i.invokeExtensionCommand(ctx, extName, rest)
2780					return false
2781				}
2782				i.mu.Lock()
2783				i.statusErr = "unknown command " + head + " — type /help to see the list"
2784				i.statusOK = ""
2785				i.mu.Unlock()
2786				return false
2787			}
2788			// Slash commands run regardless of busy state. Commands that
2789			// would mutate the transcript or replace the agent (/clear,
2790			// /compact, /logout, /login, /model) cancel the active turn
2791			// first and wait for the goroutine to wind down so they don't
2792			// race with a streaming response. Safe commands (/help,
2793			// /jump, /sessions, /jail, /unjail, /exit) run immediately
2794			// without disturbing the active turn.
2795			if slashCancelsTurn(head) {
2796				i.cancelAndWaitForIdle()
2797			}
2798			return i.runSlash(ctx, text)
2799		}
2800
2801		if i.agent == nil {
2802			i.mu.Lock()
2803			i.statusErr = "not logged in. type /login first."
2804			i.mu.Unlock()
2805			return false
2806		}
2807		// Mirror the user's typed prompt into the paired Telegram
2808		// chat (when the bridge is active) so the Telegram thread
2809		// stays a complete record of the session, not just the half
2810		// that originated on the phone. On a goroutine so the
2811		// network write doesn't delay the local turn.
2812		if i.telegramBridge != nil && i.telegramBridge.Active() {
2813			go i.telegramBridge.OnUserTyped(text)
2814		}
2815		// If a turn is already in flight, queue this prompt inside the
2816		// agent loop so it is delivered at the next safe model-call
2817		// boundary instead of waiting for the whole run to finish.
2818		i.mu.Lock()
2819		busy := i.busy
2820		compacting := i.compacting
2821		ag := i.agent
2822		i.mu.Unlock()
2823		if busy {
2824			if len(images) > 0 {
2825				i.mu.Lock()
2826				i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
2827				i.mu.Unlock()
2828				i.invalidate()
2829				return false
2830			}
2831			if ag != nil && !compacting {
2832				ag.QueueMessage(text)
2833			} else {
2834				i.mu.Lock()
2835				i.queued = append(i.queued, text)
2836				i.mu.Unlock()
2837			}
2838			i.invalidate()
2839			return false
2840		}
2841		i.startTurnWithImages(ctx, text, images)
2842	}
2843	return false
2844}
2845
2846func (i *Interactive) pasteClipboard() {
2847	data, ok, err := readClipboardImageAndCleanup(tui.ReadClipboardImagePNG)
2848	if err != nil {
2849		i.mu.Lock()
2850		i.statusErr = "clipboard paste failed: " + err.Error()
2851		i.statusOK = ""
2852		i.mu.Unlock()
2853		return
2854	}
2855	if ok {
2856		i.mu.Lock()
2857		marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
2858		i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
2859			Marker: marker,
2860			Image:  provider.ImageBlock{MimeType: "image/png", Data: data},
2861		})
2862		i.ed.Insert(marker + " ")
2863		i.statusOK = ""
2864		i.statusErr = ""
2865		i.mu.Unlock()
2866		return
2867	}
2868	key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText)
2869	i.mu.Lock()
2870	defer i.mu.Unlock()
2871	if err != nil {
2872		i.statusErr = "clipboard paste failed: " + err.Error()
2873		i.statusOK = ""
2874		return
2875	}
2876	if pastedText {
2877		i.ed.HandleKey(key)
2878		i.statusErr = ""
2879		i.statusOK = ""
2880		return
2881	}
2882	i.statusErr = "clipboard does not contain text or an image"
2883	i.statusOK = ""
2884}
2885
2886func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
2887	if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
2888		return false
2889	}
2890	// Do not steal normal vertical cursor movement. History browsing can only
2891	// start from an empty editor; once active, Up/Down keep walking
2892	// the ring so repeated presses work even though the editor now
2893	// contains the selected historical prompt.
2894	if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
2895		return false
2896	}
2897	hist := i.inputHistory()
2898	if len(hist) == 0 {
2899		return false
2900	}
2901
2902	if i.inputHistoryIndex < 0 {
2903		// Start just after the newest item so Up lands on the most
2904		// recent user prompt. A lone Down from an empty editor is not
2905		// history navigation; let the caller fall through to normal UI
2906		// behavior instead.
2907		if k.Kind != tui.KeyUp {
2908			return false
2909		}
2910		i.inputHistoryIndex = len(hist)
2911	}
2912
2913	switch k.Kind {
2914	case tui.KeyUp:
2915		if i.inputHistoryIndex > 0 {
2916			i.inputHistoryIndex--
2917		}
2918	case tui.KeyDown:
2919		if i.inputHistoryIndex < len(hist) {
2920			i.inputHistoryIndex++
2921		}
2922	}
2923
2924	if i.inputHistoryIndex >= len(hist) {
2925		i.ed.Clear()
2926	} else {
2927		i.ed.SetValue(hist[i.inputHistoryIndex])
2928	}
2929	return true
2930}
2931
2932func (i *Interactive) inputHistory() []string {
2933	if i.agent == nil {
2934		return nil
2935	}
2936	msgs := i.agent.Messages()
2937	hist := make([]string, 0, len(msgs))
2938	for _, m := range msgs {
2939		if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) {
2940			continue
2941		}
2942		text := userMessageText(m)
2943		if m.Meta[shellEscapeMetaKey] == "true" {
2944			// Older sessions lack command metadata. Never recall shell output
2945			// as input or try to recover a command from the terminal log.
2946			cmd := m.Meta[shellEscapeCommandMetaKey]
2947			if strings.TrimSpace(cmd) == "" {
2948				continue
2949			}
2950			text = "!" + cmd
2951		}
2952		if strings.TrimSpace(text) == "" {
2953			continue
2954		}
2955		hist = append(hist, text)
2956	}
2957	return hist
2958}
2959
2960func userMessageText(m provider.Message) string {
2961	var sb strings.Builder
2962	for _, c := range m.Content {
2963		if tb, ok := c.(provider.TextBlock); ok {
2964			if sb.Len() > 0 {
2965				sb.WriteByte('\n')
2966			}
2967			sb.WriteString(tb.Text)
2968		}
2969	}
2970	return sb.String()
2971}
2972
2973// invokeExtensionCommand fires an extension-registered slash command
2974// in a background goroutine, awaits the response, and applies the
2975// requested action (prompt / insert / display / noop). Errors and
2976// timeouts surface as a status_err line.
2977
2978func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
2979	resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
2980	if err != nil {
2981		i.mu.Lock()
2982		i.statusErr = "extension /" + name + ": " + err.Error()
2983		i.statusOK = ""
2984		i.mu.Unlock()
2985		i.invalidate()
2986		return
2987	}
2988	if resp.Error != "" {
2989		i.mu.Lock()
2990		i.statusErr = "extension /" + name + ": " + resp.Error
2991		i.statusOK = ""
2992		i.mu.Unlock()
2993		i.invalidate()
2994		return
2995	}
2996	switch resp.Action {
2997	case "open_panel":
2998		if resp.OpenPanel != nil {
2999			extName := name
3000			if i.cfg.Extensions != nil {
3001				if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
3002					extName = owner
3003				}
3004			}
3005			i.OpenPanel(extName, *resp.OpenPanel)
3006		}
3007	case "prompt":
3008		if strings.TrimSpace(resp.Prompt) == "" {
3009			return
3010		}
3011		i.startTurn(i.runCtx, resp.Prompt)
3012	case "insert":
3013		i.ed.Insert(resp.Insert)
3014		i.invalidate()
3015	case "display":
3016		i.appendExtensionNote(name, resp.Display, "info")
3017	case "noop", "":
3018		// nothing
3019	default:
3020		i.mu.Lock()
3021		i.statusErr = "extension /" + name + ": unknown action " + resp.Action
3022		i.mu.Unlock()
3023		i.invalidate()
3024	}
3025}
3026
3027// appendExtensionNote renders an extension-originated note in the
3028// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
3029// "success" (tool/ok green).
3030func (i *Interactive) appendExtensionNote(extName, msg, level string) {
3031	if msg == "" {
3032		return
3033	}
3034	i.mu.Lock()
3035	defer i.mu.Unlock()
3036	color := i.cfg.Theme.Muted
3037	switch level {
3038	case "warn":
3039		color = i.cfg.Theme.Warning
3040	case "error":
3041		color = i.cfg.Theme.Error
3042	case "success":
3043		color = i.cfg.Theme.Tool
3044	}
3045	prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
3046	for _, line := range strings.Split(msg, "\n") {
3047		i.statusOK = "" // clear any stale ok
3048		i.statusErr = ""
3049		i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
3050	}
3051	// Extension slash commands complete asynchronously. If their result is
3052	// displayed while confirmation is waiting, keep Esc assigned to the note
3053	// until it is dismissed rather than letting it answer the tool call.
3054	i.confirmDialog.Blur()
3055}
3056
3057// HostHooks implementation for the extension manager. The manager
3058// holds an interface, not a concrete *Interactive, so these methods
3059// are the only thing the manager sees.
3060
3061// Notify is the manager's NotifyFromExt entry point.
3062func (i *Interactive) Notify(extName, level, message string) {
3063	i.appendExtensionNote(extName, message, level)
3064	i.invalidate()
3065}
3066
3067// ClearNotes removes every note line owned by extName from the
3068// bottom-sticky ext-notes block. Extensions use this to retract a
3069// transient status line (e.g. an approval prompt) once it no longer
3070// applies, instead of leaving it stacked forever. Notes from other
3071// extensions and internal notes (auto-compact) are left untouched.
3072func (i *Interactive) ClearNotes(extName string) {
3073	marker := "[" + extName + "] "
3074	i.mu.Lock()
3075	if len(i.extNotes) == 0 {
3076		i.mu.Unlock()
3077		return
3078	}
3079	kept := i.extNotes[:0:0]
3080	changed := false
3081	for _, line := range i.extNotes {
3082		if strings.Contains(line, marker) {
3083			changed = true
3084			continue
3085		}
3086		kept = append(kept, line)
3087	}
3088	if changed {
3089		i.extNotes = kept
3090	}
3091	i.mu.Unlock()
3092	if changed {
3093		i.invalidate()
3094	}
3095}
3096
3097// Submit feeds text through the agent loop as if the user had typed it.
3098func (i *Interactive) Submit(text string) {
3099	if cmd, ok := shellEscapeCommand(text); ok {
3100		i.startShellEscape(i.runCtx, cmd)
3101		return
3102	}
3103	i.startTurn(i.runCtx, text)
3104}
3105
3106// completeStartupPre applies deferred InitialInput after entry.pre finishes.
3107// When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise
3108// it only pre-fills the editor (CLI-supplied prompts).
3109func (i *Interactive) completeStartupPre() {
3110	i.mu.Lock()
3111	if !i.awaitingStartupPre {
3112		i.mu.Unlock()
3113		return
3114	}
3115	i.awaitingStartupPre = false
3116	deferred := i.deferredInitialInput
3117	auto := i.autoSubmitDeferred
3118	i.deferredInitialInput = ""
3119	i.autoSubmitDeferred = false
3120	onDone := i.cfg.OnStartupPreDone
3121	i.mu.Unlock()
3122	if onDone != nil {
3123		onDone()
3124	}
3125	i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto}
3126	i.invalidate()
3127}
3128
3129// applyStartupPreResult runs on the TUI event loop so the editor remains
3130// single-threaded. Input entered while resources were reloading wins over the
3131// deferred prefill rather than being overwritten.
3132func (i *Interactive) applyStartupPreResult(result startupPreResult) {
3133	if result.deferred == "" {
3134		return
3135	}
3136	if result.autoSubmit {
3137		i.Submit(result.deferred)
3138		return
3139	}
3140	if i.ed.IsEmpty() {
3141		i.ed.SetValue(result.deferred)
3142	}
3143}
3144
3145// ApplyChangedCWD is called by hosts after a successful /cd hook that do
3146// not provide startup context metadata.
3147func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
3148	i.applyChangedCWD(ag, provider, model, cwd, nil)
3149}
3150
3151// ApplyChangedCWDWithStartupContext swaps a rebuilt agent and its display-only
3152// startup context into the running TUI after a successful /cd hook.
3153func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
3154	i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths)
3155}
3156
3157func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
3158	i.mu.Lock()
3159	i.agent = ag
3160	i.cfg.CWD = cwd
3161	i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
3162	i.view.StartupContextPaths = nil
3163	if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
3164		i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
3165	}
3166	// Re-report the working directory to the terminal so "new tab here"
3167	// tracks the /cd change (OSC 7, see issue #38).
3168	if i.cfg.Terminal != nil {
3169		if seq := tui.ReportCWD(cwd); seq != "" {
3170			_, _ = i.cfg.Terminal.Write([]byte(seq))
3171		}
3172	}
3173	i.cfg.Provider = provider
3174	i.cfg.Model = model
3175	i.toolCalls = map[string]*tui.ToolCallView{}
3176	i.toolOrder = nil
3177	i.toolGate = map[string]int{}
3178	i.helpBlock = nil
3179	i.parkedTurn = 0
3180	i.statusErr = ""
3181	i.mu.Unlock()
3182	i.fileSuggest.Reset()
3183	i.fileSuggest.SetCWD(cwd)
3184	i.invalidate()
3185}
3186
3187// SubmitSlash runs text as a slash command in the TUI as if the user
3188// had typed it. text must start with '/' — callers that hand it
3189// plain prose silently get a no-op so a misbehaving extension can't
3190// run a stray prompt through this path. Read-only commands run in
3191// place; commands that would mutate the transcript or replace the
3192// agent cancel the active turn first via the same path the editor
3193// uses for typed slash commands.
3194func (i *Interactive) SubmitSlash(text string) {
3195	text = strings.TrimSpace(text)
3196	if !strings.HasPrefix(text, "/") {
3197		return
3198	}
3199	head := text
3200	if idx := strings.IndexAny(text, " \t"); idx >= 0 {
3201		head = text[:idx]
3202	}
3203	if slashCancelsTurn(head) {
3204		i.cancelAndWaitForIdle()
3205	}
3206	i.runSlash(i.runCtx, text)
3207	i.invalidate()
3208}
3209
3210// SubmitOrQueue runs text immediately if the agent is idle, or
3211// appends it to the pending queue if a turn is already in flight.
3212// Used by the telegram bridge (and by the editor submit path) so
3213// both input sources share the same "queue behind an active turn"
3214// semantics. Images are ignored for now — only the text prompt is
3215// forwarded — because the queued-prompt path is text-only; a
3216// follow-up can expand the queue entry to carry images.
3217func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
3218	if cmd, ok := shellEscapeCommand(text); ok {
3219		i.startShellEscape(i.runCtx, cmd)
3220		return
3221	}
3222	i.mu.Lock()
3223	if i.agent == nil {
3224		i.statusErr = "not logged in. type /login first."
3225		i.mu.Unlock()
3226		i.invalidate()
3227		return
3228	}
3229	if i.busy {
3230		// Queue text only; images are dropped for queued prompts. Compaction
3231		// uses the host queue because it has no active agent loop to drain
3232		// Agent.QueueMessage entries.
3233		ag := i.agent
3234		compacting := i.compacting
3235		i.mu.Unlock()
3236		if ag != nil && !compacting {
3237			ag.QueueMessage(text)
3238		} else {
3239			i.mu.Lock()
3240			i.queued = append(i.queued, text)
3241			i.mu.Unlock()
3242		}
3243		i.invalidate()
3244		return
3245	}
3246	i.mu.Unlock()
3247	i.startTurnWithImages(i.runCtx, text, images)
3248}
3249
3250// CancelTurn aborts the active turn if one is running. Used by the
3251// telegram bridge when the paired user sends /stop.
3252// ChangelogVersion returns the version string of the changelog
3253// currently shown (or last shown). Used by the dismiss callback
3254// to store the correct version for dev builds.
3255func (i *Interactive) ChangelogVersion() string {
3256	if i.changelogDialog != nil {
3257		return i.changelogDialog.version
3258	}
3259	return ""
3260}
3261
3262func (i *Interactive) CancelTurn() {
3263	i.mu.Lock()
3264	cancel := i.cancelTurn
3265	i.mu.Unlock()
3266	if cancel != nil {
3267		cancel()
3268		i.confirmDialog.CancelAll("turn cancelled")
3269	}
3270}
3271
3272// Insert places text at the cursor in the editor.
3273func (i *Interactive) Insert(text string) {
3274	i.ed.Insert(text)
3275	i.invalidate()
3276}
3277
3278// Display appends a styled note from extName to the chat without a
3279// model call.
3280func (i *Interactive) Display(extName, text string) {
3281	i.appendExtensionNote(extName, text, "info")
3282	i.invalidate()
3283}
3284
3285func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
3286	i.mu.Lock()
3287	defer i.mu.Unlock()
3288	i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
3289	i.confirmDialog.Blur()
3290	if i.cfg.Extensions != nil {
3291		cols, rows := i.cfg.Terminal.Size()
3292		_ = cols
3293		_ = rows
3294	}
3295	i.invalidate()
3296}
3297
3298func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
3299	i.mu.Lock()
3300	defer i.mu.Unlock()
3301	if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
3302		i.extPanel.Update(title, lines, footer)
3303		i.invalidate()
3304	}
3305}
3306
3307func (i *Interactive) ClosePanel(extName, panelID string) {
3308	i.mu.Lock()
3309	defer i.mu.Unlock()
3310	if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
3311		i.extPanel.Close()
3312		i.invalidate()
3313	}
3314}
3315
3316func effectiveImageProtocol(override *bool) tui.ImageProtocol {
3317	detected := tui.DetectImageProtocol()
3318	if override == nil {
3319		return detected
3320	}
3321	if !*override {
3322		return tui.ImageProtocolNone
3323	}
3324	return detected
3325}
3326
3327func imageProtocolName(p tui.ImageProtocol) string {
3328	switch p {
3329	case tui.ImageProtocolKitty:
3330		return "kitty/ghostty"
3331	case tui.ImageProtocolITerm2:
3332		return "iTerm2"
3333	default:
3334		return "none"
3335	}
3336}
3337
3338func onOff(v bool) string {
3339	if v {
3340		return "enabled"
3341	}
3342	return "disabled"
3343}
3344
3345func (i *Interactive) applyInputCursorColor() {
3346	if i == nil || i.cfg.Terminal == nil {
3347		return
3348	}
3349	_, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock()))
3350}
3351
3352func reasoningSettingOptions(levels []string) []settingsOption {
3353	descriptions := map[string]string{
3354		"":        "no reasoning",
3355		"minimum": "very brief (~1k tokens)",
3356		"low":     "light (~2k tokens)",
3357		"medium":  "moderate (~8k tokens)",
3358		"high":    "deep (~16k tokens)",
3359		"xhigh":   "extra-high effort",
3360		"max":     "unconstrained effort",
3361	}
3362	options := make([]settingsOption, 0, len(levels))
3363	for _, level := range levels {
3364		label := level
3365		if label == "" {
3366			label = "off"
3367		}
3368		options = append(options, settingsOption{value: level, label: label, desc: descriptions[level]})
3369	}
3370	return options
3371}
3372
3373func (i *Interactive) reasoningSettingItem() settingsItem {
3374	levels := []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
3375	reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
3376	desc := "reasoning depth for reasoning-capable models"
3377	hint := ""
3378	if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
3379		levels = provider.AvailableReasoningLevels(m)
3380		reasoning = provider.ClampReasoningForModel(m, reasoning)
3381		if !m.Reasoning {
3382			hint = "current model does not support reasoning"
3383			desc += "; current model does not support reasoning"
3384		} else if len(levels) == 1 {
3385			hint = "current model has no adjustable reasoning levels"
3386			desc += "; current model has no adjustable reasoning levels"
3387		} else {
3388			desc += "; only levels supported by the current model are shown"
3389		}
3390	}
3391	options := reasoningSettingOptions(levels)
3392	choice := 0
3393	for idx, opt := range options {
3394		if opt.value == reasoning {
3395			choice = idx
3396			break
3397		}
3398	}
3399
3400	return settingsItem{
3401		key:     "reasoning",
3402		label:   "reasoning level",
3403		desc:    desc,
3404		options: options,
3405		choice:  choice,
3406		hint:    hint,
3407	}
3408}
3409
3410func (i *Interactive) openReasoningDialog() {
3411	i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())
3412}
3413
3414func (i *Interactive) openSettingsDialog() {
3415	detected := tui.DetectImageProtocol()
3416	imgEnabled := detected != tui.ImageProtocolNone
3417	if i.cfg.InlineImagesEnabled != nil {
3418		imgEnabled = *i.cfg.InlineImagesEnabled
3419	}
3420	imgDisabled := detected == tui.ImageProtocolNone
3421	imgHint := ""
3422	if imgDisabled {
3423		imgEnabled = false
3424		imgHint = "this terminal does not support inline images"
3425	} else {
3426		imgHint = "terminal supports " + imageProtocolName(detected)
3427	}
3428
3429	autoSwarm := false
3430	if i.cfg.AutoSwarmEnabled != nil {
3431		autoSwarm = *i.cfg.AutoSwarmEnabled
3432	}
3433	autoSwarmDisabled := i.cfg.Swarm == nil
3434	autoSwarmHint := ""
3435	if autoSwarmDisabled {
3436		autoSwarm = false
3437		autoSwarmHint = "swarm supervisor not available in this mode"
3438	}
3439
3440	jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
3441	openRouterServerTools := i.openRouterServerToolsEnabled()
3442	openRouterServerToolsDisabled := true
3443	openRouterServerToolsHint := ""
3444	switch {
3445	case i.cfg.Provider != "openrouter":
3446		openRouterServerToolsHint = "only available when the current provider is OpenRouter"
3447	case i.cfg.NoTools:
3448		openRouterServerToolsHint = "disabled for this run by --no-tools"
3449	case strings.Contains(strings.ToLower(i.cfg.Model), "@preset/"):
3450		openRouterServerToolsHint = "this preset owns its server-tool configuration"
3451	default:
3452		openRouterServerToolsDisabled = false
3453	}
3454	recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
3455	respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
3456	compactMode := i.compactModeEnabled()
3457	collapseToolCall := i.cfg.CollapseToolCall != nil && *i.cfg.CollapseToolCall
3458	showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup
3459	inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
3460	statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
3461	workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
3462	quickItems := i.quickModelSettingItems()
3463
3464	autoCompactOptions := []settingsOption{
3465		{value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"},
3466		{value: "70", label: "70%", desc: "compact earlier to keep more context headroom"},
3467		{value: "80", label: "80%", desc: "compact with moderate context headroom"},
3468		{value: "85", label: "85%", desc: "default balance between history and headroom"},
3469		{value: "90", label: "90%", desc: "retain more history before compacting"},
3470	}
3471	autoCompactThreshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
3472	autoCompactChoice := 0
3473	for idx, opt := range autoCompactOptions {
3474		if opt.value == strconv.Itoa(autoCompactThreshold) {
3475			autoCompactChoice = idx
3476			break
3477		}
3478	}
3479
3480	reasoningItem := i.reasoningSettingItem()
3481
3482	themeName := i.cfg.ThemeName
3483	if themeName == "" {
3484		themeName = "auto"
3485	}
3486	if themeName != "auto" && !tui.ThemeExists(i.cfg.ZotHome, themeName) {
3487		themeName = "auto"
3488		i.cfg.ThemeName = ""
3489		if i.cfg.SettingsStore != nil {
3490			_ = i.cfg.SettingsStore.SetTheme("auto")
3491		}
3492		i.applyThemeNow("auto")
3493	}
3494	themeOptions := []settingsOption{}
3495	themeChoice := 0
3496	availableThemes := tui.AvailableThemes(i.cfg.ZotHome)
3497	if i.cfg.ExtensionThemes != nil {
3498		availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...)
3499	}
3500	for idx, opt := range availableThemes {
3501		themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description})
3502		if opt.Value == themeName {
3503			themeChoice = idx
3504		}
3505	}
3506
3507	inputStyleOptions := []settingsOption{
3508		{value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
3509		{value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
3510		{value: tui.InputStyleBlock, label: "block", desc: "render the input as a user-bubble-style block"},
3511	}
3512	inputStyleChoice := 0
3513	for idx, opt := range inputStyleOptions {
3514		if opt.value == inputStyle {
3515			inputStyleChoice = idx
3516			break
3517		}
3518	}
3519	statusPositionOptions := []settingsOption{
3520		{value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
3521		{value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
3522	}
3523	statusPositionChoice := 0
3524	for idx, opt := range statusPositionOptions {
3525		if opt.value == statusPosition {
3526			statusPositionChoice = idx
3527			break
3528		}
3529	}
3530
3531	workingPositionOptions := []settingsOption{
3532		{value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
3533		{value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
3534	}
3535	workingPositionChoice := 0
3536	for idx, opt := range workingPositionOptions {
3537		if opt.value == workingPosition {
3538			workingPositionChoice = idx
3539			break
3540		}
3541	}
3542
3543	items := []settingsItem{
3544		{
3545			key:      "inline_images_enabled",
3546			label:    "render images when supported",
3547			desc:     "draw screenshots inline instead of showing a text placeholder",
3548			value:    imgEnabled,
3549			disabled: imgDisabled,
3550			hint:     imgHint,
3551		},
3552		{
3553			key:      "auto_swarm_enabled",
3554			label:    "auto-swarm",
3555			desc:     "let the agent spawn background sub-agents in parallel via the swarm_spawn tool",
3556			value:    autoSwarm,
3557			disabled: autoSwarmDisabled,
3558			hint:     autoSwarmHint,
3559		},
3560		{
3561			key:     "auto_compact_threshold",
3562			label:   "auto-compact threshold",
3563			desc:    "choose how full the model context can get before zot condenses conversation history",
3564			options: autoCompactOptions,
3565			choice:  autoCompactChoice,
3566		},
3567		{
3568			key:   "jail_by_default",
3569			label: "jail new sessions by default",
3570			desc:  "confine tools to the session working directory unless /unjail is used",
3571			value: jailByDefault,
3572		},
3573		{
3574			key:      "openrouter_server_tools_enabled",
3575			label:    "OpenRouter Server Tools",
3576			desc:     "advertise beta remote tools such as web search, datetime, and advisor; additional charges may apply",
3577			value:    openRouterServerTools && !openRouterServerToolsDisabled,
3578			disabled: openRouterServerToolsDisabled,
3579			hint:     openRouterServerToolsHint,
3580		},
3581		{
3582			key:   "recursive_file_suggest",
3583			label: "recursive @-file search",
3584			desc:  "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
3585			value: recursiveFiles,
3586		},
3587		{
3588			key:   "fuzzy_skill_suggest",
3589			label: "fuzzy skill suggestions",
3590			desc:  "rank /skill: suggestions by name subsequence matches and emphasize matching characters",
3591			value: i.cfg.FuzzySkillSuggest != nil && *i.cfg.FuzzySkillSuggest,
3592		},
3593		{
3594			key:   "respect_gitignore",
3595			label: "hide gitignored files in @-picker",
3596			desc:  "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
3597			value: respectGitignore,
3598		},
3599		{
3600			key:   "compact_mode",
3601			label: "compact transcript rendering",
3602			desc:  "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
3603			value: compactMode,
3604		},
3605		{
3606			key:   "collapse_tool_call",
3607			label: "collapse tool call rendering",
3608			desc:  "retain tool headers, a final preview line, and failure status; ctrl+o expands the full output",
3609			value: collapseToolCall,
3610		},
3611		{
3612			key:   "show_instructions_at_startup",
3613			label: "show loaded resources at startup",
3614			desc:  "list loaded context files, extensions, and user-installed skills above the transcript",
3615			value: showInstructions,
3616		},
3617		{
3618			key:   "tui_settings",
3619			label: "tui settings",
3620			desc:  "choose input chrome and where status information appears",
3621			children: []settingsItem{
3622				{
3623					key:     "tui_input_style",
3624					label:   "input style",
3625					desc:    "choose between the plain prompt, lines, and a block input area",
3626					options: inputStyleOptions,
3627					choice:  inputStyleChoice,
3628				},
3629				{
3630					key:     "tui_status_position",
3631					label:   "status position",
3632					desc:    "place model, usage, and working directory above or below the input",
3633					options: statusPositionOptions,
3634					choice:  statusPositionChoice,
3635				},
3636				{
3637					key:     "tui_working_position",
3638					label:   "working spinner position",
3639					desc:    "place the working spinner above or below the input",
3640					options: workingPositionOptions,
3641					choice:  workingPositionChoice,
3642				},
3643			},
3644		},
3645		reasoningItem,
3646		{
3647			key:     "theme",
3648			label:   "color theme",
3649			desc:    "choose a theme from $ZOT_HOME/themes or a loaded extension",
3650			options: themeOptions,
3651			choice:  themeChoice,
3652		},
3653	}
3654	if len(quickItems) > 0 {
3655		items = append(items, settingsItem{
3656			key:      "quick_models",
3657			label:    "model shortcuts",
3658			desc:     "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches",
3659			children: quickItems,
3660		})
3661	}
3662	items = i.appendPowerShellSetting(items, runtime.GOOS)
3663	i.settingsDialog.Open(items)
3664}
3665
3666func (i *Interactive) applySettingChange(act settingsAction) {
3667	switch {
3668	case strings.HasPrefix(act.Key, "quick_model_"):
3669		i.applyQuickModelSetting(act.Key, act.StringValue)
3670	case act.Key == "reasoning":
3671		i.applyReasoningSetting(act.StringValue)
3672	case act.Key == "theme":
3673		i.applyThemeSetting(act.StringValue)
3674	case act.Key == "auto_compact_threshold":
3675		i.applyAutoCompactThresholdSetting(act.StringValue)
3676	case act.Key == "tui_input_style":
3677		i.applyTUIInputStyleSetting(act.StringValue)
3678	case act.Key == "tui_status_position":
3679		i.applyTUIStatusPositionSetting(act.StringValue)
3680	case act.Key == "tui_working_position":
3681		i.applyTUIWorkingPositionSetting(act.StringValue)
3682	default:
3683		i.applySettingToggle(act.Key, act.Value)
3684	}
3685}
3686
3687func (i *Interactive) applyAutoCompactThresholdSetting(value string) {
3688	threshold, err := strconv.Atoi(value)
3689	if err != nil {
3690		return
3691	}
3692	threshold = normalizeAutoCompactThreshold(&threshold)
3693	if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {
3694		if err := store.SetAutoCompactThreshold(threshold); err != nil {
3695			i.mu.Lock()
3696			i.statusErr = "settings: " + err.Error()
3697			i.mu.Unlock()
3698			return
3699		}
3700	}
3701	i.mu.Lock()
3702	i.cfg.AutoCompactThreshold = &threshold
3703	if threshold == 0 {
3704		i.statusOK = "auto-compact threshold off"
3705	} else {
3706		i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold)
3707	}
3708	i.statusErr = ""
3709	i.mu.Unlock()
3710	i.invalidate()
3711}
3712
3713func (i *Interactive) quickModelSettingItems() []settingsItem {
3714	if len(i.cfg.QuickModelShortcuts) < 9 {
3715		next := make([]QuickModelShortcut, 9)
3716		copy(next, i.cfg.QuickModelShortcuts)
3717		i.cfg.QuickModelShortcuts = next
3718	}
3719	items := make([]settingsItem, 0, 9)
3720	for slot := 1; slot <= 9; slot++ {
3721		items = append(items, i.quickModelSettingItem(slot))
3722	}
3723	return items
3724}
3725
3726func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
3727	current := QuickModelShortcut{}
3728	if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot {
3729		current = i.cfg.QuickModelShortcuts[slot-1]
3730	}
3731	hint := "not assigned"
3732	if current.Provider != "" && current.Model != "" {
3733		hint = current.Provider + " / " + current.Model
3734	}
3735	return settingsItem{
3736		key:    "quick_model_" + strconv.Itoa(slot),
3737		label:  "model " + strconv.Itoa(slot),
3738		desc:   quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.",
3739		picker: true,
3740		hint:   hint,
3741	}
3742}
3743
3744func (i *Interactive) compactModeEnabled() bool {
3745	return i.cfg.CompactMode != nil && *i.cfg.CompactMode
3746}
3747
3748func quickModelShortcutSlot(k tui.Key) int {
3749	if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' {
3750		return 0
3751	}
3752	if runtime.GOOS == "darwin" {
3753		if !k.Super && !k.Ctrl {
3754			return 0
3755		}
3756	} else if !k.Ctrl {
3757		return 0
3758	}
3759	return int(k.Rune - '0')
3760}
3761
3762func quickModelShortcutPrefix() string {
3763	return "Ctrl"
3764}
3765
3766func quickModelShortcutLabel(slot int) string {
3767	return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot)
3768}
3769
3770func (i *Interactive) openQuickModelPicker(slot int) {
3771	if slot < 1 || slot > 9 {
3772		return
3773	}
3774	i.quickModelAssign = slot
3775	current := i.cfg.Model
3776	if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
3777		current = i.cfg.QuickModelShortcuts[slot-1].Model
3778	}
3779	var loggedIn []string
3780	if i.cfg.LoggedInProviders != nil {
3781		loggedIn = i.cfg.LoggedInProviders()
3782	}
3783	i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning)
3784}
3785
3786func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
3787	i.setQuickModelShortcut(slot, providerName, model)
3788}
3789
3790func (i *Interactive) applyQuickModelShortcut(slot int) {
3791	if slot < 1 || slot > 9 {
3792		return
3793	}
3794	if i.busy {
3795		i.mu.Lock()
3796		i.statusErr = "cannot switch model while a turn is running"
3797		i.statusOK = ""
3798		i.mu.Unlock()
3799		i.invalidate()
3800		return
3801	}
3802	if len(i.cfg.QuickModelShortcuts) < slot {
3803		i.mu.Lock()
3804		i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
3805		i.statusOK = ""
3806		i.mu.Unlock()
3807		i.invalidate()
3808		return
3809	}
3810	shortcut := i.cfg.QuickModelShortcuts[slot-1]
3811	if shortcut.Provider == "" || shortcut.Model == "" {
3812		i.mu.Lock()
3813		i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
3814		i.statusOK = ""
3815		i.mu.Unlock()
3816		i.invalidate()
3817		return
3818	}
3819	i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false)
3820	i.invalidate()
3821}
3822
3823func (i *Interactive) applyQuickModelSetting(key, value string) {
3824	slotText := strings.TrimPrefix(key, "quick_model_")
3825	slot, err := strconv.Atoi(slotText)
3826	if err != nil || slot < 1 || slot > 9 {
3827		return
3828	}
3829	providerName, model := "", ""
3830	if value != "" {
3831		parts := strings.SplitN(value, "\t", 2)
3832		if len(parts) == 2 {
3833			providerName, model = parts[0], parts[1]
3834		}
3835	}
3836	i.setQuickModelShortcut(slot, providerName, model)
3837}
3838
3839func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) {
3840	if len(i.cfg.QuickModelShortcuts) < slot {
3841		next := make([]QuickModelShortcut, slot)
3842		copy(next, i.cfg.QuickModelShortcuts)
3843		i.cfg.QuickModelShortcuts = next
3844	}
3845	i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model}
3846	if i.cfg.SettingsStore != nil {
3847		if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil {
3848			i.mu.Lock()
3849			i.statusErr = "settings: " + err.Error()
3850			i.mu.Unlock()
3851			return
3852		}
3853	}
3854	i.mu.Lock()
3855	if model == "" {
3856		i.statusOK = quickModelShortcutLabel(slot) + " cleared"
3857	} else {
3858		i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model
3859	}
3860	i.statusErr = ""
3861	i.mu.Unlock()
3862	i.refreshQuickModelSettingsItem(slot)
3863	i.invalidate()
3864}
3865
3866func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
3867	if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 {
3868		return
3869	}
3870	key := "quick_model_" + strconv.Itoa(slot)
3871	for idx, it := range i.settingsDialog.items {
3872		if it.key == key {
3873			i.settingsDialog.items[idx] = i.quickModelSettingItem(slot)
3874			return
3875		}
3876	}
3877}
3878
3879func (i *Interactive) applySettingToggle(key string, value bool) {
3880	// Every setting toggle forces a full repaint at the end — same
3881	// effect as the user pressing Ctrl+L — so any per-setting visual
3882	// change (image rendering, status copy, future toggles) lands
3883	// immediately instead of waiting for the next diff frame.
3884	defer func() {
3885		if i.rend != nil {
3886			i.rend.Clear()
3887		}
3888		i.invalidate()
3889	}()
3890	switch key {
3891	case "inline_images_enabled":
3892		val := value
3893		i.cfg.InlineImagesEnabled = &val
3894		if i.cfg.SettingsStore != nil {
3895			if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
3896				i.mu.Lock()
3897				i.statusErr = "settings: " + err.Error()
3898				i.mu.Unlock()
3899				return
3900			}
3901		}
3902		i.mu.Lock()
3903		i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
3904		i.view.InvalidateRenderCache()
3905		i.statusOK = "inline image rendering " + onOff(value)
3906		i.statusErr = ""
3907		i.mu.Unlock()
3908	case "auto_swarm_enabled":
3909		val := value
3910		i.cfg.AutoSwarmEnabled = &val
3911		if i.cfg.SettingsStore != nil {
3912			if err := i.cfg.SettingsStore.SetAutoSwarm(value); err != nil {
3913				i.mu.Lock()
3914				i.statusErr = "settings: " + err.Error()
3915				i.mu.Unlock()
3916				return
3917			}
3918		}
3919		// Add/remove the swarm_spawn tool on the live agent so the
3920		// model's tools[] list reflects the toggle on the next turn.
3921		// Without this the tool stays advertised after a disable and
3922		// the model keeps trying to call it.
3923		i.applyAutoSwarmTool(value)
3924		// Also swap the system-prompt addendum in/out so the model
3925		// knows to use the tool proactively (or stops referencing it
3926		// after a disable).
3927		i.applyAutoSwarmSystemPrompt(value)
3928		i.mu.Lock()
3929		i.statusOK = "auto-swarm " + onOff(value)
3930		i.statusErr = ""
3931		i.mu.Unlock()
3932	case "jail_by_default":
3933		val := value
3934		i.cfg.JailByDefault = &val
3935		if i.cfg.SettingsStore != nil {
3936			if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil {
3937				i.mu.Lock()
3938				i.statusErr = "settings: " + err.Error()
3939				i.mu.Unlock()
3940				return
3941			}
3942		}
3943		if i.cfg.Sandbox != nil {
3944			if value {
3945				i.cfg.Sandbox.Lock()
3946			} else {
3947				i.cfg.Sandbox.Unlock()
3948			}
3949		}
3950		i.mu.Lock()
3951		i.statusOK = "jail by default " + onOff(value)
3952		i.statusErr = ""
3953		i.mu.Unlock()
3954	case "powershell_enabled":
3955		i.applyPowerShellSetting(value, runtime.GOOS)
3956	case "openrouter_server_tools_enabled":
3957		val := value
3958		i.cfg.OpenRouterServerToolsEnabled = &val
3959		if i.cfg.SettingsStore != nil {
3960			if err := i.cfg.SettingsStore.SetOpenRouterServerTools(value); err != nil {
3961				i.mu.Lock()
3962				i.statusErr = "settings: " + err.Error()
3963				i.mu.Unlock()
3964				return
3965			}
3966		}
3967		i.applyOpenRouterServerTools(value)
3968		i.mu.Lock()
3969		i.statusOK = "OpenRouter server tools " + onOff(value)
3970		i.statusErr = ""
3971		i.mu.Unlock()
3972	case "fuzzy_skill_suggest":
3973		if i.cfg.SettingsStore != nil {
3974			if err := i.cfg.SettingsStore.SetFuzzySkillSuggest(value); err != nil {
3975				i.mu.Lock()
3976				i.statusErr = "settings: " + err.Error()
3977				i.mu.Unlock()
3978				return
3979			}
3980		}
3981		i.cfg.FuzzySkillSuggest = &value
3982		i.suggest.SetFuzzySkills(value)
3983		i.mu.Lock()
3984		i.statusOK = "fuzzy skill suggestions " + onOff(value)
3985		i.statusErr = ""
3986		i.mu.Unlock()
3987	case "recursive_file_suggest":
3988		val := value
3989		i.cfg.RecursiveFileSuggest = &val
3990		if i.cfg.SettingsStore != nil {
3991			if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
3992				i.mu.Lock()
3993				i.statusErr = "settings: " + err.Error()
3994				i.mu.Unlock()
3995				return
3996			}
3997		}
3998		// Flip the live picker so the next @ reflects the new mode
3999		// without restarting zot. SetRecursive drops its cache.
4000		i.fileSuggest.SetRecursive(value)
4001		i.mu.Lock()
4002		i.statusOK = "recursive @-file search " + onOff(value)
4003		i.statusErr = ""
4004		i.mu.Unlock()
4005	case "respect_gitignore":
4006		val := value
4007		i.cfg.RespectGitignore = &val
4008		if i.cfg.SettingsStore != nil {
4009			if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
4010				i.mu.Lock()
4011				i.statusErr = "settings: " + err.Error()
4012				i.mu.Unlock()
4013				return
4014			}
4015		}
4016		i.fileSuggest.SetRespectGitignore(value)
4017		i.mu.Lock()
4018		i.statusOK = "hide gitignored files in @-picker " + onOff(value)
4019		i.statusErr = ""
4020		i.mu.Unlock()
4021	case "compact_mode":
4022		val := value
4023		i.cfg.CompactMode = &val
4024		if i.cfg.SettingsStore != nil {
4025			if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
4026				i.mu.Lock()
4027				i.statusErr = "settings: " + err.Error()
4028				i.mu.Unlock()
4029				return
4030			}
4031		}
4032		i.mu.Lock()
4033		i.view.CompactMode = value
4034		i.view.InvalidateRenderCache()
4035		i.statusOK = "compact transcript rendering " + onOff(value)
4036		i.statusErr = ""
4037		i.mu.Unlock()
4038	case "collapse_tool_call":
4039		val := value
4040		i.cfg.CollapseToolCall = &val
4041		if i.cfg.SettingsStore != nil {
4042			if err := i.cfg.SettingsStore.SetCollapseToolCall(value); err != nil {
4043				i.mu.Lock()
4044				i.statusErr = "settings: " + err.Error()
4045				i.mu.Unlock()
4046				return
4047			}
4048		}
4049		i.mu.Lock()
4050		i.view.CollapseToolCall = value
4051		i.view.InvalidateRenderCache()
4052		i.statusOK = "collapse tool call rendering " + onOff(value)
4053		i.statusErr = ""
4054		i.mu.Unlock()
4055	case "show_instructions_at_startup":
4056		val := value
4057		i.cfg.ShowInstructionsAtStartup = &val
4058		if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok {
4059			if err := store.SetShowInstructionsAtStartup(value); err != nil {
4060				i.mu.Lock()
4061				i.statusErr = "settings: " + err.Error()
4062				i.mu.Unlock()
4063				return
4064			}
4065		}
4066		i.mu.Lock()
4067		i.view.StartupAgentName = ""
4068		i.view.StartupContextPaths = nil
4069		i.view.StartupExtensionNames = nil
4070		i.view.StartupSkillNames = nil
4071		if value {
4072			i.view.StartupAgentName = i.cfg.StartupAgentName
4073			i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
4074			i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...)
4075			i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...)
4076		}
4077		i.statusOK = "show loaded resources at startup " + onOff(value)
4078		i.statusErr = ""
4079		i.mu.Unlock()
4080	}
4081}
4082
4083func (i *Interactive) applyTUIInputStyleSetting(style string) {
4084	defer func() {
4085		if i.rend != nil {
4086			i.rend.Clear()
4087		}
4088		i.invalidate()
4089	}()
4090	style = tui.NormalizeInputStyle(style)
4091	i.cfg.TUIInputStyle = style
4092	i.applyInputCursorColor()
4093	if i.cfg.SettingsStore != nil {
4094		if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
4095			i.mu.Lock()
4096			i.statusErr = "settings: " + err.Error()
4097			i.mu.Unlock()
4098			return
4099		}
4100	}
4101	i.mu.Lock()
4102	i.statusOK = "input style " + style
4103	i.statusErr = ""
4104	i.mu.Unlock()
4105}
4106
4107func (i *Interactive) applyTUIStatusPositionSetting(position string) {
4108	defer func() {
4109		if i.rend != nil {
4110			i.rend.Clear()
4111		}
4112		i.invalidate()
4113	}()
4114	position = tui.NormalizeStatusPosition(position)
4115	i.cfg.TUIStatusPosition = position
4116	if i.cfg.SettingsStore != nil {
4117		if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
4118			i.mu.Lock()
4119			i.statusErr = "settings: " + err.Error()
4120			i.mu.Unlock()
4121			return
4122		}
4123	}
4124	i.mu.Lock()
4125	label := "above input"
4126	if position == tui.StatusPositionBelowInput {
4127		label = "below input"
4128	}
4129	i.statusOK = "status position " + label
4130	i.statusErr = ""
4131	i.mu.Unlock()
4132}
4133
4134func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
4135	defer func() {
4136		if i.rend != nil {
4137			i.rend.Clear()
4138		}
4139		i.invalidate()
4140	}()
4141	position = tui.NormalizeWorkingPosition(position)
4142	i.cfg.TUIWorkingPosition = position
4143	if i.cfg.SettingsStore != nil {
4144		if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
4145			i.mu.Lock()
4146			i.statusErr = "settings: " + err.Error()
4147			i.mu.Unlock()
4148			return
4149		}
4150	}
4151	i.mu.Lock()
4152	label := "above input"
4153	if position == tui.WorkingPositionBelowInput {
4154		label = "below input"
4155	}
4156	i.statusOK = "working spinner position " + label
4157	i.statusErr = ""
4158	i.mu.Unlock()
4159}
4160
4161func (i *Interactive) applyThemeSetting(name string) {
4162	if i.cfg.SettingsStore != nil {
4163		if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
4164			i.mu.Lock()
4165			i.statusErr = "settings: " + err.Error()
4166			i.mu.Unlock()
4167			return
4168		}
4169	}
4170	i.cfg.ThemeName = name
4171	if name == "auto" {
4172		i.cfg.ThemeName = ""
4173	}
4174	i.applyThemeNow(name)
4175}
4176
4177func (i *Interactive) applyThemeNow(name string) {
4178	if name == "" {
4179		name = "auto"
4180	}
4181	detected := tui.Dark
4182	if tui.IsLightTheme(i.cfg.Theme) {
4183		detected = tui.Light
4184	}
4185	th, applied, err := tui.LoadThemeFromHome(i.cfg.ZotHome, name, detected)
4186	if err != nil {
4187		if i.cfg.SettingsStore != nil {
4188			_ = i.cfg.SettingsStore.SetTheme("auto")
4189		}
4190		i.cfg.ThemeName = ""
4191		th, applied, _ = tui.LoadThemeFromHome(i.cfg.ZotHome, "auto", detected)
4192		i.mu.Lock()
4193		i.statusErr = "theme missing; reset to default"
4194		i.mu.Unlock()
4195	} else {
4196		i.mu.Lock()
4197		label := applied
4198		if label == "" {
4199			label = "auto"
4200		}
4201		i.statusOK = "theme " + label
4202		i.statusErr = ""
4203		i.mu.Unlock()
4204	}
4205	i.cfg.Theme = th
4206	i.view.Theme = th
4207	i.view.InvalidateRenderCache()
4208	i.ed.Prompt = th.AccentBar(th.Accent)
4209	i.applyInputCursorColor()
4210	i.spin.Configure(th)
4211	if i.rend != nil {
4212		i.rend.SetTheme(th)
4213		i.rend.Clear()
4214	}
4215	i.invalidate()
4216}
4217
4218func (i *Interactive) applyReasoningSetting(level string) {
4219	defer func() {
4220		if i.rend != nil {
4221			i.rend.Clear()
4222		}
4223		i.invalidate()
4224	}()
4225	level = provider.NormalizeReasoning(level)
4226	i.cfg.Reasoning = level
4227	if i.cfg.SettingsStore != nil {
4228		if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
4229			i.mu.Lock()
4230			i.statusErr = "settings: " + err.Error()
4231			i.mu.Unlock()
4232			return
4233		}
4234	}
4235	i.mu.Lock()
4236	if i.agent != nil {
4237		i.agent.Reasoning = level
4238	}
4239	label := level
4240	if label == "" {
4241		label = "off"
4242	}
4243	i.statusOK = "reasoning level " + label
4244	i.statusErr = ""
4245	i.mu.Unlock()
4246	if i.cfg.OnReasoningChanged != nil {
4247		i.cfg.OnReasoningChanged(level)
4248	}
4249}
4250
4251// buildStudyPrompt returns the canned prompt the /study command
4252// submits to the agent.
4253//
4254// With no argument, /study targets the current directory — the
4255// historical behaviour. With an argument, /study targets that path
4256// instead; either a directory ("read every file in here") or a
4257// single file ("read this file"). The argument can be:
4258//
4259//   - a relative path (resolved against cwd)
4260//   - an absolute path
4261//   - an @-picker chip, which has already been expanded to an
4262//     absolute path by expandFileChips before runSlash sees it
4263//
4264// The path is stat'd to pick the right wording ("directory" vs
4265// "file"). If the path doesn't exist, we still build a sensible
4266// prompt rather than erroring — the agent will surface the
4267// missing-file failure itself when it tries to read it, which is
4268// more useful than a refusal here.
4269func buildStudyPrompt(arg, cwd string) string {
4270	arg = strings.TrimSpace(arg)
4271	if arg == "" {
4272		return "Read and understand everything in the current directory."
4273	}
4274	abs := arg
4275	if !filepath.IsAbs(abs) {
4276		abs = filepath.Join(cwd, abs)
4277	}
4278	display := arg
4279	if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
4280		display = rel
4281	}
4282	if info, err := os.Stat(abs); err == nil && !info.IsDir() {
4283		return "Read and understand the file " + display + "."
4284	}
4285	return "Read and understand everything in the directory " + display + "."
4286}
4287
4288// tryPathTabCompleteEditor looks at ed's current value, finds the
4289// path-like token immediately before the cursor (the cursor is always
4290// at the end of the buffer after a keystroke, so "before the cursor"
4291// is the trailing non-whitespace run), and rewrites it to its shell-
4292// style completion against the filesystem.
4293//
4294// Returns true when it consumed the Tab keystroke (token recognised,
4295// completion attempted — even if no candidates matched, the keystroke
4296// is still consumed so it doesn't insert a literal tab character).
4297// Returns false when the token doesn't look like a path; callers then
4298// let Tab fall through to its normal no-op.
4299//
4300// Recognised path shapes:
4301//   - ~ or ~/foo                  expanded via os.UserHomeDir()
4302//   - /abs/path or /abs/path/foo  absolute
4303//   - ./foo, ../foo, foo/bar      relative to cwd
4304//
4305// A bare word like "hello" is not treated as a path so plain text
4306// keeps Tab as a literal no-op.
4307//
4308// Free function (not a method) so the same logic runs against the
4309// editor instances owned by btwDialog and swarmDialog without each
4310// dialog needing its own copy.
4311func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
4312	if ed == nil {
4313		return false
4314	}
4315	val := ed.Value()
4316	// Find the trailing run of non-whitespace.
4317	start := len(val)
4318	for start > 0 {
4319		r := val[start-1]
4320		if r == ' ' || r == '\t' || r == '\n' {
4321			break
4322		}
4323		start--
4324	}
4325	token := val[start:]
4326	if token == "" {
4327		return false
4328	}
4329	if !looksLikePathToken(token) {
4330		return false
4331	}
4332
4333	// Resolve the absolute parent directory + base prefix to match.
4334	parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
4335	if !ok {
4336		return true
4337	}
4338	entries, err := os.ReadDir(parentAbs)
4339	if err != nil {
4340		return true
4341	}
4342	var names []string
4343	var isDir []bool
4344	for _, e := range entries {
4345		name := e.Name()
4346		if !strings.HasPrefix(name, basePrefix) {
4347			continue
4348		}
4349		// Hide dotfiles unless the user explicitly typed a leading dot,
4350		// mirroring bash's default behaviour.
4351		if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
4352			continue
4353		}
4354		names = append(names, name)
4355		isDir = append(isDir, e.IsDir())
4356	}
4357	if len(names) == 0 {
4358		return true
4359	}
4360
4361	var completed string
4362	var completedIsDir bool
4363	if len(names) == 1 {
4364		completed = names[0]
4365		completedIsDir = isDir[0]
4366	} else {
4367		completed = longestCommonPrefix(names)
4368		if completed == basePrefix {
4369			// Already at the deepest unambiguous prefix; nothing to add.
4370			return true
4371		}
4372	}
4373
4374	// Build the replacement token in the same display form the user
4375	// typed (preserve ~ vs absolute vs relative).
4376	newToken := displayParent + completed
4377	if len(names) == 1 && completedIsDir {
4378		newToken += "/"
4379	}
4380
4381	ed.SetValue(val[:start] + newToken)
4382	return true
4383}
4384
4385// tryPathTabComplete is the Interactive-bound convenience wrapper.
4386// It calls the free helper against the main editor and invalidates
4387// the frame on a successful rewrite.
4388func (i *Interactive) tryPathTabComplete() bool {
4389	if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
4390		i.invalidate()
4391		return true
4392	}
4393	return false
4394}
4395
4396// looksLikePathToken reports whether tok is shaped like a filesystem
4397// path. Paths must either start with ~, /, ./, ../ or contain a /.
4398// Plain words are excluded so Tab on "hello" stays a no-op.
4399func looksLikePathToken(tok string) bool {
4400	if tok == "" {
4401		return false
4402	}
4403	if tok[0] == '~' || tok[0] == '/' {
4404		return true
4405	}
4406	if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
4407		return true
4408	}
4409	return strings.Contains(tok, "/")
4410}
4411
4412// resolvePathTabToken splits tok into (absolute parent dir, basename
4413// prefix to match, display-form parent the user typed). ok is false
4414// when the parent dir can't be resolved (e.g. ~ with no $HOME).
4415func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
4416	// Detect ~ expansion.
4417	expanded := tok
4418	homePrefix := ""
4419	if tok == "~" {
4420		home, err := os.UserHomeDir()
4421		if err != nil || home == "" {
4422			return "", "", "", false
4423		}
4424		// "~" alone: complete in $HOME. parent = home, base = "".
4425		return home, "", "~/", true
4426	}
4427	if strings.HasPrefix(tok, "~/") {
4428		home, err := os.UserHomeDir()
4429		if err != nil || home == "" {
4430			return "", "", "", false
4431		}
4432		expanded = home + tok[1:]
4433		homePrefix = "~"
4434	}
4435
4436	dir, base := splitDirBase(expanded)
4437	if !filepath.IsAbs(dir) {
4438		dir = filepath.Join(cwd, dir)
4439	}
4440
4441	// Reconstruct the display form the user typed for the parent,
4442	// keeping ~ when they used it. The base is dropped — the caller
4443	// substitutes the completed name.
4444	display := tok[:len(tok)-len(base)]
4445	if homePrefix != "" && !strings.HasPrefix(display, "~") {
4446		display = homePrefix + display[len(homePrefix):]
4447	}
4448	return dir, base, display, true
4449}
4450
4451// splitDirBase is like filepath.Split but preserves the trailing
4452// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
4453// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
4454// the trailing separator when non-empty so callers can rebuild paths
4455// by concatenation.
4456func splitDirBase(p string) (dir, base string) {
4457	if p == "" {
4458		return ".", ""
4459	}
4460	i := strings.LastIndex(p, "/")
4461	if i < 0 {
4462		return ".", p
4463	}
4464	return p[:i+1], p[i+1:]
4465}
4466
4467func longestCommonPrefix(ss []string) string {
4468	if len(ss) == 0 {
4469		return ""
4470	}
4471	prefix := ss[0]
4472	for _, s := range ss[1:] {
4473		n := 0
4474		for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
4475			n++
4476		}
4477		prefix = prefix[:n]
4478		if prefix == "" {
4479			return ""
4480		}
4481	}
4482	return prefix
4483}
4484
4485func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4486	parts := strings.Fields(cmd)
4487	if len(parts) == 0 {
4488		return false
4489	}
4490
4491	if isSkillCommand(cmd) {
4492		var available []*skills.Skill
4493		if i.cfg.SkillSnapshot != nil {
4494			available = i.cfg.SkillSnapshot()
4495		}
4496		prompt, _, err := expandSkillCommand(cmd, available)
4497		if err != nil {
4498			i.mu.Lock()
4499			i.statusErr = err.Error()
4500			i.statusOK = ""
4501			i.mu.Unlock()
4502			return false
4503		}
4504		i.submitOrQueuePrompt(ctx, prompt)
4505		return false
4506	}
4507
4508	switch strings.ToLower(parts[0]) {
4509	case "/exit":
4510		return true
4511	case "/clear":
4512		if i.agent != nil {
4513			i.agent.SetMessages(nil)
4514		}
4515		i.mu.Lock()
4516		i.toolCalls = map[string]*tui.ToolCallView{}
4517		i.toolOrder = nil
4518		i.toolGate = map[string]int{}
4519		i.statusErr = ""
4520		i.statusOK = ""
4521		i.helpBlock = nil
4522		i.parkedTurn = 0
4523		i.parkedTotal = 0
4524		i.scrollOffset = 0
4525		i.extNotes = nil
4526		i.reloadErrors = nil
4527		i.view.InvalidateRenderCache()
4528		i.mu.Unlock()
4529	case "/help":
4530		i.mu.Lock()
4531		i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
4532		i.statusErr = ""
4533		i.statusOK = ""
4534		// Pin the viewport to the newest content so the help block,
4535		// which we just appended to the end of the transcript, is
4536		// what the user actually sees.
4537		i.scrollOffset = 0
4538		i.mu.Unlock()
4539	case "/login":
4540		i.dialog.Open(i.cfg.ZotHome)
4541	case "/logout":
4542		if len(parts) >= 2 {
4543			// Explicit target: /logout anthropic | openai | all
4544			i.doLogout(parts[1])
4545			break
4546		}
4547		// No arg: open the picker over whichever providers are
4548		// currently logged in. If nothing's logged in, bail with a
4549		// status line.
4550		i.openLogoutDialog()
4551	case "/model":
4552		if len(parts) >= 2 {
4553			i.applyModelSelection("", parts[1])
4554		} else if (i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil) || (i.cfg.LMStudioConfigured != nil && i.cfg.LMStudioConfigured() && i.cfg.RefreshLMStudioModels != nil) {
4555			i.mu.Lock()
4556			i.statusOK = "refreshing models"
4557			i.statusErr = ""
4558			i.mu.Unlock()
4559			go func() {
4560				refresh := func(fn func(context.Context) error) error {
4561					refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
4562					defer cancel()
4563					return fn(refreshCtx)
4564				}
4565				var err error
4566				if i.cfg.RefreshLMStudioModels != nil {
4567					err = refresh(i.cfg.RefreshLMStudioModels)
4568				}
4569				if i.cfg.RefreshLlamaCPPModels != nil {
4570					if llamaErr := refresh(i.cfg.RefreshLlamaCPPModels); err == nil {
4571						err = llamaErr
4572					}
4573				}
4574				select {
4575				case i.modelRefresh <- modelRefreshResult{err: err}:
4576				case <-ctx.Done():
4577				}
4578			}()
4579		} else {
4580			i.openModelPickerAfterRefresh(nil)
4581		}
4582	case "/llama":
4583		if i.cfg.LlamaCPPConfig == nil {
4584			i.mu.Lock()
4585			i.statusErr = "llama.cpp configuration is unavailable"
4586			i.statusOK = ""
4587			i.mu.Unlock()
4588			break
4589		}
4590		baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
4591		if configErr == nil {
4592			configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
4593		}
4594		if configErr != nil {
4595			i.mu.Lock()
4596			i.statusErr = configErr.Error()
4597			i.statusOK = ""
4598			i.mu.Unlock()
4599		}
4600	case "/reasoning":
4601		i.openReasoningDialog()
4602	case "/settings":
4603		i.openSettingsDialog()
4604	case "/sessions":
4605		i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)
4606	case "/jump":
4607		i.openJumpDialog(parts[1:])
4608	case "/btw":
4609		i.openBtwDialog(parts[1:])
4610	case "/skills":
4611		i.openSkillsDialog()
4612	case "/compact":
4613		i.runCompact(ctx, false)
4614	case "/study":
4615		// Canned prompt that tells the agent to read every file
4616		// in some target so its later turns have the whole thing
4617		// in context. With no argument, the target is the current
4618		// directory. With an argument, the target is whatever the
4619		// user passed — typed by hand, drag-dropped, or selected
4620		// via the @ file picker (which is why we accept both files
4621		// and directories; the @-picker chips for both have already
4622		// been expanded to absolute paths by expandFileChips above).
4623		// Dispatched through the normal queue-or-start path so it
4624		// behaves identically to typing the prompt by hand.
4625		studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
4626		i.mu.Lock()
4627		busy := i.busy
4628		compacting := i.compacting
4629		ag := i.agent
4630		i.mu.Unlock()
4631		if busy {
4632			if ag != nil && !compacting {
4633				ag.QueueMessage(studyPrompt)
4634			} else {
4635				i.mu.Lock()
4636				i.queued = append(i.queued, studyPrompt)
4637				i.mu.Unlock()
4638			}
4639			i.invalidate()
4640			break
4641		}
4642		i.startTurn(ctx, studyPrompt)
4643	case "/cd":
4644		// Hidden command: switch the running session's cwd. Not in
4645		// slash_suggest, not in /help. Used by the workspaces
4646		// extension's panel-key Enter handler so picking a row
4647		// jumps zot into that directory without relaunching.
4648		//
4649		// Recovers the raw argument (path) from the original cmd
4650		// string rather than parts, so paths with spaces survive.
4651		// The host's ChangeCWD hook handles validation, session
4652		// close + reopen, agent rebuild, sandbox re-rooting, and
4653		// re-jail-if-jailed semantics.
4654		if i.cfg.ChangeCWD == nil {
4655			i.mu.Lock()
4656			i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
4657			i.mu.Unlock()
4658			break
4659		}
4660		path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
4661		if path == "" {
4662			i.mu.Lock()
4663			i.statusErr = "/cd: missing path"
4664			i.mu.Unlock()
4665			break
4666		}
4667		if err := i.cfg.ChangeCWD(path); err != nil {
4668			i.mu.Lock()
4669			i.statusErr = "/cd: " + err.Error()
4670			i.statusOK = ""
4671			i.mu.Unlock()
4672			break
4673		}
4674		// ChangeCWD has already updated i.cfg.CWD and swapped the
4675		// agent + session. Reset transient TUI state so the new
4676		// session opens clean.
4677		i.mu.Lock()
4678		i.toolCalls = map[string]*tui.ToolCallView{}
4679		i.toolOrder = nil
4680		i.toolGate = map[string]int{}
4681		i.helpBlock = nil
4682		i.parkedTurn = 0
4683		i.statusOK = "cwd " + i.cfg.CWD
4684		i.statusErr = ""
4685		i.mu.Unlock()
4686		i.fileSuggest.Reset()
4687		i.fileSuggest.SetCWD(i.cfg.CWD)
4688		i.invalidate()
4689	case "/jail":
4690		if i.cfg.Sandbox == nil {
4691			i.mu.Lock()
4692			i.statusErr = "sandbox not available in this build"
4693			i.mu.Unlock()
4694			break
4695		}
4696		i.cfg.Sandbox.Lock()
4697		i.mu.Lock()
4698		i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
4699		i.statusErr = ""
4700		i.mu.Unlock()
4701	case "/unjail":
4702		if i.cfg.Sandbox == nil {
4703			i.mu.Lock()
4704			i.statusErr = "sandbox not available in this build"
4705			i.mu.Unlock()
4706			break
4707		}
4708		i.cfg.Sandbox.Unlock()
4709		i.mu.Lock()
4710		i.statusOK = "unjailed"
4711		i.statusErr = ""
4712		i.mu.Unlock()
4713	case "/reload-ext":
4714		i.runReloadExt(ctx)
4715	case "/telegram", "/tg":
4716		if len(parts) >= 2 {
4717			i.doTelegram(parts[1])
4718			break
4719		}
4720		i.openTelegramDialog()
4721	case "/session":
4722		if len(parts) >= 2 {
4723			action := parts[1]
4724			arg := ""
4725			if len(parts) >= 3 {
4726				arg = strings.Join(parts[2:], " ")
4727			}
4728			i.doSessionOp(action, arg)
4729			break
4730		}
4731		i.openSessionOpsDialog()
4732	case "/swarm":
4733		i.runSwarm(ctx, parts[1:])
4734	default:
4735		// Last-resort fallback: try the extension manager. Built-in
4736		// cases above always win; this branch only fires for slash
4737		// commands the extension manager registered. Same routing as
4738		// the editor's submit-handler dispatch path so the autocomplete
4739		// "enter on highlighted suggestion" flow also works.
4740		extName := strings.TrimPrefix(parts[0], "/")
4741		if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
4742			rest := ""
4743			if len(parts) > 1 {
4744				rest = strings.Join(parts[1:], " ")
4745			}
4746			go i.invokeExtensionCommand(ctx, extName, rest)
4747			return false
4748		}
4749		i.mu.Lock()
4750		i.statusErr = "unknown command: " + parts[0]
4751		i.mu.Unlock()
4752	}
4753	return false
4754}
4755
4756func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
4757	var loggedIn []string
4758	if i.cfg.LoggedInProviders != nil {
4759		loggedIn = i.cfg.LoggedInProviders()
4760	}
4761	i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
4762	i.mu.Lock()
4763	if refreshErr != nil {
4764		i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
4765		i.statusOK = ""
4766	} else if i.statusOK == "refreshing models" {
4767		i.statusOK = ""
4768	}
4769	i.mu.Unlock()
4770}
4771
4772// openLogoutDialog shows the provider picker for `/logout` with no
4773// argument. Only providers the user is currently logged into are
4774// listed, plus an "all" entry when more than one is present. If
4775// nothing's logged in, writes a status line instead of opening an
4776// empty dialog.
4777func (i *Interactive) openLogoutDialog() {
4778	if i.cfg.AuthManager == nil {
4779		i.mu.Lock()
4780		i.statusErr = "no auth manager configured"
4781		i.mu.Unlock()
4782		i.invalidate()
4783		return
4784	}
4785	store := i.cfg.AuthManager.Store()
4786	if store == nil {
4787		i.mu.Lock()
4788		i.statusErr = "auth store is not available"
4789		i.mu.Unlock()
4790		i.invalidate()
4791		return
4792	}
4793	creds, err := store.Load()
4794	if err != nil {
4795		i.mu.Lock()
4796		i.statusErr = "read auth store: " + err.Error()
4797		i.mu.Unlock()
4798		i.invalidate()
4799		return
4800	}
4801
4802	var items []logoutItem
4803	for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
4804		if creds.Has(p) {
4805			method := creds.Method(p)
4806			if method == "oauth" {
4807				method = "subscription"
4808			}
4809			items = append(items, logoutItem{
4810				label:  providerLabel(p),
4811				target: p,
4812				method: method,
4813			})
4814		}
4815	}
4816	if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
4817		items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
4818	}
4819	if creds.OpenAI.OAuth != nil {
4820		items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
4821	}
4822	for p, c := range creds.AdditionalAPIKeyCreds {
4823		if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
4824			items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
4825		}
4826	}
4827	if len(items) == 0 {
4828		i.mu.Lock()
4829		i.statusOK = "no credentials stored; already logged out"
4830		i.statusErr = ""
4831		i.mu.Unlock()
4832		i.invalidate()
4833		return
4834	}
4835	if len(items) > 1 {
4836		items = append(items, logoutItem{label: "all", target: "all"})
4837	}
4838
4839	i.logoutDialog.Open(items)
4840	i.invalidate()
4841}
4842
4843// doLogout clears credentials for the given provider (or all providers)
4844// from auth.json. If the active agent was using those credentials, it
4845// is torn down so the user is forced through /login before their next
4846// prompt.
4847//
4848// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
4849func (i *Interactive) doLogout(target string) {
4850	if i.cfg.AuthManager == nil {
4851		i.mu.Lock()
4852		i.statusErr = "no auth manager configured"
4853		i.mu.Unlock()
4854		return
4855	}
4856	store := i.cfg.AuthManager.Store()
4857	if store == nil {
4858		i.mu.Lock()
4859		i.statusErr = "auth store is not available"
4860		i.mu.Unlock()
4861		return
4862	}
4863
4864	var providers []string
4865	switch target {
4866	case "", "all":
4867		providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
4868	case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
4869		providers = []string{target}
4870	default:
4871		known := false
4872		for _, p := range auth.APIKeyProviders() {
4873			if target == p {
4874				known = true
4875				break
4876			}
4877		}
4878		if !known {
4879			i.mu.Lock()
4880			i.statusErr = "unknown provider: " + target
4881			i.mu.Unlock()
4882			return
4883		}
4884		providers = []string{target}
4885	}
4886
4887	var errs []string
4888	clearedCurrent := false
4889	for _, p := range providers {
4890		var err error
4891		switch p {
4892		case "openai":
4893			err = store.ClearAPIKey("openai")
4894		case "openai-codex":
4895			err = store.ClearOAuth("openai")
4896		default:
4897			err = store.Clear(p)
4898		}
4899		if err != nil {
4900			errs = append(errs, p+": "+err.Error())
4901			continue
4902		}
4903		if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
4904			if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
4905				errs = append(errs, p+": "+err.Error())
4906				continue
4907			}
4908		}
4909		if p == provider.LMStudioProviderID {
4910			provider.SetManagedModelsForProvider(provider.LMStudioProviderID, nil)
4911		}
4912		if p == i.cfg.Provider {
4913			clearedCurrent = true
4914		}
4915	}
4916
4917	llamaConfigured := i.llamaConfigured
4918	if i.cfg.LlamaCPPConfig != nil {
4919		baseURL, _, err := i.cfg.LlamaCPPConfig()
4920		llamaConfigured = err == nil && baseURL != ""
4921	}
4922
4923	i.mu.Lock()
4924	defer i.mu.Unlock()
4925	i.llamaConfigured = llamaConfigured
4926	if len(errs) > 0 {
4927		i.statusErr = "logout errors: " + strings.Join(errs, "; ")
4928		return
4929	}
4930	i.statusErr = ""
4931	if clearedCurrent {
4932		// The running agent was using a credential we just wiped. Drop
4933		// it so prompts can't go out with the stale client, and hint at
4934		// /login.
4935		i.agent = nil
4936		i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
4937	} else {
4938		i.statusOK = "logged out of " + strings.Join(providers, ", ")
4939	}
4940}
4941
4942func providerSetupInfo(provider string) (string, []string, bool) {
4943	const docsURL = "https://raw.githubusercontent.com/patriceckhart/zot/main/docs/providers.md"
4944	switch provider {
4945	case "amazon-bedrock":
4946		return "Amazon Bedrock setup", []string{
4947			"Amazon Bedrock uses AWS credentials instead of a generic zot API-key entry.",
4948			"Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
4949			"",
4950			"For Bedrock API keys, set:",
4951			"  AWS_BEARER_TOKEN_BEDROCK=...",
4952			"  AWS_REGION=us-east-1",
4953			"",
4954			"Docs:",
4955			"  " + docsURL,
4956		}, true
4957	case "google-vertex":
4958		return "Google Vertex AI setup", []string{
4959			"Google Vertex AI usually uses Google Cloud credentials and project settings.",
4960			"Set a Google API key, application-default credentials, or a service account.",
4961			"",
4962			"Common environment:",
4963			"  GOOGLE_CLOUD_API_KEY=...",
4964			"  GOOGLE_CLOUD_PROJECT=...",
4965			"  GOOGLE_CLOUD_LOCATION=us-central1",
4966			"",
4967			"Docs:",
4968			"  " + docsURL,
4969		}, true
4970	case "cloudflare-workers-ai":
4971		return "Cloudflare Workers AI setup", []string{
4972			"Cloudflare Workers AI needs both an API token and an account ID.",
4973			"",
4974			"Set:",
4975			"  CLOUDFLARE_API_KEY=...",
4976			"  CLOUDFLARE_ACCOUNT_ID=...",
4977			"",
4978			"Docs:",
4979			"  " + docsURL,
4980		}, true
4981	case "cloudflare-ai-gateway":
4982		return "Cloudflare AI Gateway setup", []string{
4983			"Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
4984			"",
4985			"Set:",
4986			"  CLOUDFLARE_API_KEY=...",
4987			"  CLOUDFLARE_ACCOUNT_ID=...",
4988			"  CLOUDFLARE_GATEWAY_ID=...",
4989			"",
4990			"Docs:",
4991			"  " + docsURL,
4992		}, true
4993	case "azure-openai-responses":
4994		return "Azure OpenAI Responses setup", []string{
4995			"Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
4996			"",
4997			"Set:",
4998			"  AZURE_OPENAI_API_KEY=...",
4999			"  AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
5000			"  AZURE_OPENAI_API_VERSION=2024-02-01",
5001			"",
5002			"Docs:",
5003			"  " + docsURL,
5004		}, true
5005	default:
5006		return "", nil, false
5007	}
5008}
5009
5010func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
5011	if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
5012		i.dialog.ShowResult(false, "auth store is unavailable")
5013		return
5014	}
5015	if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
5016		i.dialog.ShowResult(false, err.Error())
5017		return
5018	}
5019	i.mu.Lock()
5020	i.llamaConfigured = true
5021	i.statusErr = ""
5022	i.statusOK = "configured llama.cpp router"
5023	i.mu.Unlock()
5024	i.dialog.ShowResult(true, "")
5025}
5026
5027func (i *Interactive) startAPIKeyFlow(provider string) {
5028	if title, lines, ok := providerSetupInfo(provider); ok {
5029		i.dialog.ShowInfo(title, lines)
5030		return
5031	}
5032	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5033		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
5034	}
5035	url, err := i.cfg.AuthManager.StartAPIKey(provider)
5036	if err != nil {
5037		i.dialog.ShowResult(false, err.Error())
5038		return
5039	}
5040	i.dialog.ShowWaiting(url)
5041}
5042
5043func (i *Interactive) startOAuthFlow(provider string) {
5044	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5045		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
5046	}
5047	// Device-code providers already support headless login and must only
5048	// start one polling flow.
5049	if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
5050		loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
5051		if err != nil {
5052			i.dialog.ShowResult(false, err.Error())
5053			return
5054		}
5055		i.dialog.ShowWaiting(loginURL)
5056		return
5057	}
5058	// Always run the manual/copy-code flow in parallel with the local
5059	// callback server so headless environments (docker, SSH) can paste
5060	// the authorization code directly without first pressing 'p'.
5061	_, err := i.cfg.AuthManager.StartOAuth(provider)
5062	if err != nil {
5063		i.dialog.ShowResult(false, err.Error())
5064		return
5065	}
5066	manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
5067	if mErr == nil {
5068		i.dialog.ShowWaiting(manualURL)
5069	} else {
5070		i.dialog.ShowResult(false, mErr.Error())
5071	}
5072}
5073
5074func (i *Interactive) startManualOAuthFlow(provider string) {
5075	if i.cfg.AuthManager == nil {
5076		return
5077	}
5078	i.cfg.AuthManager.CancelOAuth()
5079	url, err := i.cfg.AuthManager.StartManualOAuth(provider)
5080	if err != nil {
5081		i.dialog.ShowResult(false, err.Error())
5082		return
5083	}
5084	i.dialog.url = url
5085	i.invalidate()
5086}
5087
5088func (i *Interactive) submitManualOAuthCode(code string) {
5089	if i.cfg.AuthManager == nil {
5090		return
5091	}
5092	go func() {
5093		if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
5094			i.dialog.ShowResult(false, err.Error())
5095			i.invalidate()
5096		}
5097	}()
5098}
5099
5100// applyModelSelection switches the active model (and provider, if the
5101// new model belongs to a different one). It rebuilds the underlying
5102// client when needed so the provider wire-protocol matches.
5103// cancelAndWaitForIdle cancels the active turn (if any) and blocks
5104// briefly until the turn goroutine has updated i.busy = false. Used
5105// before destructive slash commands so transcript-mutating work
5106// (/clear, /compact, /logout, /login completion, cross-provider
5107// /model swap) doesn't race with the still-running stream.
5108//
5109// The wait is bounded; if the turn doesn't release within the timeout
5110// we proceed anyway. Worst case is a brief overlap that the agent's
5111// own mutex protects against.
5112func (i *Interactive) cancelAndWaitForIdle() {
5113	i.mu.Lock()
5114	busy := i.busy
5115	cancel := i.cancelTurn
5116	i.mu.Unlock()
5117	if !busy {
5118		return
5119	}
5120	if cancel != nil {
5121		cancel()
5122	}
5123	// ConfirmToolCall waits on its response channel rather than the turn
5124	// context, so cancellation must also resolve every pending prompt.
5125	i.confirmDialog.CancelAll("turn cancelled")
5126	deadline := time.Now().Add(2 * time.Second)
5127	for time.Now().Before(deadline) {
5128		i.mu.Lock()
5129		done := !i.busy
5130		i.mu.Unlock()
5131		if done {
5132			return
5133		}
5134		time.Sleep(10 * time.Millisecond)
5135	}
5136}
5137
5138// openBtwDialog opens the side-chat overlay with a frozen snapshot
5139// of the current main session. The optional argument is auto-
5140// submitted as the first question, so '/btw does X work?' fires the
5141// model call immediately instead of just opening an empty dialog.
5142func (i *Interactive) openBtwDialog(args []string) {
5143	if i.agent == nil {
5144		i.mu.Lock()
5145		i.statusErr = "not logged in. type /login first."
5146		i.mu.Unlock()
5147		return
5148	}
5149	seed := strings.TrimSpace(strings.Join(args, " "))
5150	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)
5151	i.invalidate()
5152}
5153
5154// submitOrQueuePrompt submits a slash command's expanded prompt immediately,
5155// or queues it behind the active turn using the normal text-only queue.
5156func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
5157	i.mu.Lock()
5158	if i.agent == nil {
5159		i.statusErr = "not logged in. type /login first."
5160		i.statusOK = ""
5161		i.mu.Unlock()
5162		i.invalidate()
5163		return
5164	}
5165	busy := i.busy
5166	compacting := i.compacting
5167	ag := i.agent
5168	i.mu.Unlock()
5169
5170	if busy {
5171		if !compacting {
5172			ag.QueueMessage(prompt)
5173		} else {
5174			i.mu.Lock()
5175			i.queued = append(i.queued, prompt)
5176			i.mu.Unlock()
5177		}
5178		i.invalidate()
5179		return
5180	}
5181	i.startTurn(ctx, prompt)
5182}
5183
5184// openSkillsDialog opens the skill inspector. The picker reflects
5185// whatever SkillSnapshot returns at call time, so edits to a
5186// SKILL.md made during a session show up on the next /skills.
5187func (i *Interactive) openSkillsDialog() {
5188	var list []*skills.Skill
5189	if i.cfg.SkillSnapshot != nil {
5190		list = i.cfg.SkillSnapshot()
5191	}
5192	i.skillsDialog.Open(list)
5193	i.invalidate()
5194}
5195
5196// openJumpDialog builds a /jump picker from the current transcript.
5197// If the user typed "/jump foo" with a filter and it matches exactly
5198// one turn, jump there directly without showing the dialog.
5199func (i *Interactive) openJumpDialog(args []string) {
5200	if i.view == nil || len(i.view.Messages) == 0 {
5201		i.mu.Lock()
5202		i.statusErr = "nothing to jump to \u2014 the session is empty"
5203		i.mu.Unlock()
5204		return
5205	}
5206	filter := strings.TrimSpace(strings.Join(args, " "))
5207	i.jumpDialog.Open(i.view.Messages, filter)
5208	// Shortcut: with a filter argument that matches exactly one turn,
5209	// jump immediately and skip the picker.
5210	if filter != "" {
5211		if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
5212			t := tgts[0]
5213			i.jumpDialog.Close()
5214			i.applyJumpSelection(t.MessageIdx, t.TurnNo)
5215		}
5216	}
5217}
5218
5219// applyJumpSelection scrolls the chat viewport so the user message at
5220// msgIdx is visible at (or near) the top of the chat area. Uses the
5221// anchor slice returned by view.BuildWithAnchors so the mapping from
5222// message index to row is exact, regardless of variable-height tool
5223// blocks above the target.
5224func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
5225	cols := i.lastCols()
5226	chat, anchors := i.view.BuildWithAnchors(cols)
5227	var row int
5228	found := false
5229	for _, a := range anchors {
5230		if a.MessageIdx == msgIdx {
5231			row = a.Row
5232			found = true
5233			break
5234		}
5235	}
5236	if !found {
5237		i.mu.Lock()
5238		i.statusErr = "could not resolve jump target"
5239		i.mu.Unlock()
5240		return
5241	}
5242
5243	chatLen := len(chat)
5244	page := i.chatPage()
5245	if page < 1 {
5246		page = 1
5247	}
5248	// scrollOffset is measured from the bottom of the chat slice, so
5249	// to place `row` at the top of the viewport we want:
5250	//     chatLen - scrollOffset - page == row
5251	// Solve for scrollOffset and clamp to [0, chatLen-page].
5252	offset := chatLen - (row + page)
5253	if offset < 0 {
5254		offset = 0
5255	}
5256	maxOffset := chatLen - page
5257	if maxOffset < 0 {
5258		maxOffset = 0
5259	}
5260	if offset > maxOffset {
5261		offset = maxOffset
5262	}
5263
5264	i.mu.Lock()
5265	i.scrollOffset = offset
5266	i.parkedTurn = turnNo
5267	i.parkedTotal = totalTurnsLocked(i.view.Messages)
5268	i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
5269	i.statusErr = ""
5270	i.mu.Unlock()
5271}
5272
5273// totalTurnsLocked counts user messages in the transcript. Caller is
5274// assumed to hold i.mu (the name is a mild reminder; this function
5275// itself doesn't touch shared state beyond the slice it's handed).
5276func totalTurnsLocked(msgs []provider.Message) int {
5277	n := 0
5278	for _, m := range msgs {
5279		if m.Role == provider.RoleUser {
5280			n++
5281		}
5282	}
5283	return n
5284}
5285
5286// applySessionDeletion permanently removes a session selected in /sessions.
5287// The active session is protected because unlinking its open file would lose
5288// subsequent transcript writes on Unix and behaves differently on Windows.
5289func (i *Interactive) applySessionDeletion(path string) {
5290	if i.cfg.CurrentSessionPath == nil {
5291		i.mu.Lock()
5292		i.statusErr = "session deletion is not wired in this build"
5293		i.statusOK = ""
5294		i.mu.Unlock()
5295		return
5296	}
5297	current := i.cfg.CurrentSessionPath()
5298	if current != "" && filepath.Clean(current) == filepath.Clean(path) {
5299		i.mu.Lock()
5300		i.statusErr = "cannot delete the active session; resume another session first"
5301		i.statusOK = ""
5302		i.mu.Unlock()
5303		return
5304	}
5305	if err := core.DeleteSession(path); err != nil {
5306		i.mu.Lock()
5307		i.statusErr = "delete session: " + err.Error()
5308		i.statusOK = ""
5309		i.mu.Unlock()
5310		return
5311	}
5312	i.sessionDialog.Remove(path)
5313	i.mu.Lock()
5314	i.statusOK = "deleted session: " + friendlyPath(path)
5315	i.statusErr = ""
5316	i.mu.Unlock()
5317}
5318
5319// applySessionSelection loads the given session via the cli-provided
5320// callback and snaps the viewport to the bottom (the latest message)
5321// so the user lands at the live tail of the resumed conversation.
5322func (i *Interactive) applySessionSelection(path string) {
5323	if i.cfg.LoadSession == nil {
5324		i.mu.Lock()
5325		i.statusErr = "session loading is not wired in this build"
5326		i.mu.Unlock()
5327		return
5328	}
5329	i.mu.Lock()
5330	if i.sessionLoading {
5331		i.statusErr = "already resuming a session"
5332		i.mu.Unlock()
5333		i.invalidate()
5334		return
5335	}
5336	i.sessionLoading = true
5337	i.statusOK = "resuming session: " + path
5338	i.statusErr = ""
5339	i.mu.Unlock()
5340	i.invalidate()
5341
5342	go func() {
5343		err := i.cfg.LoadSession(path)
5344		i.mu.Lock()
5345		defer i.mu.Unlock()
5346		i.sessionLoading = false
5347		if err != nil {
5348			i.statusErr = err.Error()
5349			i.statusOK = ""
5350			i.invalidate()
5351			return
5352		}
5353		i.statusOK = "resumed session: " + path
5354		i.statusErr = ""
5355		i.parkedTurn = 0
5356		i.parkedTotal = 0
5357		i.scrollOffset = 0
5358		// Fresh transcript swapped in: drop the auto-follow baseline so
5359		// the next render's follow guard doesn't see the wholesale
5360		// length change as a delta and jump the viewport.
5361		i.prevChatLen = 0
5362		i.prevChatCols = 0
5363		i.extNotes = nil
5364		i.view.InvalidateRenderCache()
5365		if i.agent != nil {
5366			i.view.Messages = i.agent.Messages()
5367			i.cumUsage = i.agent.Cost()
5368			if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
5369				i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
5370			} else {
5371				i.lastCtxInput = 0
5372			}
5373			// Snap to the tail again — the swap brought in a fresh
5374			// transcript whose markdown / chroma cost we don't want
5375			// blocking the redraw.
5376			if len(i.view.Messages) > initialResumeTailLimit {
5377				i.view.TailLimit = initialResumeTailLimit
5378			} else {
5379				i.view.TailLimit = 0
5380			}
5381		}
5382		i.invalidate()
5383	}()
5384}
5385
5386// scrollToLastTurn parks the viewport at the most recent user turn,
5387// or at the top if the transcript has no user messages. Used after
5388// resume so the user lands looking at where they left off.
5389func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
5390	if len(msgs) == 0 {
5391		i.mu.Lock()
5392		i.scrollOffset = 0
5393		i.mu.Unlock()
5394		return
5395	}
5396	// Find the last user message index.
5397	lastUser := -1
5398	turnNo, totalTurns := 0, 0
5399	for idx, m := range msgs {
5400		if m.Role == provider.RoleUser {
5401			totalTurns++
5402			lastUser = idx
5403		}
5404	}
5405	if lastUser < 0 {
5406		i.mu.Lock()
5407		i.scrollOffset = 0
5408		i.mu.Unlock()
5409		return
5410	}
5411	turnNo = totalTurns
5412
5413	cols := i.lastCols()
5414	chat, anchors := i.view.BuildWithAnchors(cols)
5415	var row int
5416	found := false
5417	for _, a := range anchors {
5418		if a.MessageIdx == lastUser {
5419			row = a.Row
5420			found = true
5421			break
5422		}
5423	}
5424	if !found {
5425		i.mu.Lock()
5426		i.scrollOffset = 0
5427		i.mu.Unlock()
5428		return
5429	}
5430
5431	chatLen := len(chat)
5432	page := i.chatPage()
5433	if page < 1 {
5434		page = 1
5435	}
5436	offset := chatLen - (row + page)
5437	if offset < 0 {
5438		offset = 0
5439	}
5440	maxOffset := chatLen - page
5441	if maxOffset < 0 {
5442		maxOffset = 0
5443	}
5444	if offset > maxOffset {
5445		offset = maxOffset
5446	}
5447
5448	i.mu.Lock()
5449	i.scrollOffset = offset
5450	// Mark the parked-turn footer so the user sees "viewing turn N of
5451	// M - pgdn to catch up" — same affordance as /jump. Tells them at
5452	// a glance that they're looking at history, not the live tail.
5453	if offset > 0 {
5454		i.parkedTurn = turnNo
5455		i.parkedTotal = totalTurns
5456	}
5457	i.mu.Unlock()
5458	i.invalidate()
5459}
5460
5461func (i *Interactive) applyModelSelection(prov, model string) {
5462	i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
5463}
5464
5465// applyRescueModelSelection is like applyModelSelection but routes
5466// through BuildAgentForRescue so launch-time --api-key / --base-url
5467// overrides are dropped before the new agent is built. Falls back to
5468// the regular builder when the host doesn't wire a rescue builder.
5469func (i *Interactive) applyRescueModelSelection(prov, model string) {
5470	builder := i.cfg.BuildAgentForRescue
5471	if builder == nil {
5472		builder = i.cfg.BuildAgentFor
5473	}
5474	i.swapModel(prov, model, builder, true)
5475}
5476
5477// swapModel applies a /model selection (or a rescue selection) using
5478// the supplied builder. rescue=true tags the success message so the
5479// user can see that launch-time overrides were ignored.
5480func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
5481	if model == "" {
5482		return
5483	}
5484	m, err := provider.FindModel(prov, model)
5485	if err != nil {
5486		i.mu.Lock()
5487		i.statusErr = err.Error()
5488		i.mu.Unlock()
5489		return
5490	}
5491	// Same provider AND not a rescue retry: just swap the model on
5492	// the existing agent. Mixed-API providers dispatch from model metadata,
5493	// so the client remains reusable. Rescue retries always rebuild so a stale
5494	// auth header / base URL can't carry over.
5495	if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
5496		i.mu.Lock()
5497		i.cfg.Model = m.ID
5498		i.agent.Model = m.ID
5499		i.statusOK = "model: " + m.ID
5500		i.statusErr = ""
5501		i.mu.Unlock()
5502		if i.cfg.PersistModel != nil {
5503			i.cfg.PersistModel(i.cfg.Provider, m.ID)
5504		}
5505		return
5506	}
5507	if builder == nil {
5508		i.mu.Lock()
5509		i.statusErr = "cannot switch provider: no builder configured"
5510		i.mu.Unlock()
5511		return
5512	}
5513	// Snapshot the current transcript and cumulative usage BEFORE we
5514	// build the replacement agent so we can hand them off. Without
5515	// this the user perceives the entire session as wiped on a
5516	// cross-provider /model swap.
5517	var carryMsgs []provider.Message
5518	var carryCost provider.Usage
5519	if i.agent != nil {
5520		carryMsgs = i.agent.Messages()
5521		carryCost = i.agent.Cost()
5522	}
5523
5524	ag, p, md, err := builder(m.Provider, m.ID)
5525	if err != nil {
5526		i.mu.Lock()
5527		i.statusErr = err.Error()
5528		i.mu.Unlock()
5529		return
5530	}
5531
5532	// Replay the transcript and seed the cost on the freshly-built
5533	// agent. Messages travel cleanly between providers because they
5534	// use the same provider.Message shape; tool-call ids are local
5535	// to a turn so cross-provider continuation never confuses the
5536	// new model (it just sees the assistant's reply, no orphan
5537	// tool_use blocks because /model swaps are gated to idle state).
5538	if len(carryMsgs) > 0 {
5539		ag.SetMessages(carryMsgs)
5540	}
5541	ag.SeedCost(carryCost)
5542
5543	i.mu.Lock()
5544	i.agent = ag
5545	i.cfg.Provider = p
5546	i.cfg.Model = md
5547	if rescue {
5548		i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
5549	} else {
5550		i.statusOK = "switched to " + p + " / " + md
5551	}
5552	i.statusErr = ""
5553	// Render cache keys are width+content based, so the new agent's
5554	// identical messages will reuse the existing entries. Nothing
5555	// to invalidate.
5556	i.mu.Unlock()
5557	// The new agent was built off the base tool registry, so any
5558	// dynamically-registered tools need to be reattached. The apply
5559	// helpers are no-ops when their feature is inactive, so the
5560	// cross-provider path still works on a vanilla setup.
5561	i.applyAutoSwarmTool(i.autoSwarmEnabled())
5562	i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
5563	i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5564	if i.cfg.PersistModel != nil {
5565		i.cfg.PersistModel(p, md)
5566	}
5567}
5568
5569func (i *Interactive) handleAuthEvent(ev auth.Event) {
5570	switch ev.Kind {
5571	case "started":
5572		i.dialog.ShowWaiting(ev.URL)
5573	case "browser_open":
5574		// no-op
5575	case "error":
5576		i.dialog.ShowResult(false, ev.Message)
5577	case "success":
5578		// Rebuild the agent with the fresh credential.
5579		ag, prov, model, err := i.cfg.BuildAgent()
5580		if err != nil {
5581			i.dialog.ShowResult(false, err.Error())
5582			return
5583		}
5584		i.mu.Lock()
5585		i.agent = ag
5586		i.cfg.Provider = prov
5587		i.cfg.Model = model
5588		i.statusErr = ""
5589		i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
5590		i.mu.Unlock()
5591		i.applyAutoSwarmTool(i.autoSwarmEnabled())
5592		i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
5593		i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5594		i.dialog.ShowResult(true, "")
5595	}
5596}
5597
5598// clearPendingCompactTurnLocked drops work that only makes sense after the
5599// current compaction. The caller must hold i.mu.
5600func (i *Interactive) clearPendingCompactTurnLocked() {
5601	i.pendingCompactPrompt = ""
5602	i.pendingCompactImages = nil
5603	i.hasPendingCompactPrompt = false
5604	i.continueAfterCompact = false
5605	i.pendingPostCompactNote = ""
5606}
5607
5608// runCompact invokes core.Agent.Compact and reflects the progress in
5609// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
5610// cancel via the same cancelTurn channel used for normal turns.
5611//
5612// When auto is true the spinner message is pinned to "condensing
5613// history" and the status bar surfaces "(auto)" next to the context
5614// percentage so it's obvious the system triggered this, not the user.
5615func (i *Interactive) runCompact(parent context.Context, auto bool) {
5616	if i.agent == nil {
5617		i.mu.Lock()
5618		i.statusErr = "not logged in. type /login first."
5619		i.mu.Unlock()
5620		return
5621	}
5622	ctx, cancel := context.WithCancel(parent)
5623	i.mu.Lock()
5624	i.busy = true
5625	i.compacting = true
5626	if auto {
5627		i.spin.StartFixed("condensing history")
5628		i.autoCompacting = true
5629	} else {
5630		i.spin.StartFixed("compacting")
5631	}
5632	i.cancelTurn = cancel
5633	i.statusErr = ""
5634	i.statusOK = ""
5635	// Do NOT set streamOn: the summary text should not be visible
5636	// in the chat while compacting. The user just sees the spinner
5637	// and can keep typing / queue prompts.
5638	i.scrollOffset = 0
5639	i.helpBlock = nil
5640	i.mu.Unlock()
5641	i.invalidate()
5642
5643	go func() {
5644		// Sink discards deltas — we don't stream the summary to the UI.
5645		sink := func(delta string) {}
5646		summary, err := i.agent.Compact(ctx, 4, sink)
5647		_ = summary
5648		i.mu.Lock()
5649		i.busy = false
5650		i.compacting = false
5651		i.resetStreamingStateLocked()
5652		i.cancelTurn = nil
5653		i.autoCompacting = false
5654
5655		// Drain pending work after the transcript is clean. Overflow recovery
5656		// continues the user message already in the transcript. Pre-turn
5657		// compaction preserves its complete text-and-images request. Regular
5658		// prompts typed during compaction remain in the host queue.
5659		var next string
5660		var nextImages []provider.ImageBlock
5661		var hasNext bool
5662		var continueExisting bool
5663
5664		switch {
5665		case err != nil && ctx.Err() != nil:
5666			i.statusErr = ""
5667			if auto {
5668				i.statusOK = "auto-condense cancelled"
5669			} else {
5670				i.statusOK = "compaction cancelled"
5671			}
5672			i.queued = nil // drop queue on cancel
5673			i.clearPendingCompactTurnLocked()
5674			if i.agent != nil {
5675				i.agent.DrainQueuedMessages()
5676			}
5677		case err != nil:
5678			i.statusErr = "compaction failed: " + err.Error()
5679			i.statusOK = ""
5680			i.queued = nil // drop queue on error
5681			i.clearPendingCompactTurnLocked()
5682			if i.agent != nil {
5683				i.agent.DrainQueuedMessages()
5684			}
5685		default:
5686			i.statusErr = ""
5687			// Read token count from the compaction message meta.
5688			tokens := ""
5689			msgs := i.agent.Messages()
5690			if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
5691				tokens = msgs[0].Meta["tokens_before"]
5692			}
5693			switch {
5694			case i.pendingPostCompactNote != "":
5695				i.statusOK = i.pendingPostCompactNote
5696			case tokens != "":
5697				i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
5698			default:
5699				i.statusOK = "compacted (ctrl+o to expand)"
5700			}
5701			i.pendingPostCompactNote = ""
5702			i.extNotes = stripAutoCompactNotes(i.extNotes)
5703			i.lastCtxInput = estimateTimelineMessageTokens(msgs)
5704			i.toolCalls = map[string]*tui.ToolCallView{}
5705			i.toolOrder = nil
5706			i.toolGate = map[string]int{}
5707			i.view.InvalidateRenderCache()
5708			switch {
5709			case i.continueAfterCompact:
5710				continueExisting = true
5711				i.continueAfterCompact = false
5712			case i.hasPendingCompactPrompt:
5713				next = i.pendingCompactPrompt
5714				nextImages = i.pendingCompactImages
5715				i.pendingCompactPrompt = ""
5716				i.pendingCompactImages = nil
5717				i.hasPendingCompactPrompt = false
5718				hasNext = true
5719			case len(i.queued) > 0:
5720				next, i.queued = i.queued[0], i.queued[1:]
5721				hasNext = true
5722			}
5723			// A prompt resumed immediately after compaction must not trigger
5724			// the pre-turn threshold guard again before fresh provider usage
5725			// replaces this estimate.
5726			if hasNext {
5727				i.lastCtxInput = 0
5728			}
5729		}
5730		i.mu.Unlock()
5731		i.invalidate()
5732
5733		if hasNext || continueExisting {
5734			p := i.runCtx
5735			if p == nil {
5736				p = context.Background()
5737			}
5738			if continueExisting {
5739				i.startTurnRequest(p, "", nil, true)
5740			} else {
5741				i.startTurnWithImages(p, next, nextImages)
5742			}
5743		}
5744	}()
5745}
5746
5747// shellEscapeCommand reports whether text is a "!command" shell
5748// escape and, if so, returns the command with the leading '!' (and
5749// surrounding whitespace) stripped. A bare "!" with no command is
5750// treated as not an escape so it falls through to the normal prompt
5751// path rather than running an empty shell.
5752func shellEscapeCommand(text string) (string, bool) {
5753	return ShellEscapeCommand(text)
5754}
5755
5756// ShellEscapeCommand reports whether text is a "!command" shell
5757// escape and, if so, returns the command with the leading '!' (and
5758// surrounding whitespace) stripped. A bare "!" with no command is
5759// treated as not an escape so it falls through to the normal prompt
5760// path rather than running an empty shell.
5761func ShellEscapeCommand(text string) (string, bool) {
5762	trimmed := strings.TrimLeft(text, " \t")
5763	if !strings.HasPrefix(trimmed, "!") {
5764		return "", false
5765	}
5766	cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
5767	if cmd == "" {
5768		return "", false
5769	}
5770	return cmd, true
5771}
5772
5773// startShellEscape runs a "!command" in the same shell the bash tool
5774// uses, in the session working directory, honoring the /jail sandbox.
5775// It shares the busy/cancel state with the agent: esc cancels it, and
5776// it refuses to start while a turn or another shell escape is already
5777// in flight. Its terminal-log output is appended to the transcript as
5778// user context without automatically starting a model turn.
5779func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5780	i.mu.Lock()
5781	if i.busy || i.shellRunning {
5782		i.statusErr = "busy — wait for the current turn to finish before running a shell command"
5783		i.statusOK = ""
5784		i.mu.Unlock()
5785		i.invalidate()
5786		return
5787	}
5788	if parent == nil {
5789		parent = i.runCtx
5790	}
5791	if parent == nil {
5792		parent = context.Background()
5793	}
5794	ctx, cancel := context.WithCancel(parent)
5795	i.busy = true
5796	i.shellRunning = true
5797	i.shellLive = "$ " + cmd + "\n\n"
5798	i.cancelTurn = cancel
5799	i.statusErr = ""
5800	i.statusOK = ""
5801	i.spin.StartFixed("running shell command")
5802	// Clear stale extension notes the same way a new turn would so the
5803	// screen doesn't accumulate transient state.
5804	i.extNotes = nil
5805	i.scrollOffset = 0
5806	i.parkedTurn = 0
5807	i.parkedTotal = 0
5808	i.helpBlock = nil
5809	sandbox := i.cfg.Sandbox
5810	cwd := i.cfg.CWD
5811	i.mu.Unlock()
5812	i.invalidate()
5813
5814	go func() {
5815		defer cancel()
5816		raw, _ := json.Marshal(map[string]any{"command": cmd})
5817		bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
5818		progress := func(chunk string) {
5819			i.mu.Lock()
5820			i.shellLive += chunk
5821			i.mu.Unlock()
5822			i.invalidate()
5823		}
5824		res, err := bash.Execute(ctx, raw, progress)
5825
5826		var out string
5827		if err != nil {
5828			out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
5829		} else {
5830			for _, c := range res.Content {
5831				if tb, ok := c.(provider.TextBlock); ok {
5832					out += tb.Text
5833				}
5834			}
5835		}
5836		cancelled := ctx.Err() != nil
5837		failed := err != nil || res.IsError || cancelled
5838		if cancelled {
5839			out += "\n\n[cancelled]"
5840		}
5841
5842		if i.agent != nil {
5843			i.agent.AppendUserContext(out, map[string]string{
5844				shellEscapeMetaKey:        "true",
5845				shellEscapeCommandMetaKey: cmd,
5846			})
5847		}
5848
5849		i.mu.Lock()
5850		i.shellRunning = false
5851		i.shellLive = ""
5852		i.busy = false
5853		i.cancelTurn = nil
5854		awaitingPre := i.awaitingStartupPre
5855		if failed {
5856			if cancelled {
5857				i.statusErr = "shell command cancelled"
5858			} else {
5859				i.statusErr = "shell command failed"
5860			}
5861			i.statusOK = ""
5862		} else {
5863			i.statusOK = "shell command finished"
5864			i.statusErr = ""
5865		}
5866		i.mu.Unlock()
5867		i.invalidate()
5868		if awaitingPre {
5869			i.completeStartupPre()
5870		}
5871	}()
5872}
5873
5874func (i *Interactive) startTurn(parent context.Context, prompt string) {
5875	i.startTurnWithImages(parent, prompt, nil)
5876}
5877
5878func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
5879	i.startTurnRequest(parent, prompt, images, false)
5880}
5881
5882func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
5883	if i.agent == nil {
5884		// Text startup pre cannot run without credentials; continue so
5885		// deferred InitialInput (pre-fill or auto-submit) still applies.
5886		i.mu.Lock()
5887		awaitingPre := i.awaitingStartupPre
5888		i.mu.Unlock()
5889		if awaitingPre {
5890			i.completeStartupPre()
5891		}
5892		return
5893	}
5894	// Pre-turn safety: if the most recent context measurement is
5895	// already past the auto-compact threshold, condense before
5896	// sending so the next outbound request stays under the limit.
5897	// The condense flow re-fires the user's queued prompt for us, so
5898	// we just hand it off and exit.
5899	i.mu.Lock()
5900	needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
5901	if needsPreCompact {
5902		i.pendingCompactPrompt = prompt
5903		i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
5904		i.hasPendingCompactPrompt = true
5905		i.statusErr = ""
5906		i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
5907		i.pendingPostCompactNote = "context auto-compacted; sending your last message"
5908		i.mu.Unlock()
5909		i.invalidate()
5910		i.runCompact(parent, true)
5911		return
5912	}
5913	i.mu.Unlock()
5914
5915	ctx, cancel := context.WithCancel(parent)
5916	i.mu.Lock()
5917	i.busy = true
5918	i.spin.Start()
5919	i.cancelTurn = cancel
5920	i.statusErr = ""
5921	i.statusOK = ""
5922	i.streaming.Reset()
5923	i.streamOn = true
5924	i.toolCalls = map[string]*tui.ToolCallView{}
5925	i.toolOrder = nil
5926	i.toolGate = map[string]int{}
5927	i.extNotes = nil   // ext notes are one-shot; a new prompt clears them
5928	i.scrollOffset = 0 // jump back to the bottom on new turn
5929	// Lift the resume tail cap once the user starts interacting. The
5930	// cap is purely a first-paint optimization (don't markdown the
5931	// whole history before showing anything). Keeping it active during
5932	// a turn makes the rendered chat a sliding window: appended
5933	// messages push older ones off the TOP of the buffer, which the
5934	// renderer must treat as a change above the viewport and repaint
5935	// fully, snapping the terminal's native scrollback to the bottom on
5936	// every streamed chunk. A fresh session has no cap (append-only),
5937	// which is why the jump only shows up in resumed sessions. Dropping
5938	// the cap here makes resumed turns append-only too.
5939	i.view.TailLimit = 0
5940	// Reset the auto-follow baseline so the very next render at
5941	// interactive.go:1053 doesn't see a synthetic shrink between
5942	// "last frame had the previous turn's tool overlay" and
5943	// "this frame had it cleared above". Without this, the guard
5944	// reads delta = -(rows in cleared overlay) and decrements
5945	// scrollOffset, which on terminals that mirror zot's pane
5946	// scroll into the host scrollbar visibly yanks the viewport.
5947	// See autofollow_shrink_test.go for the exact arithmetic.
5948	i.prevChatLen = 0
5949	i.prevChatCols = 0
5950	i.parkedTurn = 0 // starting a turn clears the /jump parked state
5951	i.parkedTotal = 0
5952	i.helpBlock = nil // hide the help block once the user asks something
5953	i.mu.Unlock()
5954	i.invalidate()
5955
5956	sink := func(ev core.AgentEvent) {
5957		i.handleEvent(ev)
5958		i.invalidate()
5959	}
5960
5961	go func() {
5962		var err error
5963		if overflowRecoveryAttempted {
5964			err = i.agent.Continue(ctx, sink)
5965		} else {
5966			err = i.agent.Prompt(ctx, prompt, images, sink)
5967		}
5968		i.mu.Lock()
5969		i.busy = false
5970		// Don't touch streamPending / streamFlushPending here — the
5971		// pacer may still be draining the final deltas and needs to
5972		// paint them even though Prompt has returned. It will reset
5973		// streamOn on its own once the buffer empties.
5974		if len(i.streamPending) == 0 {
5975			i.streamOn = false
5976		}
5977		i.cancelTurn = nil
5978		if err != nil && ctx.Err() == nil {
5979			i.statusErr = err.Error()
5980		}
5981		// Decide whether to offer a model rescue picker for recoverable
5982		// provider failures (auth/rate/temporary). The picker opens after
5983		// the mutex is released so it can take its own locks freely.
5984		var (
5985			offer       bool
5986			rescueWhy   string
5987			rescueImgs  []provider.ImageBlock
5988			rescueModel string
5989			rescueProv  string
5990			rescueFprov string
5991		)
5992		if err != nil && ctx.Err() == nil {
5993			if ok, reason := classifyRescueError(err); ok {
5994				offer = true
5995				rescueWhy = reason
5996				rescueImgs = images
5997				rescueModel = i.cfg.Model
5998				rescueProv = i.cfg.Provider
5999				rescueFprov = extractFailedProvider(err)
6000				if rescueFprov == "" {
6001					rescueFprov = i.cfg.Provider
6002				}
6003				// Suppress the red banner — the rescue dialog already
6004				// surfaces the failure.
6005				i.statusErr = ""
6006			}
6007		}
6008		// Detect responses that reject the current context, either as an
6009		// HTTP 413 payload limit or a model context-window error. Token-
6010		// based auto-compact can miss both when metadata is stale or the
6011		// limit is measured in raw bytes. Compact once, then continue the
6012		// user message already present in the transcript.
6013		contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)
6014		recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
6015		if recoverContextOverflow {
6016			i.statusErr = ""
6017			i.continueAfterCompact = true
6018			i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
6019			i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
6020		}
6021		// Persist the assistant's reply (and every tool row before
6022		// it) to the session file while the turn memory is hot.
6023		// Without this, WriteNewTranscript only fires at zot exit,
6024		// meaning a crash or ungraceful kill drops the whole
6025		// conversation. FlushSession is idempotent (it advances the
6026		// baseline so subsequent flushes only write new rows).
6027		flush := i.cfg.FlushSession
6028		i.mu.Unlock()
6029		if flush != nil {
6030			flush()
6031		}
6032		i.mu.Lock()
6033		awaitingPre := i.awaitingStartupPre
6034		// Pop the next queued message, if any, and relaunch.
6035		var next string
6036		var hasNext bool
6037		if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
6038			next, i.queued = i.queued[0], i.queued[1:]
6039			hasNext = true
6040		}
6041		// If the turn was cancelled or errored, drop the queue so the
6042		// user isn't bombarded with stale messages after an interrupt.
6043		if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
6044			i.queued = nil
6045			if i.agent != nil {
6046				i.agent.DrainQueuedMessages()
6047			}
6048		}
6049		// Decide whether the next thing to do is an auto-compaction.
6050		// Only fires when the turn completed cleanly AND no host-side
6051		// or agent-side queued messages are waiting (otherwise a queued
6052		// message would race the condense).
6053		agentQueued := 0
6054		if i.agent != nil {
6055			agentQueued = i.agent.QueuedMessageCount()
6056		}
6057		shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
6058		i.mu.Unlock()
6059		i.invalidate()
6060		parent := i.runCtx
6061		if parent == nil {
6062			parent = context.Background()
6063		}
6064		if awaitingPre {
6065			i.completeStartupPre()
6066			return
6067		}
6068		switch {
6069		case hasNext:
6070			i.startTurn(parent, next)
6071		case offer:
6072			i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
6073		case recoverContextOverflow:
6074			i.runCompact(parent, true)
6075		case shouldAutoCompact:
6076			i.runCompact(parent, true)
6077		}
6078	}()
6079}
6080
6081// openRescueDialog surfaces the rescue model picker after a recoverable
6082// provider failure. The pending prompt + images are stashed on the
6083// Interactive so a later applyRescueSelection can re-run the same turn
6084// against the freshly-picked model. activeProvider/failedProvider are
6085// usually the same, but some clients embed different prefixes in their
6086// errors than the configured provider id, so we accept both.
6087func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
6088	if i.rescueDialog == nil {
6089		return
6090	}
6091	loggedIn := []string{}
6092	if i.cfg.LoggedInProviders != nil {
6093		loggedIn = i.cfg.LoggedInProviders()
6094	}
6095	fprov := failedProvider
6096	if fprov == "" {
6097		fprov = activeProvider
6098	}
6099	i.mu.Lock()
6100	i.pendingRescuePrompt = prompt
6101	i.pendingRescueImages = images
6102	i.mu.Unlock()
6103	i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
6104	i.invalidate()
6105}
6106
6107// applyRescueSelection switches model (cross-provider if needed) and
6108// re-runs the same prompt+images that just failed. Mirrors
6109// applyModelSelection's transcript-carry logic so the user keeps full
6110// session continuity across the swap.
6111func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
6112	if model == "" {
6113		return
6114	}
6115	i.applyRescueModelSelection(prov, model)
6116	i.mu.Lock()
6117	images := i.pendingRescueImages
6118	if prompt == "" {
6119		prompt = i.pendingRescuePrompt
6120	}
6121	i.pendingRescuePrompt = ""
6122	i.pendingRescueImages = nil
6123	i.mu.Unlock()
6124	parent := i.runCtx
6125	if parent == nil {
6126		parent = context.Background()
6127	}
6128	i.startTurnWithImages(parent, prompt, images)
6129}
6130
6131func stripAutoCompactNotes(notes []string) []string {
6132	if len(notes) == 0 {
6133		return notes
6134	}
6135	out := notes[:0]
6136	for _, n := range notes {
6137		if strings.Contains(n, "condensing history") {
6138			continue
6139		}
6140		out = append(out, n)
6141	}
6142	return out
6143}
6144
6145// autoCompactNoteLine returns a styled chat-area note for the
6146// inline auto-compact heads-up. Lives in extNotes so it survives
6147// the busy-spinner overwrite of the status row.
6148func autoCompactNoteLine(th tui.Theme, msg string) string {
6149	return "  " + th.FG256(th.Warning, "⚠ "+msg)
6150}
6151
6152// isContextOverflowError matches provider responses that reject the
6153// current request because its raw payload or tokenized input is too
6154// large. Providers surface this as either HTTP 413 or semantic context
6155// errors with differing codes and messages.
6156func isContextOverflowError(err error) bool {
6157	if err == nil {
6158		return false
6159	}
6160	msg := strings.ToLower(err.Error())
6161	return strings.Contains(msg, "http 413") ||
6162		strings.Contains(msg, " 413") ||
6163		strings.HasPrefix(msg, "413 ") ||
6164		strings.Contains(msg, "payload too large") ||
6165		strings.Contains(msg, "request entity too large") ||
6166		strings.Contains(msg, "input exceeds the context window") ||
6167		strings.Contains(msg, "context window exceeded") ||
6168		strings.Contains(msg, "maximum context length") ||
6169		strings.Contains(msg, "context_length_exceeded") ||
6170		(strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
6171}
6172
6173const defaultAutoCompactThreshold = 85
6174
6175func normalizeAutoCompactThreshold(threshold *int) int {
6176	if threshold == nil {
6177		return defaultAutoCompactThreshold
6178	}
6179	switch *threshold {
6180	case 0, 70, 80, 85, 90:
6181		return *threshold
6182	default:
6183		return defaultAutoCompactThreshold
6184	}
6185}
6186
6187func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
6188	if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
6189		return false
6190	}
6191	return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
6192}
6193
6194// shouldAutoCompactLocked reports whether the last turn pushed context
6195// usage past the auto-compact threshold. Must be called with i.mu
6196// held; it reads lastCtxInput and the current model's context window.
6197func (i *Interactive) shouldAutoCompactLocked() bool {
6198	if i.agent == nil {
6199		return false
6200	}
6201	if i.autoCompacting {
6202		return false
6203	}
6204	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
6205	if err != nil || m.ContextWindow <= 0 {
6206		return false
6207	}
6208	threshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
6209	return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
6210}
6211
6212func (i *Interactive) handleEvent(ev core.AgentEvent) {
6213	i.mu.Lock()
6214	defer i.mu.Unlock()
6215	switch e := ev.(type) {
6216	case core.EvAssistantStart:
6217		// Fires at the top of every oneTurn, including follow-up
6218		// turns after tool use. Without this, the streaming buffer
6219		// is still marked off from the previous assistant message
6220		// and the final summary text pops in all at once instead
6221		// of typewriter-streaming delta by delta.
6222		i.streaming.Reset()
6223		i.streamPending = i.streamPending[:0]
6224		i.streamFlushPending = false
6225		i.streamOn = true
6226		// Clear the live tool-call overlay. Any tools from the
6227		// previous round are now fully folded into the transcript
6228		// (assistant tool_use block + tool role message with the
6229		// result), so keeping them in the overlay would duplicate
6230		// them in the view — once inside the finalised transcript
6231		// and once below the streaming block, with the streaming
6232		// summary sandwiched in between. The next EvToolUseStart
6233		// will populate fresh entries for this turn's tools.
6234		i.toolCalls = map[string]*tui.ToolCallView{}
6235		i.toolOrder = nil
6236		i.toolGate = map[string]int{}
6237	case core.EvTextDelta:
6238		// Buffer into streamPending; the paintPace ticker drains
6239		// it into i.streaming a few runes at a time for a smooth
6240		// typewriter effect independent of upstream chunk size.
6241		i.streamPending = append(i.streamPending, []rune(e.Delta)...)
6242		i.streamOn = true
6243	case core.EvAssistantMessage:
6244		// OnAssistant + telegram mirroring always fire on message
6245		// arrival — they read the FINAL message content, which is
6246		// complete regardless of what's still in the pacer.
6247		i.assistantMessageSideEffects(e.Message)
6248		// If the pacer still has characters to drain, keep streamOn
6249		// true and mark flush pending; the paintPace ticker will
6250		// drain the remainder and reset streaming state when done.
6251		// Otherwise (rare: full-replay sessions, abort paths) clear
6252		// synchronously so a later render doesn't show stale text.
6253		if len(i.streamPending) > 0 {
6254			i.streamFlushPending = true
6255			return
6256		}
6257		i.resetStreamingStateLocked()
6258	case core.EvToolUseStart:
6259		// Live streaming: pre-create the view so the user sees the
6260		// tool call being composed in real time. Any subsequent
6261		// EvToolCall for the same ID updates the same struct (the
6262		// final parsed args + name are already known here).
6263		if _, exists := i.toolCalls[e.ID]; !exists {
6264			i.toolCalls[e.ID] = &tui.ToolCallView{
6265				ID:        e.ID,
6266				Name:      e.Name,
6267				Streaming: true,
6268			}
6269			i.toolOrder = append(i.toolOrder, e.ID)
6270			i.gateToolLocked(e.ID)
6271		}
6272	case core.EvToolUseArgs:
6273		if tc, ok := i.toolCalls[e.ID]; ok {
6274			tc.RawJSONBuf += e.Delta
6275			// Refresh the live path as soon as it parses; used in
6276			// the header (write /Users/pat/Desktop/demo.ts)
6277			// while the content is still streaming.
6278			if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
6279				tc.LivePath = p
6280			} else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
6281				tc.LivePath = p
6282			}
6283		}
6284	case core.EvToolUseEnd:
6285		if tc, ok := i.toolCalls[e.ID]; ok {
6286			tc.Streaming = false
6287		}
6288	case core.EvToolCall:
6289		// If we already pre-created the view during streaming, just
6290		// refresh the final Args summary. Otherwise create a new one
6291		// (non-streaming providers or legacy paths).
6292		if tc, ok := i.toolCalls[e.ID]; ok {
6293			tc.Args = tui.ShortArgs(e.Name, e.Args)
6294			if tc.RawJSONBuf == "" {
6295				tc.RawJSONBuf = string(e.Args)
6296			}
6297			tc.Streaming = false
6298		} else {
6299			i.toolCalls[e.ID] = &tui.ToolCallView{
6300				ID:         e.ID,
6301				Name:       e.Name,
6302				Args:       tui.ShortArgs(e.Name, e.Args),
6303				RawJSONBuf: string(e.Args),
6304			}
6305			i.toolOrder = append(i.toolOrder, e.ID)
6306			i.gateToolLocked(e.ID)
6307		}
6308	case core.EvToolResult:
6309		if tc, ok := i.toolCalls[e.ID]; ok {
6310			tc.Done = true
6311			tc.Error = e.Result.IsError
6312			tc.Preview = ""
6313			var text strings.Builder
6314			for _, c := range e.Result.Content {
6315				if tb, ok := c.(provider.TextBlock); ok {
6316					if text.Len() > 0 {
6317						text.WriteString("\n")
6318					}
6319					text.WriteString(tb.Text)
6320				}
6321			}
6322			tc.Result = text.String()
6323		}
6324		if i.cfg.OnToolResult != nil {
6325			i.cfg.OnToolResult(e.ID, e.Result)
6326		}
6327	case core.EvUsage:
6328		i.cumUsage = e.Cumulative
6329		if e.Usage.InputTokens > 0 {
6330			i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
6331		}
6332	case core.EvTurnEnd:
6333		if e.Stop == provider.StopAborted {
6334			i.resetStreamingStateLocked()
6335			i.statusErr = ""
6336			i.statusOK = "cancelled"
6337			return
6338		}
6339		if e.Stop == provider.StopLength {
6340			// The model hit its output-token cap mid-response, so the
6341			// reply (often a long write/edit) is truncated. Surface it
6342			// explicitly, otherwise the turn just ends and reads like
6343			// the UI gave up. The agent already requests the model's
6344			// full MaxOutput budget, so this means the response genuinely
6345			// exceeded that ceiling; ask the user to continue.
6346			i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
6347			i.statusOK = ""
6348			return
6349		}
6350		// Don't surface mid-loop stream errors as a red banner here.
6351		// EvTurnEnd fires after every step in a multi-step tool loop,
6352		// so a transient 503 / network blip would briefly paint a red
6353		// banner over the still-streaming chat before the agent loop
6354		// either retries or exits. The final error (if any) is set by
6355		// startTurnWithImages once Prompt() returns, and recoverable
6356		// failures are routed to the rescue picker instead — which
6357		// keeps the chat clean while the agent is working.
6358		_ = e.Err
6359	}
6360}
6361
6362// Agent returns the current agent, if any. Used by cli.go to flush the
6363// final transcript to the session file.
6364func (i *Interactive) Agent() *core.Agent {
6365	i.mu.Lock()
6366	defer i.mu.Unlock()
6367	return i.agent
6368}
6369
6370// silence unused import in some build configs
6371var _ = fmt.Sprintf
6372
6373// runReloadExt triggers a live reload of every extension (discovered
6374// + explicit). Runs on a goroutine so the TUI stays responsive; the
6375// Manager.Reload takes a couple of hundred ms to shut down subprocs
6376// and respawn them. Shows a temporary status line throughout.
6377func (i *Interactive) runReloadExt(ctx context.Context) {
6378	if i.cfg.Extensions == nil {
6379		i.mu.Lock()
6380		i.statusErr = "no extension manager in this build"
6381		i.mu.Unlock()
6382		i.invalidate()
6383		return
6384	}
6385	i.mu.Lock()
6386	i.statusOK = "reloading extensions..."
6387	i.statusErr = ""
6388	i.mu.Unlock()
6389	i.invalidate()
6390
6391	go func() {
6392		stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
6393		msg, failed := formatReloadStatus(stats)
6394		seq := i.setReloadStatus(msg, failed)
6395		i.invalidate()
6396		go i.dismissReloadStatus(ctx, seq, msg, failed)
6397	}()
6398}
6399
6400// Confirm implements core.Confirmer. The agent goroutine calls
6401// this synchronously before every tool invocation when --no-yolo is
6402// active. We push the request onto the confirmDialog queue, trigger
6403// a redraw, and block the caller until the user answers.
6404//
6405// If the session is cancelled or the TUI exits mid-prompt, any
6406// pending request is refused via CancelAll so the agent doesn't
6407// deadlock.
6408func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
6409	return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
6410}
6411
6412// ConfirmToolCall attaches a side-effect-free preview to the matching live
6413// tool panel, then blocks until the user approves or refuses the call.
6414func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
6415	resp := make(chan core.ConfirmDecision, 1)
6416	returnToChild := false
6417	if call.ID != "" {
6418		i.mu.Lock()
6419		if tc, ok := i.toolCalls[call.ID]; ok {
6420			tc.Args = call.Summary
6421			tc.Preview = call.Content
6422		}
6423		i.mu.Unlock()
6424		if i.btwDialog != nil && i.btwDialog.Active() {
6425			returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content)
6426		}
6427	}
6428	i.confirmDialog.Enqueue(&confirmRequest{
6429		toolName:      call.Name,
6430		preview:       call.Summary,
6431		resp:          resp,
6432		returnToChild: returnToChild,
6433	})
6434	i.invalidate()
6435	return <-resp
6436}
6437
6438// openTelegramDialog shows the picker for `/telegram` with no arg.
6439// Items depend on current state: disconnect + status when running,
6440// connect + status when stopped.
6441func (i *Interactive) openTelegramDialog() {
6442	items := i.telegramMenuItems()
6443	if len(items) == 0 {
6444		i.mu.Lock()
6445		i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
6446		i.mu.Unlock()
6447		i.invalidate()
6448		return
6449	}
6450	i.telegramDialog.Open(items)
6451	i.invalidate()
6452}
6453
6454// telegramMenuItems builds the dialog entries for the current
6455// bridge state. Returns empty when no bot.json exists so the
6456// caller can show a helpful status line instead of an empty menu.
6457func (i *Interactive) telegramMenuItems() []telegramItem {
6458	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
6459	if err != nil || cfg.BotToken == "" {
6460		return nil
6461	}
6462	var items []telegramItem
6463	if i.telegramBridge != nil && i.telegramBridge.Active() {
6464		items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
6465		st := i.telegramBridge.State()
6466		hint := "active"
6467		if st.Username != "" {
6468			hint += " as @" + st.Username
6469		}
6470		items = append(items, telegramItem{label: "status", action: "status", hint: hint})
6471	} else {
6472		label := "connect"
6473		hint := "start mirroring dms into this session"
6474		if cfg.AllowedUserID == 0 {
6475			hint = "awaiting pairing (send /start to the bot once connected)"
6476		}
6477		items = append(items, telegramItem{label: label, action: "connect", hint: hint})
6478		items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
6479	}
6480	return items
6481}
6482
6483// doTelegram dispatches one of the three explicit actions. Called
6484// from /telegram <action> or after the picker selects a row.
6485func (i *Interactive) doTelegram(action string) {
6486	switch action {
6487	case "connect":
6488		i.telegramConnect()
6489	case "disconnect":
6490		i.telegramDisconnect()
6491	case "status":
6492		i.telegramStatus()
6493	default:
6494		i.mu.Lock()
6495		i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
6496		i.mu.Unlock()
6497		i.invalidate()
6498	}
6499}
6500
6501// telegramConnect starts the bridge. Refuses if it's already
6502// running or if the on-disk bot.json is missing a token.
6503func (i *Interactive) telegramConnect() {
6504	if i.telegramBridge != nil && i.telegramBridge.Active() {
6505		i.mu.Lock()
6506		i.statusOK = "telegram already connected"
6507		i.statusErr = ""
6508		i.mu.Unlock()
6509		i.invalidate()
6510		return
6511	}
6512	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
6513	if err != nil {
6514		i.mu.Lock()
6515		i.statusErr = "telegram: " + err.Error()
6516		i.mu.Unlock()
6517		i.invalidate()
6518		return
6519	}
6520	if cfg.BotToken == "" {
6521		i.mu.Lock()
6522		i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
6523		i.mu.Unlock()
6524		i.invalidate()
6525		return
6526	}
6527	// Refuse to start when a background daemon is already polling
6528	// the same bot. Two concurrent long-poll consumers race each
6529	// update and one always loses, so DMs get half-delivered. The
6530	// user can `zot telegram-bot stop` first, then /telegram connect.
6531	if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
6532		i.mu.Lock()
6533		i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
6534		i.mu.Unlock()
6535		i.invalidate()
6536		return
6537	}
6538	i.telegramBridge = &telegram.Bridge{
6539		Client: telegram.NewClient(cfg.BotToken),
6540		Config: cfg,
6541		Save: func(next telegram.Config) error {
6542			return telegram.SaveConfig(i.cfg.ZotHome, next)
6543		},
6544		Host: &telegramHost{iv: i},
6545	}
6546	if err := i.telegramBridge.Start(i.runCtx); err != nil {
6547		i.telegramBridge = nil
6548		i.mu.Lock()
6549		i.statusErr = "telegram connect failed: " + err.Error()
6550		i.mu.Unlock()
6551		i.invalidate()
6552		return
6553	}
6554	i.applyTelegramTools(true)
6555	state := i.telegramBridge.State()
6556	label := "telegram connected"
6557	if state.Username != "" {
6558		label += " as @" + state.Username
6559	}
6560	if state.PairedID == 0 {
6561		label += " — send /start to the bot from your phone to claim it"
6562	}
6563	i.mu.Lock()
6564	i.statusOK = label
6565	i.statusErr = ""
6566	i.mu.Unlock()
6567	i.invalidate()
6568}
6569
6570// telegramDisconnect stops the bridge. No-op when already stopped.
6571func (i *Interactive) telegramDisconnect() {
6572	if i.telegramBridge == nil || !i.telegramBridge.Active() {
6573		i.mu.Lock()
6574		i.statusOK = "telegram already disconnected"
6575		i.statusErr = ""
6576		i.mu.Unlock()
6577		i.invalidate()
6578		return
6579	}
6580	i.telegramBridge.Stop()
6581	i.applyTelegramTools(false)
6582	i.mu.Lock()
6583	i.statusOK = "telegram disconnected"
6584	i.statusErr = ""
6585	i.mu.Unlock()
6586	i.invalidate()
6587}
6588
6589// telegramSenderAdapter wraps the bridge so the tools package can
6590// drive it without importing telegram directly. The Active() check
6591// is forwarded to the bridge so the tool can fail clearly with a
6592// model-readable error when the user disconnected mid-turn.
6593type telegramSenderAdapter struct {
6594	bridge *telegram.Bridge
6595}
6596
6597func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
6598	if a.bridge == nil {
6599		return fmt.Errorf("telegram bridge is not connected")
6600	}
6601	return a.bridge.SendImage(ctx, path, caption)
6602}
6603
6604func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
6605	if a.bridge == nil {
6606		return fmt.Errorf("telegram bridge is not connected")
6607	}
6608	return a.bridge.SendDocument(ctx, path, caption)
6609}
6610
6611func (a telegramSenderAdapter) Active() bool {
6612	return a.bridge != nil && a.bridge.Active()
6613}
6614
6615// swarmWatchEntry is one tracked auto-swarm sub-agent. It records the
6616// delegated turn's outcome separately from the long-lived daemon status.
6617type swarmWatchEntry struct {
6618	agent   *swarm.Agent
6619	task    string
6620	done    bool
6621	outcome string
6622	err     string
6623}
6624
6625// TrackSwarmAgent is the exported entry point used by the cli to
6626// hand a freshly-spawned auto-swarm agent off to the watcher.
6627func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) {
6628	i.trackSwarmAgent(a, task)
6629}
6630
6631// trackSwarmAgent records a freshly-spawned auto-swarm agent and
6632// subscribes to both ways its delegated task can finish. Successful
6633// long-lived agents report a prompt-level turn_end and keep listening;
6634// startup failures and unexpected daemon exits only unblock Agent.Wait.
6635// Whichever signal arrives first completes the entry exactly once.
6636//
6637// Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto-
6638// swarm is enabled, so this is a no-op when the feature is off.
6639func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) {
6640	if i == nil || a == nil {
6641		return
6642	}
6643	entry := &swarmWatchEntry{agent: a, task: task}
6644	i.swarmWatchMu.Lock()
6645	i.swarmWatch = append(i.swarmWatch, entry)
6646	i.swarmWatchMu.Unlock()
6647
6648	a.SetOnTurnEnd(func(step int, errMsg string) {
6649		outcome := "completed"
6650		if errMsg != "" {
6651			outcome = "failed"
6652		}
6653		i.completeSwarmWatchEntry(entry, outcome, errMsg)
6654	})
6655	go func() {
6656		a.Wait()
6657		snap := a.Snapshot()
6658		outcome := string(snap.Status)
6659		if snap.Status == swarm.StatusDone {
6660			outcome = "completed"
6661		}
6662		i.completeSwarmWatchEntry(entry, outcome, snap.Err)
6663	}()
6664}
6665
6666func (i *Interactive) completeSwarmWatchEntry(entry *swarmWatchEntry, outcome, errMsg string) {
6667	i.swarmWatchMu.Lock()
6668	if entry.done {
6669		i.swarmWatchMu.Unlock()
6670		return
6671	}
6672	entry.done = true
6673	entry.outcome = outcome
6674	entry.err = errMsg
6675	allDone := true
6676	for _, e := range i.swarmWatch {
6677		if !e.done {
6678			allDone = false
6679			break
6680		}
6681	}
6682	var batch []*swarmWatchEntry
6683	if allDone {
6684		batch = i.swarmWatch
6685		i.swarmWatch = nil
6686	}
6687	i.swarmWatchMu.Unlock()
6688	if len(batch) != 0 {
6689		i.flushSwarmSummary(batch)
6690	}
6691}
6692
6693// flushSwarmSummary composes a synthetic user turn describing every
6694// sub-agent's outcome and injects it via SubmitOrQueue so the main
6695// agent picks it up at the next safe boundary. The summary is
6696// phrased as a system update ("Auto-swarm finished: ...") so the
6697// model treats it as observed state, not as a fresh user request.
6698func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) {
6699	if len(batch) == 0 {
6700		return
6701	}
6702	var sb strings.Builder
6703	fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch))
6704	for idx, e := range batch {
6705		snap := e.agent.Snapshot()
6706		status := e.outcome
6707		if status == "" {
6708			status = string(snap.Status)
6709		}
6710		task := snap.Task
6711		if task == "" {
6712			task = e.task
6713		}
6714		fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)
6715		fmt.Fprintf(&sb, "   task: %s\n", truncateForSummary(task, 240))
6716		if snap.Err != "" {
6717			fmt.Fprintf(&sb, "   error: %s\n", truncateForSummary(snap.Err, 240))
6718		} else if e.err != "" {
6719			fmt.Fprintf(&sb, "   turn error: %s\n", truncateForSummary(e.err, 240))
6720		}
6721		if snap.LastAssistant != "" {
6722			sb.WriteString("   final response:\n")
6723			sb.WriteString(snap.LastAssistant)
6724			sb.WriteString("\n")
6725		} else if tail := strings.TrimSpace(snap.Tail); tail != "" {
6726			fmt.Fprintf(&sb, "   tail: %s\n", truncateForSummary(tail, 600))
6727		}
6728		sb.WriteString("\n")
6729	}
6730	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.")
6731	i.SubmitOrQueue(sb.String(), nil)
6732}
6733
6734func truncateForSummary(s string, n int) string {
6735	s = strings.TrimSpace(s)
6736	if len(s) <= n {
6737		return s
6738	}
6739	return s[:n-3] + "..."
6740}
6741
6742// applyAutoSwarmSystemPrompt appends (active=true) or strips
6743// (active=false) the auto-swarm system-prompt block on the running
6744// agent so the model proactively considers swarm_spawn when the user
6745// flips the toggle. Edit the base prompt so extension replacements are not
6746// applied twice when the next turn prepares the rebuilt prompt.
6747func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
6748	if i.agent == nil {
6749		return
6750	}
6751	addendum := i.cfg.AutoSwarmSystemAddendum
6752	if addendum == "" {
6753		return
6754	}
6755	sys := i.agent.BaseSystem()
6756	has := strings.Contains(sys, addendum)
6757	switch {
6758	case active && !has:
6759		if sys != "" && !strings.HasSuffix(sys, "\n\n") {
6760			sys += "\n\n"
6761		}
6762		i.agent.SetSystem(sys + addendum)
6763	case !active && has:
6764		i.agent.SetSystem(strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n")
6765	}
6766}
6767
6768// applyAutoSwarmTool registers (active=true) or removes (active=false)
6769// the swarm_spawn tool on the running agent so the model only sees it
6770// when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'
6771// snapshot+mutate pattern so extension tools and /reload-ext additions
6772// survive a toggle.
6773func (i *Interactive) autoSwarmEnabled() bool {
6774	return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled
6775}
6776
6777func (i *Interactive) openRouterServerToolsEnabled() bool {
6778	return i.cfg.OpenRouterServerToolsEnabled != nil && *i.cfg.OpenRouterServerToolsEnabled
6779}
6780
6781func (i *Interactive) applyOpenRouterServerTools(active bool) {
6782	if i.agent == nil {
6783		return
6784	}
6785	current := i.agent.Tools
6786	next := core.Registry{}
6787	for name, t := range current {
6788		if tools.IsOpenRouterServerTool(name) {
6789			continue
6790		}
6791		next[name] = t
6792	}
6793	i.agent.MaxToolCalls = 0
6794	if active && !i.cfg.NoTools && i.cfg.Provider == "openrouter" && !strings.Contains(strings.ToLower(i.cfg.Model), "@preset/") {
6795		for _, t := range tools.OpenRouterServerTools() {
6796			next[t.Name()] = t
6797		}
6798		i.agent.MaxToolCalls = tools.ResolveToolCallLimit(i.cfg.OpenRouterServerToolCallLimit)
6799	}
6800	i.agent.SetTools(next)
6801}
6802
6803func (i *Interactive) applyAutoSwarmTool(active bool) {
6804	if i.agent == nil {
6805		return
6806	}
6807	current := i.agent.Tools
6808	next := core.Registry{}
6809	for name, t := range current {
6810		if name == "swarm_spawn" {
6811			continue
6812		}
6813		next[name] = t
6814	}
6815	if active && i.cfg.Swarm != nil {
6816		next["swarm_spawn"] = &tools.SwarmSpawnTool{
6817			Swarm:           i.cfg.Swarm,
6818			Enabled:         func() bool { return true },
6819			DefaultModel:    func() string { return i.cfg.Model },
6820			DefaultProvider: func() string { return i.cfg.Provider },
6821			OnSpawned:       i.trackSwarmAgent,
6822		}
6823	}
6824	i.agent.SetTools(next)
6825}
6826
6827// applyTelegramTools registers (active=true) or removes (active=false)
6828// the telegram_send_image and telegram_send_file tools on the running
6829// agent so the model only sees them while the bridge is connected.
6830// Snapshots and mutates the live tool registry so any extension or
6831// /reload-ext additions made while Telegram is connected survive a
6832// later /telegram disconnect (we only add or strip the two telegram
6833// entries, never the rest).
6834func (i *Interactive) applyTelegramTools(active bool) {
6835	if i.agent == nil {
6836		return
6837	}
6838	current := i.agent.Tools
6839	next := core.Registry{}
6840	for name, t := range current {
6841		if name == "telegram_send_image" || name == "telegram_send_file" {
6842			continue
6843		}
6844		next[name] = t
6845	}
6846	if active {
6847		sender := telegramSenderAdapter{bridge: i.telegramBridge}
6848		next["telegram_send_image"] = &tools.TelegramSendImageTool{
6849			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
6850		}
6851		next["telegram_send_file"] = &tools.TelegramSendFileTool{
6852			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
6853		}
6854	}
6855	i.agent.SetTools(next)
6856}
6857
6858// telegramStatus writes a one-liner describing the bridge state.
6859// Reports on both the in-tui bridge and the background daemon so
6860// the user isn't confused when the daemon owns the poll loop.
6861func (i *Interactive) telegramStatus() {
6862	var msg string
6863	if i.telegramBridge != nil && i.telegramBridge.Active() {
6864		s := i.telegramBridge.State()
6865		msg = "telegram: connected (tui bridge)"
6866		if s.Username != "" {
6867			msg += " as @" + s.Username
6868		}
6869		if s.PairedID != 0 {
6870			msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
6871		} else {
6872			msg += " - awaiting pairing"
6873		}
6874	} else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
6875		msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
6876	} else {
6877		cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
6878		if cfg.BotToken == "" {
6879			msg = "telegram: not configured. run `zot telegram-bot setup` first."
6880		} else {
6881			msg = "telegram: disconnected"
6882			if cfg.BotUsername != "" {
6883				msg += " (@" + cfg.BotUsername + " ready to connect)"
6884			}
6885		}
6886	}
6887	i.mu.Lock()
6888	i.statusOK = msg
6889	i.statusErr = ""
6890	i.mu.Unlock()
6891	i.invalidate()
6892}
6893
6894// telegramHost adapts *Interactive to telegram.Host so the bridge
6895// can call back into the TUI without importing modes directly.
6896type telegramHost struct{ iv *Interactive }
6897
6898func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
6899	h.iv.SubmitOrQueue(prompt, images)
6900}
6901
6902func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
6903
6904func (h *telegramHost) Status() string {
6905	h.iv.mu.Lock()
6906	providerName := h.iv.cfg.Provider
6907	model := h.iv.cfg.Model
6908	cwd := h.iv.cfg.CWD
6909	usage := h.iv.cumUsage
6910	subscription := h.iv.cfg.AuthMethod == "oauth"
6911	ctxUsed := h.iv.lastCtxInput
6912	busy := h.iv.busy
6913	queued := len(h.iv.queued)
6914	h.iv.mu.Unlock()
6915
6916	ctxMax := 0
6917	if m, err := provider.FindModel(providerName, model); err == nil {
6918		ctxMax = m.ContextWindow
6919	}
6920	return telegram.FormatStatus(telegram.StatusSnapshot{
6921		Provider:     providerName,
6922		Model:        model,
6923		CWD:          cwd,
6924		Usage:        usage,
6925		Subscription: subscription,
6926		ContextUsed:  ctxUsed,
6927		ContextMax:   ctxMax,
6928		Busy:         busy,
6929		Queued:       queued,
6930	})
6931}
6932
6933func (h *telegramHost) Notify(level, message string) {
6934	h.iv.mu.Lock()
6935	switch level {
6936	case "error", "warn":
6937		h.iv.statusErr = message
6938		h.iv.statusOK = ""
6939	default:
6940		h.iv.statusOK = message
6941		h.iv.statusErr = ""
6942	}
6943	h.iv.mu.Unlock()
6944	h.iv.invalidate()
6945}
6946
6947// openSessionOpsDialog shows the picker for `/session` with no arg.
6948// Always offers timeline, export, import, fork, and tree. The handlers bail
6949// with a clear status message when a precondition is not met (empty transcript
6950// on fork; no parent/siblings on tree).
6951func (i *Interactive) openSessionOpsDialog() {
6952	items := []sessionOpsItem{
6953		{label: "timeline", action: "timeline", hint: "inspect context, messages, and tool calls"},
6954		{label: "export", action: "export", hint: "write the current session to a .zotsession file"},
6955		{label: "import", action: "import", hint: "load a .zotsession file into this directory"},
6956		{label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
6957		{label: "tree", action: "tree", hint: "switch between branches in this directory"},
6958	}
6959	i.sessionOpsDialog.Open(items)
6960	i.invalidate()
6961}
6962
6963// doSessionOp dispatches timeline, export, import, fork, or tree. arg is the
6964// optional positional argument from e.g. /session export <path>
6965// or /session import <path>; timeline, fork, and tree ignore it.
6966func (i *Interactive) doSessionOp(action, arg string) {
6967	switch action {
6968	case "timeline":
6969		i.openTimeline()
6970	case "export":
6971		i.doSessionExport(arg)
6972	case "import":
6973		i.doSessionImport(arg)
6974	case "fork":
6975		i.doSessionFork()
6976	case "tree":
6977		i.doSessionTree()
6978	default:
6979		i.mu.Lock()
6980		i.statusErr = "unknown /session action: " + action + " (use timeline, export, import, fork, or tree)"
6981		i.mu.Unlock()
6982		i.invalidate()
6983	}
6984}
6985
6986func (i *Interactive) openTimeline() {
6987	i.mu.Lock()
6988	if i.timeline == nil {
6989		i.timeline = newTimelineView()
6990	}
6991	i.timeline.Open(i.timelineDataLocked())
6992	i.chatCacheValid = false
6993	i.scrollOffset = 0
6994	i.statusErr = ""
6995	i.statusOK = ""
6996	i.mu.Unlock()
6997	i.invalidate()
6998}
6999
7000// doSessionExport writes the live session file to destination path
7001// dst. When dst is empty we default to ~/Downloads (falling back to
7002// the user's home directory if it doesn't exist). The helper
7003// expands a leading `~` and creates any missing parent directories.
7004func (i *Interactive) doSessionExport(dst string) {
7005	if i.cfg.CurrentSessionPath == nil {
7006		i.mu.Lock()
7007		i.statusErr = "export: no session is active (running with --no-session?)"
7008		i.mu.Unlock()
7009		i.invalidate()
7010		return
7011	}
7012	src := i.cfg.CurrentSessionPath()
7013	if src == "" {
7014		i.mu.Lock()
7015		i.statusErr = "export: no session is active (running with --no-session?)"
7016		i.mu.Unlock()
7017		i.invalidate()
7018		return
7019	}
7020	// Persist any in-memory agent messages to the session file so
7021	// the export carries the full conversation. Without this, the
7022	// default lazy-flush-at-exit strategy leaves most of a running
7023	// session unwritten and the export ends up with only the meta.
7024	if i.cfg.FlushSession != nil {
7025		i.cfg.FlushSession()
7026	}
7027	dst = unquotePath(dst)
7028	if dst == "" {
7029		dst = defaultExportDir()
7030	} else {
7031		dst = expandTilde(dst)
7032	}
7033	out, err := core.ExportSession(src, dst)
7034	if err != nil {
7035		i.mu.Lock()
7036		i.statusErr = "export: " + err.Error()
7037		i.mu.Unlock()
7038		i.invalidate()
7039		return
7040	}
7041	i.mu.Lock()
7042	i.statusOK = "exported session to " + friendlyPath(out)
7043	i.statusErr = ""
7044	i.mu.Unlock()
7045	i.invalidate()
7046}
7047
7048// doSessionImport copies the .zotsession file at src into the
7049// running cwd's sessions directory and loads it as the active
7050// session, same as `/sessions` -> pick. When src is empty we ask
7051// the user to pass a path (no usable default here).
7052func (i *Interactive) doSessionImport(src string) {
7053	src = unquotePath(src)
7054	if src == "" {
7055		i.mu.Lock()
7056		i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
7057		i.mu.Unlock()
7058		i.invalidate()
7059		return
7060	}
7061	src = expandTilde(src)
7062	if _, err := os.Stat(src); err != nil {
7063		i.mu.Lock()
7064		i.statusErr = "import: " + err.Error()
7065		i.mu.Unlock()
7066		i.invalidate()
7067		return
7068	}
7069	newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
7070	if err != nil {
7071		i.mu.Lock()
7072		i.statusErr = "import: " + err.Error()
7073		i.mu.Unlock()
7074		i.invalidate()
7075		return
7076	}
7077	if i.cfg.LoadSession == nil {
7078		i.mu.Lock()
7079		i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
7080		i.statusErr = ""
7081		i.mu.Unlock()
7082		i.invalidate()
7083		return
7084	}
7085	if err := i.cfg.LoadSession(newPath); err != nil {
7086		i.mu.Lock()
7087		i.statusErr = "import: load failed: " + err.Error()
7088		i.mu.Unlock()
7089		i.invalidate()
7090		return
7091	}
7092	i.mu.Lock()
7093	i.statusOK = "imported and switched to session " + friendlyPath(newPath)
7094	i.statusErr = ""
7095	i.mu.Unlock()
7096	i.invalidate()
7097}
7098
7099// defaultExportDir returns ~/Downloads when it exists, or ~ as a
7100// fallback, or /tmp on exotic machines with no home dir.
7101func defaultExportDir() string {
7102	home, err := os.UserHomeDir()
7103	if err != nil || home == "" {
7104		return os.TempDir()
7105	}
7106	downloads := filepath.Join(home, "Downloads")
7107	if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
7108		return downloads
7109	}
7110	return home
7111}
7112
7113// expandTilde turns a leading ~ into the user's home directory.
7114// Returns the input unchanged when there's no tilde or no home.
7115func expandTilde(p string) string {
7116	if p == "" || p[0] != '~' {
7117		return p
7118	}
7119	home, err := os.UserHomeDir()
7120	if err != nil || home == "" {
7121		return p
7122	}
7123	if len(p) == 1 {
7124		return home
7125	}
7126	if p[1] == '/' || p[1] == filepath.Separator {
7127		return filepath.Join(home, p[2:])
7128	}
7129	return p
7130}
7131
7132// unquotePath strips a matching pair of surrounding single or
7133// double quotes. Drag-drop paste in the tui auto-quotes dropped
7134// file paths so the shell-like `/session import 'foo bar.zs'`
7135// stays well-formed; when the TUI's own slash handler consumes
7136// the arg, we want the raw path back.
7137func unquotePath(p string) string {
7138	p = strings.TrimSpace(p)
7139	if len(p) >= 2 {
7140		first := p[0]
7141		last := p[len(p)-1]
7142		if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
7143			p = p[1 : len(p)-1]
7144		}
7145	}
7146	return p
7147}
7148
7149// friendlyPath collapses the user's home directory to a leading ~
7150// so status messages read cleanly. Falls back to the raw path when
7151// the home dir is unknown.
7152func friendlyPath(p string) string {
7153	home, err := os.UserHomeDir()
7154	if err != nil || home == "" {
7155		return p
7156	}
7157	if strings.HasPrefix(p, home+string(filepath.Separator)) {
7158		return "~" + p[len(home):]
7159	}
7160	return p
7161}
7162
7163// doSessionFork opens the /jump turn picker in "fork mode". The
7164// next selection branches the current session at that user turn
7165// instead of scrolling the viewport to it.
7166func (i *Interactive) doSessionFork() {
7167	if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
7168		i.mu.Lock()
7169		i.statusErr = "fork: no session is active (running with --no-session?)"
7170		i.mu.Unlock()
7171		i.invalidate()
7172		return
7173	}
7174	msgs := []provider.Message{}
7175	if i.agent != nil {
7176		msgs = i.agent.Messages()
7177	}
7178	if len(msgs) == 0 {
7179		i.mu.Lock()
7180		i.statusErr = "fork: transcript is empty; nothing to fork from"
7181		i.mu.Unlock()
7182		i.invalidate()
7183		return
7184	}
7185	i.pendingFork = true
7186	i.jumpDialog.Open(msgs, "")
7187	i.invalidate()
7188}
7189
7190// doSessionTree shows the current session family as an inline branch tree.
7191// Pick an entry to check out that point into a new branch. Subsequent
7192// prompts use only context up to the selected message.
7193func (i *Interactive) doSessionTree() {
7194	current := ""
7195	if i.cfg.CurrentSessionPath != nil {
7196		current = i.cfg.CurrentSessionPath()
7197	}
7198	if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) {
7199		i.invalidate()
7200		return
7201	}
7202	msgs := []provider.Message{}
7203	if i.agent != nil {
7204		msgs = i.agent.Messages()
7205	}
7206	if len(msgs) == 0 {
7207		i.mu.Lock()
7208		i.statusErr = "tree: no messages in this session yet"
7209		i.mu.Unlock()
7210		i.invalidate()
7211		return
7212	}
7213	if !i.sessionTreeDialog.OpenMessages(msgs) {
7214		i.mu.Lock()
7215		i.statusErr = "tree: no messages to show"
7216		i.mu.Unlock()
7217	}
7218	i.invalidate()
7219}
7220
7221// applySessionTreeMessageSelection checks out the selected message into a
7222// new branch, then switches the running agent to that branch. Unlike /jump,
7223// this changes future model context: only messages up to the selected row are
7224// sent on subsequent turns.
7225func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
7226	if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
7227		i.mu.Lock()
7228		i.statusErr = "tree: session branching is not available in this build"
7229		i.mu.Unlock()
7230		i.invalidate()
7231		return
7232	}
7233	if src == "" {
7234		src = i.cfg.CurrentSessionPath()
7235	}
7236	if src == "" {
7237		i.mu.Lock()
7238		i.statusErr = "tree: no session is active"
7239		i.mu.Unlock()
7240		i.invalidate()
7241		return
7242	}
7243	if i.cfg.FlushSession != nil {
7244		i.cfg.FlushSession()
7245	}
7246	upTo := msgIdx + 1
7247	if role == provider.RoleUser {
7248		upTo = msgIdx
7249	}
7250	newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
7251	if err != nil {
7252		i.mu.Lock()
7253		i.statusErr = "tree: " + err.Error()
7254		i.mu.Unlock()
7255		i.invalidate()
7256		return
7257	}
7258	if err := i.cfg.LoadSession(newPath); err != nil {
7259		i.mu.Lock()
7260		i.statusErr = "tree: checkout failed: " + err.Error()
7261		i.mu.Unlock()
7262		i.invalidate()
7263		return
7264	}
7265	i.scrollToBottom()
7266	i.mu.Lock()
7267	if role == provider.RoleUser {
7268		i.ed.SetValue(prompt)
7269		i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
7270	} else {
7271		i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
7272	}
7273	i.statusErr = ""
7274	i.mu.Unlock()
7275	i.invalidate()
7276}
7277
7278// applyForkSelection branches the current session at msgIdx+1 (so
7279// the selected user message and everything before it is included
7280// in the new branch), then switches the running agent to the new
7281// file. Called from the jump-dialog handler when pendingFork=true.
7282func (i *Interactive) applyForkSelection(msgIdx int) {
7283	i.pendingFork = false
7284	if i.cfg.CurrentSessionPath == nil {
7285		i.mu.Lock()
7286		i.statusErr = "fork: no session is active"
7287		i.mu.Unlock()
7288		i.invalidate()
7289		return
7290	}
7291	src := i.cfg.CurrentSessionPath()
7292	if src == "" {
7293		i.mu.Lock()
7294		i.statusErr = "fork: no session is active"
7295		i.mu.Unlock()
7296		i.invalidate()
7297		return
7298	}
7299	if i.cfg.FlushSession != nil {
7300		i.cfg.FlushSession()
7301	}
7302	// msgIdx is 0-indexed message position; copy msgIdx+1 rows so
7303	// the selected user message is included.
7304	upTo := msgIdx + 1
7305	newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
7306	if err != nil {
7307		i.mu.Lock()
7308		i.statusErr = "fork: " + err.Error()
7309		i.mu.Unlock()
7310		i.invalidate()
7311		return
7312	}
7313	if i.cfg.LoadSession == nil {
7314		i.mu.Lock()
7315		i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
7316		i.statusErr = ""
7317		i.mu.Unlock()
7318		i.invalidate()
7319		return
7320	}
7321	if err := i.cfg.LoadSession(newPath); err != nil {
7322		i.mu.Lock()
7323		i.statusErr = "fork: switch failed: " + err.Error()
7324		i.mu.Unlock()
7325		i.invalidate()
7326		return
7327	}
7328	i.mu.Lock()
7329	i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
7330	i.statusErr = ""
7331	i.mu.Unlock()
7332	i.invalidate()
7333}
7334
7335// formatInt is a tiny strconv.Itoa shim; keeps the handler above
7336// from needing a strconv import just for one call.
7337func formatInt(n int) string {
7338	return fmt.Sprintf("%d", n)
7339}
7340
7341// assistantText returns the concatenated text of every TextBlock in
7342// m. Used by the streaming-view dedupe guard to tell when a live
7343// streamed reply has already been promoted into the transcript.
7344func assistantText(m provider.Message) string {
7345	var sb strings.Builder
7346	for _, c := range m.Content {
7347		if tb, ok := c.(provider.TextBlock); ok {
7348			if sb.Len() > 0 {
7349				sb.WriteByte('\n')
7350			}
7351			sb.WriteString(tb.Text)
7352		}
7353	}
7354	return sb.String()
7355}
7356
7357// resetStreamingStateLocked clears every piece of streaming state
7358// in one shot. Used by abort paths (turn cancel, compact finish,
7359// queue drain) so the pacer doesn't keep draining stale runes from
7360// a prior turn. Must be called with i.mu held.
7361func (i *Interactive) resetStreamingStateLocked() {
7362	i.streaming.Reset()
7363	i.streamPending = i.streamPending[:0]
7364	i.streamFlushPending = false
7365	i.streamOn = false
7366	i.openAllToolGatesLocked()
7367}
7368
7369// openAllToolGatesLocked drops every pending tool gate so that any
7370// tool registered during this turn renders unconditionally from now
7371// on. Called when streaming finalizes (the paced text has fully
7372// drained and `streaming` is about to reset to length 0): without
7373// this, the gate comparison against a freshly-reset streaming buffer
7374// would wrongly re-hide tools that had already cleared their gate.
7375// Must be called with i.mu held.
7376func (i *Interactive) openAllToolGatesLocked() {
7377	for id := range i.toolGate {
7378		i.toolGate[id] = 0
7379	}
7380}
7381
7382// gateToolLocked records the stream position at which a tool call may
7383// become visible. The gate is the total length the streaming buffer
7384// will reach once the pacer has drained everything currently queued
7385// (already painted + still pending). Holding the tool block back
7386// until the pacer crosses that mark guarantees the prose emitted
7387// before the tool call finishes typing out above it, instead of the
7388// tool block snapping in while the paragraph is still filling in.
7389//
7390// We only gate while text is actively streaming. If no stream is in
7391// flight (gate 0), the tool shows immediately, which is the correct
7392// behaviour for tool-only turns and replayed sessions. First
7393// registration wins so a later EvToolCall can't move an existing
7394// gate. Must be called with i.mu held.
7395func (i *Interactive) gateToolLocked(id string) {
7396	if _, ok := i.toolGate[id]; ok {
7397		return
7398	}
7399	if !i.streamOn {
7400		i.toolGate[id] = 0
7401		return
7402	}
7403	i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
7404}
7405
7406// toolGateOpenLocked reports whether the gated tool block may render
7407// yet, i.e. the pacer has drained enough text to reach the position
7408// recorded when the tool call arrived. Must be called with i.mu held.
7409func (i *Interactive) toolGateOpenLocked(id string) bool {
7410	gate, ok := i.toolGate[id]
7411	if !ok || gate == 0 {
7412		return true
7413	}
7414	return i.streaming.Len() >= gate
7415}
7416
7417// assistantMessageSideEffects runs the non-visual hooks attached to
7418// EvAssistantMessage: the host-provided OnAssistant callback and the
7419// telegram-bridge mirror. Called with i.mu held.
7420//
7421// Factored out of handleEvent because the streaming pacer may defer
7422// visual reset until after the last buffered rune has painted, but
7423// the callbacks themselves must fire on message arrival so
7424// downstream observers (session persistence, telegram, cost panels)
7425// don't wait on a UI animation to catch up.
7426func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
7427	if i.cfg.OnAssistant != nil {
7428		i.cfg.OnAssistant(m)
7429	}
7430	if i.telegramBridge != nil && i.telegramBridge.Active() {
7431		var sb strings.Builder
7432		for _, c := range m.Content {
7433			if tb, ok := c.(provider.TextBlock); ok {
7434				if sb.Len() > 0 {
7435					sb.WriteString("\n")
7436				}
7437				sb.WriteString(tb.Text)
7438			}
7439		}
7440		if text := sb.String(); strings.TrimSpace(text) != "" {
7441			go i.telegramBridge.OnAssistantText(text)
7442		}
7443	}
7444}
7445
7446// paintPaceRate is how many runes the streaming pacer releases per
7447// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
7448// that a 500-rune summary finishes in ~1.3s, slow enough to look
7449// like a human typing. Empirically matches the feel of provider
7450// paths that already drip-stream natively.
7451const paintPaceRate = 6
7452
7453// paintPaceInterval is the tick interval for the streaming pacer.
7454// 16ms lines up with the redraw throttle so we never paint faster
7455// than the terminal can keep up.
7456const paintPaceInterval = 16 * time.Millisecond
7457
7458// runStreamPacer drains buffered deltas from streamPending into
7459// streaming a small batch per tick, invalidating after each move.
7460// It stops when the context cancels (tui shutdown).
7461//
7462// Why a pacer: providers differ wildly in how they chunk their
7463// text_delta events. The API-key path on Anthropic emits ~30 drips
7464// for a 400-token summary; the OAuth path can coalesce the same
7465// summary into 3 fat chunks, visually indistinguishable from "the
7466// whole reply just appeared". The pacer normalizes that so every
7467// path looks the same on screen.
7468func (i *Interactive) runStreamPacer(ctx context.Context) {
7469	t := time.NewTicker(paintPaceInterval)
7470	defer t.Stop()
7471	for {
7472		select {
7473		case <-ctx.Done():
7474			return
7475		case <-t.C:
7476			i.mu.Lock()
7477			if len(i.streamPending) == 0 {
7478				// EvAssistantMessage already fired but the pacer
7479				// was still draining a tick ago. Everything is now
7480				// painted; clear the streaming flags so the next
7481				// redraw shows the finalised transcript message
7482				// and hides the streaming overlay.
7483				if i.streamFlushPending {
7484					i.streamFlushPending = false
7485					i.streaming.Reset()
7486					i.streamOn = false
7487					i.openAllToolGatesLocked()
7488					i.mu.Unlock()
7489					i.invalidate()
7490					continue
7491				}
7492				i.mu.Unlock()
7493				continue
7494			}
7495			n := paintPaceRate
7496			if n > len(i.streamPending) {
7497				n = len(i.streamPending)
7498			}
7499			i.streaming.WriteString(string(i.streamPending[:n]))
7500			i.streamPending = i.streamPending[n:]
7501			i.mu.Unlock()
7502			i.invalidate()
7503		}
7504	}
7505}

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