v0.2.12 7a7bf0b

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close