v0.2.21 f7bf4a9

interactive.go RawHistoryFolder

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

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 476

↑↓ moveenter openesc close