v0.3.82 2e387ff

interactive.go RawHistoryFolder

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

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