v0.3.59 e54d007

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close