v0.3.12 999d53a

interactive.go RawHistoryFolder

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

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