v0.2.51 92241f6

interactive.go RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close