v0.3.11 2c9dbfa

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

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