v0.3.46 b3121d6

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

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 477

↑↓ moveenter openesc close