v0.3.74 0f485af

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

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