v0.3.2 6af4e0f

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

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