v0.3.65 9ef467d

interactive.go RawHistoryFolder

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

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