v0.2.43 6899695

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

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