v0.2.42 821f51a

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

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