v0.3.55 fdc6643

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

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close