v0.3.26 b289bf6

interactive.go RawHistoryFolder

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

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