v0.2.95 b3586dd

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

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