v0.3.41 9fa3d04

interactive.go RawHistoryFolder

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

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