v0.3.17 1d11cc6

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

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