v0.3.32 6bae491

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

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