v0.3.71 0dbbc58

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

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