v0.2.92 b99b1ae

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

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