v0.3.67 80dd4c6

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

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