v0.3.22 10bb5d0

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	switch strings.ToLower(parts[0]) {
4165	case "/exit":
4166		return true
4167	case "/clear":
4168		if i.agent != nil {
4169			i.agent.SetMessages(nil)
4170		}
4171		i.mu.Lock()
4172		i.toolCalls = map[string]*tui.ToolCallView{}
4173		i.toolOrder = nil
4174		i.toolGate = map[string]int{}
4175		i.statusErr = ""
4176		i.statusOK = ""
4177		i.helpBlock = nil
4178		i.parkedTurn = 0
4179		i.parkedTotal = 0
4180		i.scrollOffset = 0
4181		i.extNotes = nil
4182		i.reloadErrors = nil
4183		i.view.InvalidateRenderCache()
4184		i.mu.Unlock()
4185	case "/help":
4186		i.mu.Lock()
4187		i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
4188		i.statusErr = ""
4189		i.statusOK = ""
4190		// Pin the viewport to the newest content so the help block,
4191		// which we just appended to the end of the transcript, is
4192		// what the user actually sees.
4193		i.scrollOffset = 0
4194		i.mu.Unlock()
4195	case "/login":
4196		i.dialog.Open(i.cfg.ZotHome)
4197	case "/logout":
4198		if len(parts) >= 2 {
4199			// Explicit target: /logout anthropic | openai | all
4200			i.doLogout(parts[1])
4201			break
4202		}
4203		// No arg: open the picker over whichever providers are
4204		// currently logged in. If nothing's logged in, bail with a
4205		// status line.
4206		i.openLogoutDialog()
4207	case "/model":
4208		if len(parts) >= 2 {
4209			i.applyModelSelection("", parts[1])
4210		} else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
4211			i.mu.Lock()
4212			i.statusOK = "refreshing models"
4213			i.statusErr = ""
4214			i.mu.Unlock()
4215			go func() {
4216				refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
4217				defer cancel()
4218				i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)}
4219			}()
4220		} else {
4221			i.openModelPickerAfterRefresh(nil)
4222		}
4223	case "/llama":
4224		if i.cfg.LlamaCPPConfig == nil {
4225			i.mu.Lock()
4226			i.statusErr = "llama.cpp configuration is unavailable"
4227			i.statusOK = ""
4228			i.mu.Unlock()
4229			break
4230		}
4231		baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
4232		if configErr == nil {
4233			configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
4234		}
4235		if configErr != nil {
4236			i.mu.Lock()
4237			i.statusErr = configErr.Error()
4238			i.statusOK = ""
4239			i.mu.Unlock()
4240		}
4241	case "/settings":
4242		i.openSettingsDialog()
4243	case "/sessions":
4244		i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)
4245	case "/jump":
4246		i.openJumpDialog(parts[1:])
4247	case "/btw":
4248		i.openBtwDialog(parts[1:])
4249	case "/skills":
4250		i.openSkillsDialog()
4251	case "/compact":
4252		i.runCompact(ctx, false)
4253	case "/study":
4254		// Canned prompt that tells the agent to read every file
4255		// in some target so its later turns have the whole thing
4256		// in context. With no argument, the target is the current
4257		// directory. With an argument, the target is whatever the
4258		// user passed — typed by hand, drag-dropped, or selected
4259		// via the @ file picker (which is why we accept both files
4260		// and directories; the @-picker chips for both have already
4261		// been expanded to absolute paths by expandFileChips above).
4262		// Dispatched through the normal queue-or-start path so it
4263		// behaves identically to typing the prompt by hand.
4264		studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
4265		i.mu.Lock()
4266		busy := i.busy
4267		compacting := i.compacting
4268		ag := i.agent
4269		i.mu.Unlock()
4270		if busy {
4271			if ag != nil && !compacting {
4272				ag.QueueMessage(studyPrompt)
4273			} else {
4274				i.mu.Lock()
4275				i.queued = append(i.queued, studyPrompt)
4276				i.mu.Unlock()
4277			}
4278			i.invalidate()
4279			break
4280		}
4281		i.startTurn(ctx, studyPrompt)
4282	case "/cd":
4283		// Hidden command: switch the running session's cwd. Not in
4284		// slash_suggest, not in /help. Used by the workspaces
4285		// extension's panel-key Enter handler so picking a row
4286		// jumps zot into that directory without relaunching.
4287		//
4288		// Recovers the raw argument (path) from the original cmd
4289		// string rather than parts, so paths with spaces survive.
4290		// The host's ChangeCWD hook handles validation, session
4291		// close + reopen, agent rebuild, sandbox re-rooting, and
4292		// re-jail-if-jailed semantics.
4293		if i.cfg.ChangeCWD == nil {
4294			i.mu.Lock()
4295			i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
4296			i.mu.Unlock()
4297			break
4298		}
4299		path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
4300		if path == "" {
4301			i.mu.Lock()
4302			i.statusErr = "/cd: missing path"
4303			i.mu.Unlock()
4304			break
4305		}
4306		if err := i.cfg.ChangeCWD(path); err != nil {
4307			i.mu.Lock()
4308			i.statusErr = "/cd: " + err.Error()
4309			i.statusOK = ""
4310			i.mu.Unlock()
4311			break
4312		}
4313		// ChangeCWD has already updated i.cfg.CWD and swapped the
4314		// agent + session. Reset transient TUI state so the new
4315		// session opens clean.
4316		i.mu.Lock()
4317		i.toolCalls = map[string]*tui.ToolCallView{}
4318		i.toolOrder = nil
4319		i.toolGate = map[string]int{}
4320		i.helpBlock = nil
4321		i.parkedTurn = 0
4322		i.statusOK = "cwd " + i.cfg.CWD
4323		i.statusErr = ""
4324		i.mu.Unlock()
4325		i.fileSuggest.Reset()
4326		i.fileSuggest.SetCWD(i.cfg.CWD)
4327		i.invalidate()
4328	case "/jail":
4329		if i.cfg.Sandbox == nil {
4330			i.mu.Lock()
4331			i.statusErr = "sandbox not available in this build"
4332			i.mu.Unlock()
4333			break
4334		}
4335		i.cfg.Sandbox.Lock()
4336		i.mu.Lock()
4337		i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
4338		i.statusErr = ""
4339		i.mu.Unlock()
4340	case "/unjail":
4341		if i.cfg.Sandbox == nil {
4342			i.mu.Lock()
4343			i.statusErr = "sandbox not available in this build"
4344			i.mu.Unlock()
4345			break
4346		}
4347		i.cfg.Sandbox.Unlock()
4348		i.mu.Lock()
4349		i.statusOK = "unjailed"
4350		i.statusErr = ""
4351		i.mu.Unlock()
4352	case "/reload-ext":
4353		i.runReloadExt(ctx)
4354	case "/telegram", "/tg":
4355		if len(parts) >= 2 {
4356			i.doTelegram(parts[1])
4357			break
4358		}
4359		i.openTelegramDialog()
4360	case "/session":
4361		if len(parts) >= 2 {
4362			action := parts[1]
4363			arg := ""
4364			if len(parts) >= 3 {
4365				arg = strings.Join(parts[2:], " ")
4366			}
4367			i.doSessionOp(action, arg)
4368			break
4369		}
4370		i.openSessionOpsDialog()
4371	case "/swarm":
4372		i.runSwarm(ctx, parts[1:])
4373	default:
4374		// Last-resort fallback: try the extension manager. Built-in
4375		// cases above always win; this branch only fires for slash
4376		// commands the extension manager registered. Same routing as
4377		// the editor's submit-handler dispatch path so the autocomplete
4378		// "enter on highlighted suggestion" flow also works.
4379		extName := strings.TrimPrefix(parts[0], "/")
4380		if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
4381			rest := ""
4382			if len(parts) > 1 {
4383				rest = strings.Join(parts[1:], " ")
4384			}
4385			go i.invokeExtensionCommand(ctx, extName, rest)
4386			return false
4387		}
4388		i.mu.Lock()
4389		i.statusErr = "unknown command: " + parts[0]
4390		i.mu.Unlock()
4391	}
4392	return false
4393}
4394
4395func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
4396	var loggedIn []string
4397	if i.cfg.LoggedInProviders != nil {
4398		loggedIn = i.cfg.LoggedInProviders()
4399	}
4400	i.modelDialog.Open(i.cfg.Model, loggedIn)
4401	i.mu.Lock()
4402	if refreshErr != nil {
4403		i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
4404		i.statusOK = ""
4405	} else if i.statusOK == "refreshing models" {
4406		i.statusOK = ""
4407	}
4408	i.mu.Unlock()
4409}
4410
4411// openLogoutDialog shows the provider picker for `/logout` with no
4412// argument. Only providers the user is currently logged into are
4413// listed, plus an "all" entry when more than one is present. If
4414// nothing's logged in, writes a status line instead of opening an
4415// empty dialog.
4416func (i *Interactive) openLogoutDialog() {
4417	if i.cfg.AuthManager == nil {
4418		i.mu.Lock()
4419		i.statusErr = "no auth manager configured"
4420		i.mu.Unlock()
4421		i.invalidate()
4422		return
4423	}
4424	store := i.cfg.AuthManager.Store()
4425	if store == nil {
4426		i.mu.Lock()
4427		i.statusErr = "auth store is not available"
4428		i.mu.Unlock()
4429		i.invalidate()
4430		return
4431	}
4432	creds, err := store.Load()
4433	if err != nil {
4434		i.mu.Lock()
4435		i.statusErr = "read auth store: " + err.Error()
4436		i.mu.Unlock()
4437		i.invalidate()
4438		return
4439	}
4440
4441	var items []logoutItem
4442	for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
4443		if creds.Has(p) {
4444			method := creds.Method(p)
4445			if method == "oauth" {
4446				method = "subscription"
4447			}
4448			items = append(items, logoutItem{
4449				label:  providerLabel(p),
4450				target: p,
4451				method: method,
4452			})
4453		}
4454	}
4455	if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
4456		items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
4457	}
4458	if creds.OpenAI.OAuth != nil {
4459		items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
4460	}
4461	for p, c := range creds.AdditionalAPIKeyCreds {
4462		if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
4463			items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
4464		}
4465	}
4466	if len(items) == 0 {
4467		i.mu.Lock()
4468		i.statusOK = "no credentials stored; already logged out"
4469		i.statusErr = ""
4470		i.mu.Unlock()
4471		i.invalidate()
4472		return
4473	}
4474	if len(items) > 1 {
4475		items = append(items, logoutItem{label: "all", target: "all"})
4476	}
4477
4478	i.logoutDialog.Open(items)
4479	i.invalidate()
4480}
4481
4482// doLogout clears credentials for the given provider (or all providers)
4483// from auth.json. If the active agent was using those credentials, it
4484// is torn down so the user is forced through /login before their next
4485// prompt.
4486//
4487// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
4488func (i *Interactive) doLogout(target string) {
4489	if i.cfg.AuthManager == nil {
4490		i.mu.Lock()
4491		i.statusErr = "no auth manager configured"
4492		i.mu.Unlock()
4493		return
4494	}
4495	store := i.cfg.AuthManager.Store()
4496	if store == nil {
4497		i.mu.Lock()
4498		i.statusErr = "auth store is not available"
4499		i.mu.Unlock()
4500		return
4501	}
4502
4503	var providers []string
4504	switch target {
4505	case "", "all":
4506		providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
4507	case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
4508		providers = []string{target}
4509	default:
4510		known := false
4511		for _, p := range auth.APIKeyProviders() {
4512			if target == p {
4513				known = true
4514				break
4515			}
4516		}
4517		if !known {
4518			i.mu.Lock()
4519			i.statusErr = "unknown provider: " + target
4520			i.mu.Unlock()
4521			return
4522		}
4523		providers = []string{target}
4524	}
4525
4526	var errs []string
4527	clearedCurrent := false
4528	for _, p := range providers {
4529		var err error
4530		switch p {
4531		case "openai":
4532			err = store.ClearAPIKey("openai")
4533		case "openai-codex":
4534			err = store.ClearOAuth("openai")
4535		default:
4536			err = store.Clear(p)
4537		}
4538		if err != nil {
4539			errs = append(errs, p+": "+err.Error())
4540			continue
4541		}
4542		if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
4543			if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
4544				errs = append(errs, p+": "+err.Error())
4545				continue
4546			}
4547		}
4548		if p == i.cfg.Provider {
4549			clearedCurrent = true
4550		}
4551	}
4552
4553	llamaConfigured := i.llamaConfigured
4554	if i.cfg.LlamaCPPConfig != nil {
4555		baseURL, _, err := i.cfg.LlamaCPPConfig()
4556		llamaConfigured = err == nil && baseURL != ""
4557	}
4558
4559	i.mu.Lock()
4560	defer i.mu.Unlock()
4561	i.llamaConfigured = llamaConfigured
4562	if len(errs) > 0 {
4563		i.statusErr = "logout errors: " + strings.Join(errs, "; ")
4564		return
4565	}
4566	i.statusErr = ""
4567	if clearedCurrent {
4568		// The running agent was using a credential we just wiped. Drop
4569		// it so prompts can't go out with the stale client, and hint at
4570		// /login.
4571		i.agent = nil
4572		i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
4573	} else {
4574		i.statusOK = "logged out of " + strings.Join(providers, ", ")
4575	}
4576}
4577
4578func providerSetupInfo(provider string) (string, []string, bool) {
4579	const docsURL = "https://raw.githubusercontent.com/patriceckhart/zot/main/docs/providers.md"
4580	switch provider {
4581	case "amazon-bedrock":
4582		return "Amazon Bedrock setup", []string{
4583			"Amazon Bedrock uses AWS credentials instead of a generic zot API-key entry.",
4584			"Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
4585			"",
4586			"For Bedrock API keys, set:",
4587			"  AWS_BEARER_TOKEN_BEDROCK=...",
4588			"  AWS_REGION=us-east-1",
4589			"",
4590			"Docs:",
4591			"  " + docsURL,
4592		}, true
4593	case "google-vertex":
4594		return "Google Vertex AI setup", []string{
4595			"Google Vertex AI usually uses Google Cloud credentials and project settings.",
4596			"Set a Google API key, application-default credentials, or a service account.",
4597			"",
4598			"Common environment:",
4599			"  GOOGLE_CLOUD_API_KEY=...",
4600			"  GOOGLE_CLOUD_PROJECT=...",
4601			"  GOOGLE_CLOUD_LOCATION=us-central1",
4602			"",
4603			"Docs:",
4604			"  " + docsURL,
4605		}, true
4606	case "cloudflare-workers-ai":
4607		return "Cloudflare Workers AI setup", []string{
4608			"Cloudflare Workers AI needs both an API token and an account ID.",
4609			"",
4610			"Set:",
4611			"  CLOUDFLARE_API_KEY=...",
4612			"  CLOUDFLARE_ACCOUNT_ID=...",
4613			"",
4614			"Docs:",
4615			"  " + docsURL,
4616		}, true
4617	case "cloudflare-ai-gateway":
4618		return "Cloudflare AI Gateway setup", []string{
4619			"Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
4620			"",
4621			"Set:",
4622			"  CLOUDFLARE_API_KEY=...",
4623			"  CLOUDFLARE_ACCOUNT_ID=...",
4624			"  CLOUDFLARE_GATEWAY_ID=...",
4625			"",
4626			"Docs:",
4627			"  " + docsURL,
4628		}, true
4629	case "azure-openai-responses":
4630		return "Azure OpenAI Responses setup", []string{
4631			"Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
4632			"",
4633			"Set:",
4634			"  AZURE_OPENAI_API_KEY=...",
4635			"  AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
4636			"  AZURE_OPENAI_API_VERSION=2024-02-01",
4637			"",
4638			"Docs:",
4639			"  " + docsURL,
4640		}, true
4641	default:
4642		return "", nil, false
4643	}
4644}
4645
4646func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
4647	if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
4648		i.dialog.ShowResult(false, "auth store is unavailable")
4649		return
4650	}
4651	if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
4652		i.dialog.ShowResult(false, err.Error())
4653		return
4654	}
4655	i.mu.Lock()
4656	i.llamaConfigured = true
4657	i.statusErr = ""
4658	i.statusOK = "configured llama.cpp router"
4659	i.mu.Unlock()
4660	i.dialog.ShowResult(true, "")
4661}
4662
4663func (i *Interactive) startAPIKeyFlow(provider string) {
4664	if title, lines, ok := providerSetupInfo(provider); ok {
4665		i.dialog.ShowInfo(title, lines)
4666		return
4667	}
4668	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
4669		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
4670	}
4671	url, err := i.cfg.AuthManager.StartAPIKey(provider)
4672	if err != nil {
4673		i.dialog.ShowResult(false, err.Error())
4674		return
4675	}
4676	i.dialog.ShowWaiting(url)
4677}
4678
4679func (i *Interactive) startOAuthFlow(provider string) {
4680	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
4681		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
4682	}
4683	// Device-code providers already support headless login and must only
4684	// start one polling flow.
4685	if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
4686		loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
4687		if err != nil {
4688			i.dialog.ShowResult(false, err.Error())
4689			return
4690		}
4691		i.dialog.ShowWaiting(loginURL)
4692		return
4693	}
4694	// Always run the manual/copy-code flow in parallel with the local
4695	// callback server so headless environments (docker, SSH) can paste
4696	// the authorization code directly without first pressing 'p'.
4697	_, err := i.cfg.AuthManager.StartOAuth(provider)
4698	if err != nil {
4699		i.dialog.ShowResult(false, err.Error())
4700		return
4701	}
4702	manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
4703	if mErr == nil {
4704		i.dialog.ShowWaiting(manualURL)
4705	} else {
4706		i.dialog.ShowResult(false, mErr.Error())
4707	}
4708}
4709
4710func (i *Interactive) startManualOAuthFlow(provider string) {
4711	if i.cfg.AuthManager == nil {
4712		return
4713	}
4714	i.cfg.AuthManager.CancelOAuth()
4715	url, err := i.cfg.AuthManager.StartManualOAuth(provider)
4716	if err != nil {
4717		i.dialog.ShowResult(false, err.Error())
4718		return
4719	}
4720	i.dialog.url = url
4721	i.invalidate()
4722}
4723
4724func (i *Interactive) submitManualOAuthCode(code string) {
4725	if i.cfg.AuthManager == nil {
4726		return
4727	}
4728	go func() {
4729		if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
4730			i.dialog.ShowResult(false, err.Error())
4731			i.invalidate()
4732		}
4733	}()
4734}
4735
4736// applyModelSelection switches the active model (and provider, if the
4737// new model belongs to a different one). It rebuilds the underlying
4738// client when needed so the provider wire-protocol matches.
4739// cancelAndWaitForIdle cancels the active turn (if any) and blocks
4740// briefly until the turn goroutine has updated i.busy = false. Used
4741// before destructive slash commands so transcript-mutating work
4742// (/clear, /compact, /logout, /login completion, cross-provider
4743// /model swap) doesn't race with the still-running stream.
4744//
4745// The wait is bounded; if the turn doesn't release within the timeout
4746// we proceed anyway. Worst case is a brief overlap that the agent's
4747// own mutex protects against.
4748func (i *Interactive) cancelAndWaitForIdle() {
4749	i.mu.Lock()
4750	busy := i.busy
4751	cancel := i.cancelTurn
4752	i.mu.Unlock()
4753	if !busy {
4754		return
4755	}
4756	if cancel != nil {
4757		cancel()
4758	}
4759	// ConfirmToolCall waits on its response channel rather than the turn
4760	// context, so cancellation must also resolve every pending prompt.
4761	i.confirmDialog.CancelAll("turn cancelled")
4762	deadline := time.Now().Add(2 * time.Second)
4763	for time.Now().Before(deadline) {
4764		i.mu.Lock()
4765		done := !i.busy
4766		i.mu.Unlock()
4767		if done {
4768			return
4769		}
4770		time.Sleep(10 * time.Millisecond)
4771	}
4772}
4773
4774// openBtwDialog opens the side-chat overlay with a frozen snapshot
4775// of the current main session. The optional argument is auto-
4776// submitted as the first question, so '/btw does X work?' fires the
4777// model call immediately instead of just opening an empty dialog.
4778func (i *Interactive) openBtwDialog(args []string) {
4779	if i.agent == nil {
4780		i.mu.Lock()
4781		i.statusErr = "not logged in. type /login first."
4782		i.mu.Unlock()
4783		return
4784	}
4785	seed := strings.TrimSpace(strings.Join(args, " "))
4786	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)
4787	i.invalidate()
4788}
4789
4790// openSkillsDialog opens the skill inspector. The picker reflects
4791// whatever SkillSnapshot returns at call time, so edits to a
4792// SKILL.md made during a session show up on the next /skills.
4793func (i *Interactive) openSkillsDialog() {
4794	var list []*skills.Skill
4795	if i.cfg.SkillSnapshot != nil {
4796		list = i.cfg.SkillSnapshot()
4797	}
4798	i.skillsDialog.Open(list)
4799	i.invalidate()
4800}
4801
4802// openJumpDialog builds a /jump picker from the current transcript.
4803// If the user typed "/jump foo" with a filter and it matches exactly
4804// one turn, jump there directly without showing the dialog.
4805func (i *Interactive) openJumpDialog(args []string) {
4806	if i.view == nil || len(i.view.Messages) == 0 {
4807		i.mu.Lock()
4808		i.statusErr = "nothing to jump to \u2014 the session is empty"
4809		i.mu.Unlock()
4810		return
4811	}
4812	filter := strings.TrimSpace(strings.Join(args, " "))
4813	i.jumpDialog.Open(i.view.Messages, filter)
4814	// Shortcut: with a filter argument that matches exactly one turn,
4815	// jump immediately and skip the picker.
4816	if filter != "" {
4817		if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
4818			t := tgts[0]
4819			i.jumpDialog.Close()
4820			i.applyJumpSelection(t.MessageIdx, t.TurnNo)
4821		}
4822	}
4823}
4824
4825// applyJumpSelection scrolls the chat viewport so the user message at
4826// msgIdx is visible at (or near) the top of the chat area. Uses the
4827// anchor slice returned by view.BuildWithAnchors so the mapping from
4828// message index to row is exact, regardless of variable-height tool
4829// blocks above the target.
4830func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
4831	cols := i.lastCols()
4832	chat, anchors := i.view.BuildWithAnchors(cols)
4833	var row int
4834	found := false
4835	for _, a := range anchors {
4836		if a.MessageIdx == msgIdx {
4837			row = a.Row
4838			found = true
4839			break
4840		}
4841	}
4842	if !found {
4843		i.mu.Lock()
4844		i.statusErr = "could not resolve jump target"
4845		i.mu.Unlock()
4846		return
4847	}
4848
4849	chatLen := len(chat)
4850	page := i.chatPage()
4851	if page < 1 {
4852		page = 1
4853	}
4854	// scrollOffset is measured from the bottom of the chat slice, so
4855	// to place `row` at the top of the viewport we want:
4856	//     chatLen - scrollOffset - page == row
4857	// Solve for scrollOffset and clamp to [0, chatLen-page].
4858	offset := chatLen - (row + page)
4859	if offset < 0 {
4860		offset = 0
4861	}
4862	maxOffset := chatLen - page
4863	if maxOffset < 0 {
4864		maxOffset = 0
4865	}
4866	if offset > maxOffset {
4867		offset = maxOffset
4868	}
4869
4870	i.mu.Lock()
4871	i.scrollOffset = offset
4872	i.parkedTurn = turnNo
4873	i.parkedTotal = totalTurnsLocked(i.view.Messages)
4874	i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
4875	i.statusErr = ""
4876	i.mu.Unlock()
4877}
4878
4879// totalTurnsLocked counts user messages in the transcript. Caller is
4880// assumed to hold i.mu (the name is a mild reminder; this function
4881// itself doesn't touch shared state beyond the slice it's handed).
4882func totalTurnsLocked(msgs []provider.Message) int {
4883	n := 0
4884	for _, m := range msgs {
4885		if m.Role == provider.RoleUser {
4886			n++
4887		}
4888	}
4889	return n
4890}
4891
4892// applySessionSelection loads the given session via the cli-provided
4893// callback and snaps the viewport to the bottom (the latest message)
4894// so the user lands at the live tail of the resumed conversation.
4895func (i *Interactive) applySessionSelection(path string) {
4896	if i.cfg.LoadSession == nil {
4897		i.mu.Lock()
4898		i.statusErr = "session loading is not wired in this build"
4899		i.mu.Unlock()
4900		return
4901	}
4902	i.mu.Lock()
4903	if i.sessionLoading {
4904		i.statusErr = "already resuming a session"
4905		i.mu.Unlock()
4906		i.invalidate()
4907		return
4908	}
4909	i.sessionLoading = true
4910	i.statusOK = "resuming session: " + path
4911	i.statusErr = ""
4912	i.mu.Unlock()
4913	i.invalidate()
4914
4915	go func() {
4916		err := i.cfg.LoadSession(path)
4917		i.mu.Lock()
4918		defer i.mu.Unlock()
4919		i.sessionLoading = false
4920		if err != nil {
4921			i.statusErr = err.Error()
4922			i.statusOK = ""
4923			i.invalidate()
4924			return
4925		}
4926		i.statusOK = "resumed session: " + path
4927		i.statusErr = ""
4928		i.parkedTurn = 0
4929		i.parkedTotal = 0
4930		i.scrollOffset = 0
4931		// Fresh transcript swapped in: drop the auto-follow baseline so
4932		// the next render's follow guard doesn't see the wholesale
4933		// length change as a delta and jump the viewport.
4934		i.prevChatLen = 0
4935		i.prevChatCols = 0
4936		i.extNotes = nil
4937		i.view.InvalidateRenderCache()
4938		if i.agent != nil {
4939			i.view.Messages = i.agent.Messages()
4940			i.cumUsage = i.agent.Cost()
4941			if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
4942				i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
4943			} else {
4944				i.lastCtxInput = 0
4945			}
4946			// Snap to the tail again — the swap brought in a fresh
4947			// transcript whose markdown / chroma cost we don't want
4948			// blocking the redraw.
4949			if len(i.view.Messages) > initialResumeTailLimit {
4950				i.view.TailLimit = initialResumeTailLimit
4951			} else {
4952				i.view.TailLimit = 0
4953			}
4954		}
4955		i.invalidate()
4956	}()
4957}
4958
4959// scrollToLastTurn parks the viewport at the most recent user turn,
4960// or at the top if the transcript has no user messages. Used after
4961// resume so the user lands looking at where they left off.
4962func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
4963	if len(msgs) == 0 {
4964		i.mu.Lock()
4965		i.scrollOffset = 0
4966		i.mu.Unlock()
4967		return
4968	}
4969	// Find the last user message index.
4970	lastUser := -1
4971	turnNo, totalTurns := 0, 0
4972	for idx, m := range msgs {
4973		if m.Role == provider.RoleUser {
4974			totalTurns++
4975			lastUser = idx
4976		}
4977	}
4978	if lastUser < 0 {
4979		i.mu.Lock()
4980		i.scrollOffset = 0
4981		i.mu.Unlock()
4982		return
4983	}
4984	turnNo = totalTurns
4985
4986	cols := i.lastCols()
4987	chat, anchors := i.view.BuildWithAnchors(cols)
4988	var row int
4989	found := false
4990	for _, a := range anchors {
4991		if a.MessageIdx == lastUser {
4992			row = a.Row
4993			found = true
4994			break
4995		}
4996	}
4997	if !found {
4998		i.mu.Lock()
4999		i.scrollOffset = 0
5000		i.mu.Unlock()
5001		return
5002	}
5003
5004	chatLen := len(chat)
5005	page := i.chatPage()
5006	if page < 1 {
5007		page = 1
5008	}
5009	offset := chatLen - (row + page)
5010	if offset < 0 {
5011		offset = 0
5012	}
5013	maxOffset := chatLen - page
5014	if maxOffset < 0 {
5015		maxOffset = 0
5016	}
5017	if offset > maxOffset {
5018		offset = maxOffset
5019	}
5020
5021	i.mu.Lock()
5022	i.scrollOffset = offset
5023	// Mark the parked-turn footer so the user sees "viewing turn N of
5024	// M - pgdn to catch up" — same affordance as /jump. Tells them at
5025	// a glance that they're looking at history, not the live tail.
5026	if offset > 0 {
5027		i.parkedTurn = turnNo
5028		i.parkedTotal = totalTurns
5029	}
5030	i.mu.Unlock()
5031	i.invalidate()
5032}
5033
5034func (i *Interactive) applyModelSelection(prov, model string) {
5035	i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
5036}
5037
5038// applyRescueModelSelection is like applyModelSelection but routes
5039// through BuildAgentForRescue so launch-time --api-key / --base-url
5040// overrides are dropped before the new agent is built. Falls back to
5041// the regular builder when the host doesn't wire a rescue builder.
5042func (i *Interactive) applyRescueModelSelection(prov, model string) {
5043	builder := i.cfg.BuildAgentForRescue
5044	if builder == nil {
5045		builder = i.cfg.BuildAgentFor
5046	}
5047	i.swapModel(prov, model, builder, true)
5048}
5049
5050// swapModel applies a /model selection (or a rescue selection) using
5051// the supplied builder. rescue=true tags the success message so the
5052// user can see that launch-time overrides were ignored.
5053func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
5054	if model == "" {
5055		return
5056	}
5057	m, err := provider.FindModel(prov, model)
5058	if err != nil {
5059		i.mu.Lock()
5060		i.statusErr = err.Error()
5061		i.mu.Unlock()
5062		return
5063	}
5064	// Same provider AND not a rescue retry: just swap the model on
5065	// the existing agent. Mixed-API providers dispatch from model metadata,
5066	// so the client remains reusable. Rescue retries always rebuild so a stale
5067	// auth header / base URL can't carry over.
5068	if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
5069		i.mu.Lock()
5070		i.cfg.Model = m.ID
5071		i.agent.Model = m.ID
5072		i.statusOK = "model: " + m.ID
5073		i.statusErr = ""
5074		i.mu.Unlock()
5075		if i.cfg.PersistModel != nil {
5076			i.cfg.PersistModel(i.cfg.Provider, m.ID)
5077		}
5078		return
5079	}
5080	if builder == nil {
5081		i.mu.Lock()
5082		i.statusErr = "cannot switch provider: no builder configured"
5083		i.mu.Unlock()
5084		return
5085	}
5086	// Snapshot the current transcript and cumulative usage BEFORE we
5087	// build the replacement agent so we can hand them off. Without
5088	// this the user perceives the entire session as wiped on a
5089	// cross-provider /model swap.
5090	var carryMsgs []provider.Message
5091	var carryCost provider.Usage
5092	if i.agent != nil {
5093		carryMsgs = i.agent.Messages()
5094		carryCost = i.agent.Cost()
5095	}
5096
5097	ag, p, md, err := builder(m.Provider, m.ID)
5098	if err != nil {
5099		i.mu.Lock()
5100		i.statusErr = err.Error()
5101		i.mu.Unlock()
5102		return
5103	}
5104
5105	// Replay the transcript and seed the cost on the freshly-built
5106	// agent. Messages travel cleanly between providers because they
5107	// use the same provider.Message shape; tool-call ids are local
5108	// to a turn so cross-provider continuation never confuses the
5109	// new model (it just sees the assistant's reply, no orphan
5110	// tool_use blocks because /model swaps are gated to idle state).
5111	if len(carryMsgs) > 0 {
5112		ag.SetMessages(carryMsgs)
5113	}
5114	ag.SeedCost(carryCost)
5115
5116	i.mu.Lock()
5117	i.agent = ag
5118	i.cfg.Provider = p
5119	i.cfg.Model = md
5120	if rescue {
5121		i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
5122	} else {
5123		i.statusOK = "switched to " + p + " / " + md
5124	}
5125	i.statusErr = ""
5126	// Render cache keys are width+content based, so the new agent's
5127	// identical messages will reuse the existing entries. Nothing
5128	// to invalidate.
5129	i.mu.Unlock()
5130	// The new agent was built off the base tool registry, so any
5131	// dynamically-registered tools need to be reattached. The apply
5132	// helpers are no-ops when their feature is inactive, so the
5133	// cross-provider path still works on a vanilla setup.
5134	i.applyAutoSwarmTool(i.autoSwarmEnabled())
5135	i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5136	if i.cfg.PersistModel != nil {
5137		i.cfg.PersistModel(p, md)
5138	}
5139}
5140
5141func (i *Interactive) handleAuthEvent(ev auth.Event) {
5142	switch ev.Kind {
5143	case "started":
5144		i.dialog.ShowWaiting(ev.URL)
5145	case "browser_open":
5146		// no-op
5147	case "error":
5148		i.dialog.ShowResult(false, ev.Message)
5149	case "success":
5150		// Rebuild the agent with the fresh credential.
5151		ag, prov, model, err := i.cfg.BuildAgent()
5152		if err != nil {
5153			i.dialog.ShowResult(false, err.Error())
5154			return
5155		}
5156		i.mu.Lock()
5157		i.agent = ag
5158		i.cfg.Provider = prov
5159		i.cfg.Model = model
5160		i.statusErr = ""
5161		i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
5162		i.mu.Unlock()
5163		i.applyAutoSwarmTool(i.autoSwarmEnabled())
5164		i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5165		i.dialog.ShowResult(true, "")
5166	}
5167}
5168
5169// clearPendingCompactTurnLocked drops work that only makes sense after the
5170// current compaction. The caller must hold i.mu.
5171func (i *Interactive) clearPendingCompactTurnLocked() {
5172	i.pendingCompactPrompt = ""
5173	i.pendingCompactImages = nil
5174	i.hasPendingCompactPrompt = false
5175	i.continueAfterCompact = false
5176	i.pendingPostCompactNote = ""
5177}
5178
5179// runCompact invokes core.Agent.Compact and reflects the progress in
5180// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
5181// cancel via the same cancelTurn channel used for normal turns.
5182//
5183// When auto is true the spinner message is pinned to "condensing
5184// history" and the status bar surfaces "(auto)" next to the context
5185// percentage so it's obvious the system triggered this, not the user.
5186func (i *Interactive) runCompact(parent context.Context, auto bool) {
5187	if i.agent == nil {
5188		i.mu.Lock()
5189		i.statusErr = "not logged in. type /login first."
5190		i.mu.Unlock()
5191		return
5192	}
5193	ctx, cancel := context.WithCancel(parent)
5194	i.mu.Lock()
5195	i.busy = true
5196	i.compacting = true
5197	if auto {
5198		i.spin.StartFixed("condensing history")
5199		i.autoCompacting = true
5200	} else {
5201		i.spin.StartFixed("compacting")
5202	}
5203	i.cancelTurn = cancel
5204	i.statusErr = ""
5205	i.statusOK = ""
5206	// Do NOT set streamOn: the summary text should not be visible
5207	// in the chat while compacting. The user just sees the spinner
5208	// and can keep typing / queue prompts.
5209	i.scrollOffset = 0
5210	i.helpBlock = nil
5211	i.mu.Unlock()
5212	i.invalidate()
5213
5214	go func() {
5215		// Sink discards deltas — we don't stream the summary to the UI.
5216		sink := func(delta string) {}
5217		summary, err := i.agent.Compact(ctx, 4, sink)
5218		_ = summary
5219		i.mu.Lock()
5220		i.busy = false
5221		i.compacting = false
5222		i.resetStreamingStateLocked()
5223		i.cancelTurn = nil
5224		i.autoCompacting = false
5225
5226		// Drain pending work after the transcript is clean. Overflow recovery
5227		// continues the user message already in the transcript. Pre-turn
5228		// compaction preserves its complete text-and-images request. Regular
5229		// prompts typed during compaction remain in the host queue.
5230		var next string
5231		var nextImages []provider.ImageBlock
5232		var hasNext bool
5233		var continueExisting bool
5234
5235		switch {
5236		case err != nil && ctx.Err() != nil:
5237			i.statusErr = ""
5238			if auto {
5239				i.statusOK = "auto-condense cancelled"
5240			} else {
5241				i.statusOK = "compaction cancelled"
5242			}
5243			i.queued = nil // drop queue on cancel
5244			i.clearPendingCompactTurnLocked()
5245			if i.agent != nil {
5246				i.agent.DrainQueuedMessages()
5247			}
5248		case err != nil:
5249			i.statusErr = "compaction failed: " + err.Error()
5250			i.statusOK = ""
5251			i.queued = nil // drop queue on error
5252			i.clearPendingCompactTurnLocked()
5253			if i.agent != nil {
5254				i.agent.DrainQueuedMessages()
5255			}
5256		default:
5257			i.statusErr = ""
5258			// Read token count from the compaction message meta.
5259			tokens := ""
5260			msgs := i.agent.Messages()
5261			if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
5262				tokens = msgs[0].Meta["tokens_before"]
5263			}
5264			switch {
5265			case i.pendingPostCompactNote != "":
5266				i.statusOK = i.pendingPostCompactNote
5267			case tokens != "":
5268				i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
5269			default:
5270				i.statusOK = "compacted (ctrl+o to expand)"
5271			}
5272			i.pendingPostCompactNote = ""
5273			i.extNotes = stripAutoCompactNotes(i.extNotes)
5274			i.lastCtxInput = 0
5275			i.toolCalls = map[string]*tui.ToolCallView{}
5276			i.toolOrder = nil
5277			i.toolGate = map[string]int{}
5278			i.view.InvalidateRenderCache()
5279			switch {
5280			case i.continueAfterCompact:
5281				continueExisting = true
5282				i.continueAfterCompact = false
5283			case i.hasPendingCompactPrompt:
5284				next = i.pendingCompactPrompt
5285				nextImages = i.pendingCompactImages
5286				i.pendingCompactPrompt = ""
5287				i.pendingCompactImages = nil
5288				i.hasPendingCompactPrompt = false
5289				hasNext = true
5290			case len(i.queued) > 0:
5291				next, i.queued = i.queued[0], i.queued[1:]
5292				hasNext = true
5293			}
5294		}
5295		i.mu.Unlock()
5296		i.invalidate()
5297
5298		if hasNext || continueExisting {
5299			p := i.runCtx
5300			if p == nil {
5301				p = context.Background()
5302			}
5303			if continueExisting {
5304				i.startTurnRequest(p, "", nil, true)
5305			} else {
5306				i.startTurnWithImages(p, next, nextImages)
5307			}
5308		}
5309	}()
5310}
5311
5312// shellEscapeCommand reports whether text is a "!command" shell
5313// escape and, if so, returns the command with the leading '!' (and
5314// surrounding whitespace) stripped. A bare "!" with no command is
5315// treated as not an escape so it falls through to the normal prompt
5316// path rather than running an empty shell.
5317func shellEscapeCommand(text string) (string, bool) {
5318	trimmed := strings.TrimLeft(text, " \t")
5319	if !strings.HasPrefix(trimmed, "!") {
5320		return "", false
5321	}
5322	cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
5323	if cmd == "" {
5324		return "", false
5325	}
5326	return cmd, true
5327}
5328
5329// startShellEscape runs a "!command" in the same shell the bash tool
5330// uses, in the session working directory, honoring the /jail sandbox.
5331// It shares the busy/cancel state with the agent: esc cancels it, and
5332// it refuses to start while a turn or another shell escape is already
5333// in flight. Its terminal-log output is appended to the transcript as
5334// user context without automatically starting a model turn.
5335func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5336	i.mu.Lock()
5337	if i.busy || i.shellRunning {
5338		i.statusErr = "busy — wait for the current turn to finish before running a shell command"
5339		i.statusOK = ""
5340		i.mu.Unlock()
5341		i.invalidate()
5342		return
5343	}
5344	if parent == nil {
5345		parent = i.runCtx
5346	}
5347	if parent == nil {
5348		parent = context.Background()
5349	}
5350	ctx, cancel := context.WithCancel(parent)
5351	i.busy = true
5352	i.shellRunning = true
5353	i.cancelTurn = cancel
5354	i.statusErr = ""
5355	i.statusOK = ""
5356	i.spin.StartFixed("running shell command")
5357	// Clear stale extension notes the same way a new turn would so the
5358	// screen doesn't accumulate transient state.
5359	i.extNotes = nil
5360	i.scrollOffset = 0
5361	i.parkedTurn = 0
5362	i.parkedTotal = 0
5363	i.helpBlock = nil
5364	sandbox := i.cfg.Sandbox
5365	cwd := i.cfg.CWD
5366	i.mu.Unlock()
5367	i.invalidate()
5368
5369	go func() {
5370		defer cancel()
5371		raw, _ := json.Marshal(map[string]any{"command": cmd})
5372		bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
5373		res, err := bash.Execute(ctx, raw, nil)
5374
5375		var out string
5376		if err != nil {
5377			out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
5378		} else {
5379			for _, c := range res.Content {
5380				if tb, ok := c.(provider.TextBlock); ok {
5381					out += tb.Text
5382				}
5383			}
5384		}
5385		cancelled := ctx.Err() != nil
5386		failed := err != nil || res.IsError || cancelled
5387		if cancelled {
5388			out += "\n\n[cancelled]"
5389		}
5390
5391		i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
5392
5393		i.mu.Lock()
5394		i.shellRunning = false
5395		i.busy = false
5396		i.cancelTurn = nil
5397		if failed {
5398			if cancelled {
5399				i.statusErr = "shell command cancelled"
5400			} else {
5401				i.statusErr = "shell command failed"
5402			}
5403			i.statusOK = ""
5404		} else {
5405			i.statusOK = "shell command finished"
5406			i.statusErr = ""
5407		}
5408		i.mu.Unlock()
5409		i.invalidate()
5410	}()
5411}
5412
5413func (i *Interactive) startTurn(parent context.Context, prompt string) {
5414	i.startTurnWithImages(parent, prompt, nil)
5415}
5416
5417func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
5418	i.startTurnRequest(parent, prompt, images, false)
5419}
5420
5421func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
5422	if i.agent == nil {
5423		return
5424	}
5425	// Pre-turn safety: if the most recent context measurement is
5426	// already past the auto-compact threshold, condense before
5427	// sending so the next outbound request stays under the limit.
5428	// The condense flow re-fires the user's queued prompt for us, so
5429	// we just hand it off and exit.
5430	i.mu.Lock()
5431	needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
5432	if needsPreCompact {
5433		i.pendingCompactPrompt = prompt
5434		i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
5435		i.hasPendingCompactPrompt = true
5436		i.statusErr = ""
5437		i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
5438		i.pendingPostCompactNote = "context auto-compacted; sending your last message"
5439		i.mu.Unlock()
5440		i.invalidate()
5441		i.runCompact(parent, true)
5442		return
5443	}
5444	i.mu.Unlock()
5445
5446	ctx, cancel := context.WithCancel(parent)
5447	i.mu.Lock()
5448	i.busy = true
5449	i.spin.Start()
5450	i.cancelTurn = cancel
5451	i.statusErr = ""
5452	i.statusOK = ""
5453	i.streaming.Reset()
5454	i.streamOn = true
5455	i.toolCalls = map[string]*tui.ToolCallView{}
5456	i.toolOrder = nil
5457	i.toolGate = map[string]int{}
5458	i.extNotes = nil   // ext notes are one-shot; a new prompt clears them
5459	i.scrollOffset = 0 // jump back to the bottom on new turn
5460	// Lift the resume tail cap once the user starts interacting. The
5461	// cap is purely a first-paint optimization (don't markdown the
5462	// whole history before showing anything). Keeping it active during
5463	// a turn makes the rendered chat a sliding window: appended
5464	// messages push older ones off the TOP of the buffer, which the
5465	// renderer must treat as a change above the viewport and repaint
5466	// fully, snapping the terminal's native scrollback to the bottom on
5467	// every streamed chunk. A fresh session has no cap (append-only),
5468	// which is why the jump only shows up in resumed sessions. Dropping
5469	// the cap here makes resumed turns append-only too.
5470	i.view.TailLimit = 0
5471	// Reset the auto-follow baseline so the very next render at
5472	// interactive.go:1053 doesn't see a synthetic shrink between
5473	// "last frame had the previous turn's tool overlay" and
5474	// "this frame had it cleared above". Without this, the guard
5475	// reads delta = -(rows in cleared overlay) and decrements
5476	// scrollOffset, which on terminals that mirror zot's pane
5477	// scroll into the host scrollbar visibly yanks the viewport.
5478	// See autofollow_shrink_test.go for the exact arithmetic.
5479	i.prevChatLen = 0
5480	i.prevChatCols = 0
5481	i.parkedTurn = 0 // starting a turn clears the /jump parked state
5482	i.parkedTotal = 0
5483	i.helpBlock = nil // hide the help block once the user asks something
5484	i.mu.Unlock()
5485	i.invalidate()
5486
5487	sink := func(ev core.AgentEvent) {
5488		i.handleEvent(ev)
5489		i.invalidate()
5490	}
5491
5492	go func() {
5493		var err error
5494		if overflowRecoveryAttempted {
5495			err = i.agent.Continue(ctx, sink)
5496		} else {
5497			err = i.agent.Prompt(ctx, prompt, images, sink)
5498		}
5499		i.mu.Lock()
5500		i.busy = false
5501		// Don't touch streamPending / streamFlushPending here — the
5502		// pacer may still be draining the final deltas and needs to
5503		// paint them even though Prompt has returned. It will reset
5504		// streamOn on its own once the buffer empties.
5505		if len(i.streamPending) == 0 {
5506			i.streamOn = false
5507		}
5508		i.cancelTurn = nil
5509		if err != nil && ctx.Err() == nil {
5510			i.statusErr = err.Error()
5511		}
5512		// Decide whether to offer a model rescue picker for recoverable
5513		// provider failures (auth/rate/temporary). The picker opens after
5514		// the mutex is released so it can take its own locks freely.
5515		var (
5516			offer       bool
5517			rescueWhy   string
5518			rescueImgs  []provider.ImageBlock
5519			rescueModel string
5520			rescueProv  string
5521			rescueFprov string
5522		)
5523		if err != nil && ctx.Err() == nil {
5524			if ok, reason := classifyRescueError(err); ok {
5525				offer = true
5526				rescueWhy = reason
5527				rescueImgs = images
5528				rescueModel = i.cfg.Model
5529				rescueProv = i.cfg.Provider
5530				rescueFprov = extractFailedProvider(err)
5531				if rescueFprov == "" {
5532					rescueFprov = i.cfg.Provider
5533				}
5534				// Suppress the red banner — the rescue dialog already
5535				// surfaces the failure.
5536				i.statusErr = ""
5537			}
5538		}
5539		// Detect responses that reject the current context, either as an
5540		// HTTP 413 payload limit or a model context-window error. Token-
5541		// based auto-compact can miss both when metadata is stale or the
5542		// limit is measured in raw bytes. Compact once, then continue the
5543		// user message already present in the transcript.
5544		contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)
5545		recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
5546		if recoverContextOverflow {
5547			i.statusErr = ""
5548			i.continueAfterCompact = true
5549			i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
5550			i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
5551		}
5552		// Persist the assistant's reply (and every tool row before
5553		// it) to the session file while the turn memory is hot.
5554		// Without this, WriteNewTranscript only fires at zot exit,
5555		// meaning a crash or ungraceful kill drops the whole
5556		// conversation. FlushSession is idempotent (it advances the
5557		// baseline so subsequent flushes only write new rows).
5558		flush := i.cfg.FlushSession
5559		i.mu.Unlock()
5560		if flush != nil {
5561			flush()
5562		}
5563		i.mu.Lock()
5564		// Pop the next queued message, if any, and relaunch.
5565		var next string
5566		var hasNext bool
5567		if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
5568			next, i.queued = i.queued[0], i.queued[1:]
5569			hasNext = true
5570		}
5571		// If the turn was cancelled or errored, drop the queue so the
5572		// user isn't bombarded with stale messages after an interrupt.
5573		if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
5574			i.queued = nil
5575			if i.agent != nil {
5576				i.agent.DrainQueuedMessages()
5577			}
5578		}
5579		// Decide whether the next thing to do is an auto-compaction.
5580		// Only fires when the turn completed cleanly AND no host-side
5581		// or agent-side queued messages are waiting (otherwise a queued
5582		// message would race the condense).
5583		agentQueued := 0
5584		if i.agent != nil {
5585			agentQueued = i.agent.QueuedMessageCount()
5586		}
5587		shouldAutoCompact := !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
5588		i.mu.Unlock()
5589		i.invalidate()
5590		parent := i.runCtx
5591		if parent == nil {
5592			parent = context.Background()
5593		}
5594		switch {
5595		case hasNext:
5596			i.startTurn(parent, next)
5597		case offer:
5598			i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
5599		case recoverContextOverflow:
5600			i.runCompact(parent, true)
5601		case shouldAutoCompact:
5602			i.runCompact(parent, true)
5603		}
5604	}()
5605}
5606
5607// openRescueDialog surfaces the rescue model picker after a recoverable
5608// provider failure. The pending prompt + images are stashed on the
5609// Interactive so a later applyRescueSelection can re-run the same turn
5610// against the freshly-picked model. activeProvider/failedProvider are
5611// usually the same, but some clients embed different prefixes in their
5612// errors than the configured provider id, so we accept both.
5613func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
5614	if i.rescueDialog == nil {
5615		return
5616	}
5617	loggedIn := []string{}
5618	if i.cfg.LoggedInProviders != nil {
5619		loggedIn = i.cfg.LoggedInProviders()
5620	}
5621	fprov := failedProvider
5622	if fprov == "" {
5623		fprov = activeProvider
5624	}
5625	i.mu.Lock()
5626	i.pendingRescuePrompt = prompt
5627	i.pendingRescueImages = images
5628	i.mu.Unlock()
5629	i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
5630	i.invalidate()
5631}
5632
5633// applyRescueSelection switches model (cross-provider if needed) and
5634// re-runs the same prompt+images that just failed. Mirrors
5635// applyModelSelection's transcript-carry logic so the user keeps full
5636// session continuity across the swap.
5637func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
5638	if model == "" {
5639		return
5640	}
5641	i.applyRescueModelSelection(prov, model)
5642	i.mu.Lock()
5643	images := i.pendingRescueImages
5644	if prompt == "" {
5645		prompt = i.pendingRescuePrompt
5646	}
5647	i.pendingRescuePrompt = ""
5648	i.pendingRescueImages = nil
5649	i.mu.Unlock()
5650	parent := i.runCtx
5651	if parent == nil {
5652		parent = context.Background()
5653	}
5654	i.startTurnWithImages(parent, prompt, images)
5655}
5656
5657func stripAutoCompactNotes(notes []string) []string {
5658	if len(notes) == 0 {
5659		return notes
5660	}
5661	out := notes[:0]
5662	for _, n := range notes {
5663		if strings.Contains(n, "condensing history") {
5664			continue
5665		}
5666		out = append(out, n)
5667	}
5668	return out
5669}
5670
5671// autoCompactNoteLine returns a styled chat-area note for the
5672// inline auto-compact heads-up. Lives in extNotes so it survives
5673// the busy-spinner overwrite of the status row.
5674func autoCompactNoteLine(th tui.Theme, msg string) string {
5675	return "  " + th.FG256(th.Warning, "⚠ "+msg)
5676}
5677
5678// isContextOverflowError matches provider responses that reject the
5679// current request because its raw payload or tokenized input is too
5680// large. Providers surface this as either HTTP 413 or semantic context
5681// errors with differing codes and messages.
5682func isContextOverflowError(err error) bool {
5683	if err == nil {
5684		return false
5685	}
5686	msg := strings.ToLower(err.Error())
5687	return strings.Contains(msg, "http 413") ||
5688		strings.Contains(msg, " 413") ||
5689		strings.HasPrefix(msg, "413 ") ||
5690		strings.Contains(msg, "payload too large") ||
5691		strings.Contains(msg, "request entity too large") ||
5692		strings.Contains(msg, "input exceeds the context window") ||
5693		strings.Contains(msg, "context window exceeded") ||
5694		strings.Contains(msg, "maximum context length") ||
5695		strings.Contains(msg, "context_length_exceeded") ||
5696		(strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
5697}
5698
5699const defaultAutoCompactThreshold = 85
5700
5701func normalizeAutoCompactThreshold(threshold *int) int {
5702	if threshold == nil {
5703		return defaultAutoCompactThreshold
5704	}
5705	switch *threshold {
5706	case 0, 70, 80, 85, 90:
5707		return *threshold
5708	default:
5709		return defaultAutoCompactThreshold
5710	}
5711}
5712
5713func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
5714	if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
5715		return false
5716	}
5717	return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
5718}
5719
5720// shouldAutoCompactLocked reports whether the last turn pushed context
5721// usage past the auto-compact threshold. Must be called with i.mu
5722// held; it reads lastCtxInput and the current model's context window.
5723func (i *Interactive) shouldAutoCompactLocked() bool {
5724	if i.agent == nil {
5725		return false
5726	}
5727	if i.autoCompacting {
5728		return false
5729	}
5730	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
5731	if err != nil || m.ContextWindow <= 0 {
5732		return false
5733	}
5734	threshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
5735	return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
5736}
5737
5738func (i *Interactive) handleEvent(ev core.AgentEvent) {
5739	i.mu.Lock()
5740	defer i.mu.Unlock()
5741	switch e := ev.(type) {
5742	case core.EvAssistantStart:
5743		// Fires at the top of every oneTurn, including follow-up
5744		// turns after tool use. Without this, the streaming buffer
5745		// is still marked off from the previous assistant message
5746		// and the final summary text pops in all at once instead
5747		// of typewriter-streaming delta by delta.
5748		i.streaming.Reset()
5749		i.streamPending = i.streamPending[:0]
5750		i.streamFlushPending = false
5751		i.streamOn = true
5752		// Clear the live tool-call overlay. Any tools from the
5753		// previous round are now fully folded into the transcript
5754		// (assistant tool_use block + tool role message with the
5755		// result), so keeping them in the overlay would duplicate
5756		// them in the view — once inside the finalised transcript
5757		// and once below the streaming block, with the streaming
5758		// summary sandwiched in between. The next EvToolUseStart
5759		// will populate fresh entries for this turn's tools.
5760		i.toolCalls = map[string]*tui.ToolCallView{}
5761		i.toolOrder = nil
5762		i.toolGate = map[string]int{}
5763	case core.EvTextDelta:
5764		// Buffer into streamPending; the paintPace ticker drains
5765		// it into i.streaming a few runes at a time for a smooth
5766		// typewriter effect independent of upstream chunk size.
5767		i.streamPending = append(i.streamPending, []rune(e.Delta)...)
5768		i.streamOn = true
5769	case core.EvAssistantMessage:
5770		// OnAssistant + telegram mirroring always fire on message
5771		// arrival — they read the FINAL message content, which is
5772		// complete regardless of what's still in the pacer.
5773		i.assistantMessageSideEffects(e.Message)
5774		// If the pacer still has characters to drain, keep streamOn
5775		// true and mark flush pending; the paintPace ticker will
5776		// drain the remainder and reset streaming state when done.
5777		// Otherwise (rare: full-replay sessions, abort paths) clear
5778		// synchronously so a later render doesn't show stale text.
5779		if len(i.streamPending) > 0 {
5780			i.streamFlushPending = true
5781			return
5782		}
5783		i.resetStreamingStateLocked()
5784	case core.EvToolUseStart:
5785		// Live streaming: pre-create the view so the user sees the
5786		// tool call being composed in real time. Any subsequent
5787		// EvToolCall for the same ID updates the same struct (the
5788		// final parsed args + name are already known here).
5789		if _, exists := i.toolCalls[e.ID]; !exists {
5790			i.toolCalls[e.ID] = &tui.ToolCallView{
5791				ID:        e.ID,
5792				Name:      e.Name,
5793				Streaming: true,
5794			}
5795			i.toolOrder = append(i.toolOrder, e.ID)
5796			i.gateToolLocked(e.ID)
5797		}
5798	case core.EvToolUseArgs:
5799		if tc, ok := i.toolCalls[e.ID]; ok {
5800			tc.RawJSONBuf += e.Delta
5801			// Refresh the live path as soon as it parses; used in
5802			// the header (write /Users/pat/Desktop/demo.ts)
5803			// while the content is still streaming.
5804			if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
5805				tc.LivePath = p
5806			} else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
5807				tc.LivePath = p
5808			}
5809		}
5810	case core.EvToolUseEnd:
5811		if tc, ok := i.toolCalls[e.ID]; ok {
5812			tc.Streaming = false
5813		}
5814	case core.EvToolCall:
5815		// If we already pre-created the view during streaming, just
5816		// refresh the final Args summary. Otherwise create a new one
5817		// (non-streaming providers or legacy paths).
5818		if tc, ok := i.toolCalls[e.ID]; ok {
5819			tc.Args = tui.ShortArgs(e.Name, e.Args)
5820			if tc.RawJSONBuf == "" {
5821				tc.RawJSONBuf = string(e.Args)
5822			}
5823			tc.Streaming = false
5824		} else {
5825			i.toolCalls[e.ID] = &tui.ToolCallView{
5826				ID:         e.ID,
5827				Name:       e.Name,
5828				Args:       tui.ShortArgs(e.Name, e.Args),
5829				RawJSONBuf: string(e.Args),
5830			}
5831			i.toolOrder = append(i.toolOrder, e.ID)
5832			i.gateToolLocked(e.ID)
5833		}
5834	case core.EvToolResult:
5835		if tc, ok := i.toolCalls[e.ID]; ok {
5836			tc.Done = true
5837			tc.Error = e.Result.IsError
5838			tc.Preview = ""
5839			var text strings.Builder
5840			for _, c := range e.Result.Content {
5841				if tb, ok := c.(provider.TextBlock); ok {
5842					if text.Len() > 0 {
5843						text.WriteString("\n")
5844					}
5845					text.WriteString(tb.Text)
5846				}
5847			}
5848			tc.Result = text.String()
5849		}
5850		if i.cfg.OnToolResult != nil {
5851			i.cfg.OnToolResult(e.ID, e.Result)
5852		}
5853	case core.EvUsage:
5854		i.cumUsage = e.Cumulative
5855		if e.Usage.InputTokens > 0 {
5856			i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
5857		}
5858	case core.EvTurnEnd:
5859		if e.Stop == provider.StopAborted {
5860			i.resetStreamingStateLocked()
5861			i.statusErr = ""
5862			i.statusOK = "cancelled"
5863			return
5864		}
5865		if e.Stop == provider.StopLength {
5866			// The model hit its output-token cap mid-response, so the
5867			// reply (often a long write/edit) is truncated. Surface it
5868			// explicitly, otherwise the turn just ends and reads like
5869			// the UI gave up. The agent already requests the model's
5870			// full MaxOutput budget, so this means the response genuinely
5871			// exceeded that ceiling; ask the user to continue.
5872			i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
5873			i.statusOK = ""
5874			return
5875		}
5876		// Don't surface mid-loop stream errors as a red banner here.
5877		// EvTurnEnd fires after every step in a multi-step tool loop,
5878		// so a transient 503 / network blip would briefly paint a red
5879		// banner over the still-streaming chat before the agent loop
5880		// either retries or exits. The final error (if any) is set by
5881		// startTurnWithImages once Prompt() returns, and recoverable
5882		// failures are routed to the rescue picker instead — which
5883		// keeps the chat clean while the agent is working.
5884		_ = e.Err
5885	}
5886}
5887
5888// Agent returns the current agent, if any. Used by cli.go to flush the
5889// final transcript to the session file.
5890func (i *Interactive) Agent() *core.Agent {
5891	i.mu.Lock()
5892	defer i.mu.Unlock()
5893	return i.agent
5894}
5895
5896// silence unused import in some build configs
5897var _ = fmt.Sprintf
5898
5899// runReloadExt triggers a live reload of every extension (discovered
5900// + explicit). Runs on a goroutine so the TUI stays responsive; the
5901// Manager.Reload takes a couple of hundred ms to shut down subprocs
5902// and respawn them. Shows a temporary status line throughout.
5903func (i *Interactive) runReloadExt(ctx context.Context) {
5904	if i.cfg.Extensions == nil {
5905		i.mu.Lock()
5906		i.statusErr = "no extension manager in this build"
5907		i.mu.Unlock()
5908		i.invalidate()
5909		return
5910	}
5911	i.mu.Lock()
5912	i.statusOK = "reloading extensions..."
5913	i.statusErr = ""
5914	i.mu.Unlock()
5915	i.invalidate()
5916
5917	go func() {
5918		stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
5919		msg, failed := formatReloadStatus(stats)
5920		seq := i.setReloadStatus(msg, failed)
5921		i.invalidate()
5922		go i.dismissReloadStatus(ctx, seq, msg, failed)
5923	}()
5924}
5925
5926// Confirm implements core.Confirmer. The agent goroutine calls
5927// this synchronously before every tool invocation when --no-yolo is
5928// active. We push the request onto the confirmDialog queue, trigger
5929// a redraw, and block the caller until the user answers.
5930//
5931// If the session is cancelled or the TUI exits mid-prompt, any
5932// pending request is refused via CancelAll so the agent doesn't
5933// deadlock.
5934func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
5935	return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
5936}
5937
5938// ConfirmToolCall attaches a side-effect-free preview to the matching live
5939// tool panel, then blocks until the user approves or refuses the call.
5940func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
5941	resp := make(chan core.ConfirmDecision, 1)
5942	returnToChild := false
5943	if call.ID != "" {
5944		i.mu.Lock()
5945		if tc, ok := i.toolCalls[call.ID]; ok {
5946			tc.Args = call.Summary
5947			tc.Preview = call.Content
5948		}
5949		i.mu.Unlock()
5950		if i.btwDialog != nil && i.btwDialog.Active() {
5951			returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content)
5952		}
5953	}
5954	i.confirmDialog.Enqueue(&confirmRequest{
5955		toolName:      call.Name,
5956		preview:       call.Summary,
5957		resp:          resp,
5958		returnToChild: returnToChild,
5959	})
5960	i.invalidate()
5961	return <-resp
5962}
5963
5964// openTelegramDialog shows the picker for `/telegram` with no arg.
5965// Items depend on current state: disconnect + status when running,
5966// connect + status when stopped.
5967func (i *Interactive) openTelegramDialog() {
5968	items := i.telegramMenuItems()
5969	if len(items) == 0 {
5970		i.mu.Lock()
5971		i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
5972		i.mu.Unlock()
5973		i.invalidate()
5974		return
5975	}
5976	i.telegramDialog.Open(items)
5977	i.invalidate()
5978}
5979
5980// telegramMenuItems builds the dialog entries for the current
5981// bridge state. Returns empty when no bot.json exists so the
5982// caller can show a helpful status line instead of an empty menu.
5983func (i *Interactive) telegramMenuItems() []telegramItem {
5984	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
5985	if err != nil || cfg.BotToken == "" {
5986		return nil
5987	}
5988	var items []telegramItem
5989	if i.telegramBridge != nil && i.telegramBridge.Active() {
5990		items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
5991		st := i.telegramBridge.State()
5992		hint := "active"
5993		if st.Username != "" {
5994			hint += " as @" + st.Username
5995		}
5996		items = append(items, telegramItem{label: "status", action: "status", hint: hint})
5997	} else {
5998		label := "connect"
5999		hint := "start mirroring dms into this session"
6000		if cfg.AllowedUserID == 0 {
6001			hint = "awaiting pairing (send /start to the bot once connected)"
6002		}
6003		items = append(items, telegramItem{label: label, action: "connect", hint: hint})
6004		items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
6005	}
6006	return items
6007}
6008
6009// doTelegram dispatches one of the three explicit actions. Called
6010// from /telegram <action> or after the picker selects a row.
6011func (i *Interactive) doTelegram(action string) {
6012	switch action {
6013	case "connect":
6014		i.telegramConnect()
6015	case "disconnect":
6016		i.telegramDisconnect()
6017	case "status":
6018		i.telegramStatus()
6019	default:
6020		i.mu.Lock()
6021		i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
6022		i.mu.Unlock()
6023		i.invalidate()
6024	}
6025}
6026
6027// telegramConnect starts the bridge. Refuses if it's already
6028// running or if the on-disk bot.json is missing a token.
6029func (i *Interactive) telegramConnect() {
6030	if i.telegramBridge != nil && i.telegramBridge.Active() {
6031		i.mu.Lock()
6032		i.statusOK = "telegram already connected"
6033		i.statusErr = ""
6034		i.mu.Unlock()
6035		i.invalidate()
6036		return
6037	}
6038	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
6039	if err != nil {
6040		i.mu.Lock()
6041		i.statusErr = "telegram: " + err.Error()
6042		i.mu.Unlock()
6043		i.invalidate()
6044		return
6045	}
6046	if cfg.BotToken == "" {
6047		i.mu.Lock()
6048		i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
6049		i.mu.Unlock()
6050		i.invalidate()
6051		return
6052	}
6053	// Refuse to start when a background daemon is already polling
6054	// the same bot. Two concurrent long-poll consumers race each
6055	// update and one always loses, so DMs get half-delivered. The
6056	// user can `zot telegram-bot stop` first, then /telegram connect.
6057	if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
6058		i.mu.Lock()
6059		i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
6060		i.mu.Unlock()
6061		i.invalidate()
6062		return
6063	}
6064	i.telegramBridge = &telegram.Bridge{
6065		Client: telegram.NewClient(cfg.BotToken),
6066		Config: cfg,
6067		Save: func(next telegram.Config) error {
6068			return telegram.SaveConfig(i.cfg.ZotHome, next)
6069		},
6070		Host: &telegramHost{iv: i},
6071	}
6072	if err := i.telegramBridge.Start(i.runCtx); err != nil {
6073		i.telegramBridge = nil
6074		i.mu.Lock()
6075		i.statusErr = "telegram connect failed: " + err.Error()
6076		i.mu.Unlock()
6077		i.invalidate()
6078		return
6079	}
6080	i.applyTelegramTools(true)
6081	state := i.telegramBridge.State()
6082	label := "telegram connected"
6083	if state.Username != "" {
6084		label += " as @" + state.Username
6085	}
6086	if state.PairedID == 0 {
6087		label += " — send /start to the bot from your phone to claim it"
6088	}
6089	i.mu.Lock()
6090	i.statusOK = label
6091	i.statusErr = ""
6092	i.mu.Unlock()
6093	i.invalidate()
6094}
6095
6096// telegramDisconnect stops the bridge. No-op when already stopped.
6097func (i *Interactive) telegramDisconnect() {
6098	if i.telegramBridge == nil || !i.telegramBridge.Active() {
6099		i.mu.Lock()
6100		i.statusOK = "telegram already disconnected"
6101		i.statusErr = ""
6102		i.mu.Unlock()
6103		i.invalidate()
6104		return
6105	}
6106	i.telegramBridge.Stop()
6107	i.applyTelegramTools(false)
6108	i.mu.Lock()
6109	i.statusOK = "telegram disconnected"
6110	i.statusErr = ""
6111	i.mu.Unlock()
6112	i.invalidate()
6113}
6114
6115// telegramSenderAdapter wraps the bridge so the tools package can
6116// drive it without importing telegram directly. The Active() check
6117// is forwarded to the bridge so the tool can fail clearly with a
6118// model-readable error when the user disconnected mid-turn.
6119type telegramSenderAdapter struct {
6120	bridge *telegram.Bridge
6121}
6122
6123func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
6124	if a.bridge == nil {
6125		return fmt.Errorf("telegram bridge is not connected")
6126	}
6127	return a.bridge.SendImage(ctx, path, caption)
6128}
6129
6130func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
6131	if a.bridge == nil {
6132		return fmt.Errorf("telegram bridge is not connected")
6133	}
6134	return a.bridge.SendDocument(ctx, path, caption)
6135}
6136
6137func (a telegramSenderAdapter) Active() bool {
6138	return a.bridge != nil && a.bridge.Active()
6139}
6140
6141// swarmWatchEntry is one tracked auto-swarm sub-agent. It records the
6142// delegated turn's outcome separately from the long-lived daemon status.
6143type swarmWatchEntry struct {
6144	agent   *swarm.Agent
6145	task    string
6146	done    bool
6147	outcome string
6148	err     string
6149}
6150
6151// TrackSwarmAgent is the exported entry point used by the cli to
6152// hand a freshly-spawned auto-swarm agent off to the watcher.
6153func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) {
6154	i.trackSwarmAgent(a, task)
6155}
6156
6157// trackSwarmAgent records a freshly-spawned auto-swarm agent and
6158// subscribes to both ways its delegated task can finish. Successful
6159// long-lived agents report a prompt-level turn_end and keep listening;
6160// startup failures and unexpected daemon exits only unblock Agent.Wait.
6161// Whichever signal arrives first completes the entry exactly once.
6162//
6163// Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto-
6164// swarm is enabled, so this is a no-op when the feature is off.
6165func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) {
6166	if i == nil || a == nil {
6167		return
6168	}
6169	entry := &swarmWatchEntry{agent: a, task: task}
6170	i.swarmWatchMu.Lock()
6171	i.swarmWatch = append(i.swarmWatch, entry)
6172	i.swarmWatchMu.Unlock()
6173
6174	a.SetOnTurnEnd(func(step int, errMsg string) {
6175		outcome := "completed"
6176		if errMsg != "" {
6177			outcome = "failed"
6178		}
6179		i.completeSwarmWatchEntry(entry, outcome, errMsg)
6180	})
6181	go func() {
6182		a.Wait()
6183		snap := a.Snapshot()
6184		outcome := string(snap.Status)
6185		if snap.Status == swarm.StatusDone {
6186			outcome = "completed"
6187		}
6188		i.completeSwarmWatchEntry(entry, outcome, snap.Err)
6189	}()
6190}
6191
6192func (i *Interactive) completeSwarmWatchEntry(entry *swarmWatchEntry, outcome, errMsg string) {
6193	i.swarmWatchMu.Lock()
6194	if entry.done {
6195		i.swarmWatchMu.Unlock()
6196		return
6197	}
6198	entry.done = true
6199	entry.outcome = outcome
6200	entry.err = errMsg
6201	allDone := true
6202	for _, e := range i.swarmWatch {
6203		if !e.done {
6204			allDone = false
6205			break
6206		}
6207	}
6208	var batch []*swarmWatchEntry
6209	if allDone {
6210		batch = i.swarmWatch
6211		i.swarmWatch = nil
6212	}
6213	i.swarmWatchMu.Unlock()
6214	if len(batch) != 0 {
6215		i.flushSwarmSummary(batch)
6216	}
6217}
6218
6219// flushSwarmSummary composes a synthetic user turn describing every
6220// sub-agent's outcome and injects it via SubmitOrQueue so the main
6221// agent picks it up at the next safe boundary. The summary is
6222// phrased as a system update ("Auto-swarm finished: ...") so the
6223// model treats it as observed state, not as a fresh user request.
6224func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) {
6225	if len(batch) == 0 {
6226		return
6227	}
6228	var sb strings.Builder
6229	fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch))
6230	for idx, e := range batch {
6231		snap := e.agent.Snapshot()
6232		status := e.outcome
6233		if status == "" {
6234			status = string(snap.Status)
6235		}
6236		task := snap.Task
6237		if task == "" {
6238			task = e.task
6239		}
6240		fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)
6241		fmt.Fprintf(&sb, "   task: %s\n", truncateForSummary(task, 240))
6242		if snap.Err != "" {
6243			fmt.Fprintf(&sb, "   error: %s\n", truncateForSummary(snap.Err, 240))
6244		} else if e.err != "" {
6245			fmt.Fprintf(&sb, "   turn error: %s\n", truncateForSummary(e.err, 240))
6246		}
6247		if tail := strings.TrimSpace(snap.Tail); tail != "" {
6248			fmt.Fprintf(&sb, "   tail: %s\n", truncateForSummary(tail, 600))
6249		}
6250		sb.WriteString("\n")
6251	}
6252	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.")
6253	i.SubmitOrQueue(sb.String(), nil)
6254}
6255
6256func truncateForSummary(s string, n int) string {
6257	s = strings.TrimSpace(s)
6258	if len(s) <= n {
6259		return s
6260	}
6261	return s[:n-3] + "..."
6262}
6263
6264// applyAutoSwarmSystemPrompt appends (active=true) or strips
6265// (active=false) the auto-swarm system-prompt block on the running
6266// agent so the model proactively considers swarm_spawn when the user
6267// flips the toggle. The block lives at the tail of agent.System so
6268// stripping is a plain suffix-trim; idempotent in both directions.
6269func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
6270	if i.agent == nil {
6271		return
6272	}
6273	addendum := i.cfg.AutoSwarmSystemAddendum
6274	if addendum == "" {
6275		return
6276	}
6277	sys := i.agent.System
6278	has := strings.Contains(sys, addendum)
6279	switch {
6280	case active && !has:
6281		if sys != "" && !strings.HasSuffix(sys, "\n\n") {
6282			sys += "\n\n"
6283		}
6284		i.agent.System = sys + addendum
6285	case !active && has:
6286		i.agent.System = strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n"
6287	}
6288}
6289
6290// applyAutoSwarmTool registers (active=true) or removes (active=false)
6291// the swarm_spawn tool on the running agent so the model only sees it
6292// when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'
6293// snapshot+mutate pattern so extension tools and /reload-ext additions
6294// survive a toggle.
6295func (i *Interactive) autoSwarmEnabled() bool {
6296	return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled
6297}
6298
6299func (i *Interactive) applyAutoSwarmTool(active bool) {
6300	if i.agent == nil {
6301		return
6302	}
6303	current := i.agent.Tools
6304	next := core.Registry{}
6305	for name, t := range current {
6306		if name == "swarm_spawn" {
6307			continue
6308		}
6309		next[name] = t
6310	}
6311	if active && i.cfg.Swarm != nil {
6312		next["swarm_spawn"] = &tools.SwarmSpawnTool{
6313			Swarm:           i.cfg.Swarm,
6314			Enabled:         func() bool { return true },
6315			DefaultModel:    func() string { return i.cfg.Model },
6316			DefaultProvider: func() string { return i.cfg.Provider },
6317			OnSpawned:       i.trackSwarmAgent,
6318		}
6319	}
6320	i.agent.SetTools(next)
6321}
6322
6323// applyTelegramTools registers (active=true) or removes (active=false)
6324// the telegram_send_image and telegram_send_file tools on the running
6325// agent so the model only sees them while the bridge is connected.
6326// Snapshots and mutates the live tool registry so any extension or
6327// /reload-ext additions made while Telegram is connected survive a
6328// later /telegram disconnect (we only add or strip the two telegram
6329// entries, never the rest).
6330func (i *Interactive) applyTelegramTools(active bool) {
6331	if i.agent == nil {
6332		return
6333	}
6334	current := i.agent.Tools
6335	next := core.Registry{}
6336	for name, t := range current {
6337		if name == "telegram_send_image" || name == "telegram_send_file" {
6338			continue
6339		}
6340		next[name] = t
6341	}
6342	if active {
6343		sender := telegramSenderAdapter{bridge: i.telegramBridge}
6344		next["telegram_send_image"] = &tools.TelegramSendImageTool{
6345			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
6346		}
6347		next["telegram_send_file"] = &tools.TelegramSendFileTool{
6348			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
6349		}
6350	}
6351	i.agent.SetTools(next)
6352}
6353
6354// telegramStatus writes a one-liner describing the bridge state.
6355// Reports on both the in-tui bridge and the background daemon so
6356// the user isn't confused when the daemon owns the poll loop.
6357func (i *Interactive) telegramStatus() {
6358	var msg string
6359	if i.telegramBridge != nil && i.telegramBridge.Active() {
6360		s := i.telegramBridge.State()
6361		msg = "telegram: connected (tui bridge)"
6362		if s.Username != "" {
6363			msg += " as @" + s.Username
6364		}
6365		if s.PairedID != 0 {
6366			msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
6367		} else {
6368			msg += " - awaiting pairing"
6369		}
6370	} else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
6371		msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
6372	} else {
6373		cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
6374		if cfg.BotToken == "" {
6375			msg = "telegram: not configured. run `zot telegram-bot setup` first."
6376		} else {
6377			msg = "telegram: disconnected"
6378			if cfg.BotUsername != "" {
6379				msg += " (@" + cfg.BotUsername + " ready to connect)"
6380			}
6381		}
6382	}
6383	i.mu.Lock()
6384	i.statusOK = msg
6385	i.statusErr = ""
6386	i.mu.Unlock()
6387	i.invalidate()
6388}
6389
6390// telegramHost adapts *Interactive to telegram.Host so the bridge
6391// can call back into the TUI without importing modes directly.
6392type telegramHost struct{ iv *Interactive }
6393
6394func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
6395	h.iv.SubmitOrQueue(prompt, images)
6396}
6397
6398func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
6399
6400func (h *telegramHost) Status() string {
6401	h.iv.mu.Lock()
6402	providerName := h.iv.cfg.Provider
6403	model := h.iv.cfg.Model
6404	cwd := h.iv.cfg.CWD
6405	usage := h.iv.cumUsage
6406	subscription := h.iv.cfg.AuthMethod == "oauth"
6407	ctxUsed := h.iv.lastCtxInput
6408	busy := h.iv.busy
6409	queued := len(h.iv.queued)
6410	h.iv.mu.Unlock()
6411
6412	ctxMax := 0
6413	if m, err := provider.FindModel(providerName, model); err == nil {
6414		ctxMax = m.ContextWindow
6415	}
6416	return telegram.FormatStatus(telegram.StatusSnapshot{
6417		Provider:     providerName,
6418		Model:        model,
6419		CWD:          cwd,
6420		Usage:        usage,
6421		Subscription: subscription,
6422		ContextUsed:  ctxUsed,
6423		ContextMax:   ctxMax,
6424		Busy:         busy,
6425		Queued:       queued,
6426	})
6427}
6428
6429func (h *telegramHost) Notify(level, message string) {
6430	h.iv.mu.Lock()
6431	switch level {
6432	case "error", "warn":
6433		h.iv.statusErr = message
6434		h.iv.statusOK = ""
6435	default:
6436		h.iv.statusOK = message
6437		h.iv.statusErr = ""
6438	}
6439	h.iv.mu.Unlock()
6440	h.iv.invalidate()
6441}
6442
6443// openSessionOpsDialog shows the picker for `/session` with no arg.
6444// Always offers export, import, fork, tree; the handlers bail with
6445// a clear status message when the precondition isn't met (empty
6446// transcript on fork; no parent/siblings on tree).
6447func (i *Interactive) openSessionOpsDialog() {
6448	items := []sessionOpsItem{
6449		{label: "export", action: "export", hint: "write the current session to a .zotsession file"},
6450		{label: "import", action: "import", hint: "load a .zotsession file into this directory"},
6451		{label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
6452		{label: "tree", action: "tree", hint: "switch between branches in this directory"},
6453	}
6454	i.sessionOpsDialog.Open(items)
6455	i.invalidate()
6456}
6457
6458// doSessionOp dispatches export, import, fork, or tree. arg is the
6459// optional positional argument from e.g. /session export <path>
6460// or /session import <path>; fork and tree ignore it.
6461func (i *Interactive) doSessionOp(action, arg string) {
6462	switch action {
6463	case "export":
6464		i.doSessionExport(arg)
6465	case "import":
6466		i.doSessionImport(arg)
6467	case "fork":
6468		i.doSessionFork()
6469	case "tree":
6470		i.doSessionTree()
6471	default:
6472		i.mu.Lock()
6473		i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
6474		i.mu.Unlock()
6475		i.invalidate()
6476	}
6477}
6478
6479// doSessionExport writes the live session file to destination path
6480// dst. When dst is empty we default to ~/Downloads (falling back to
6481// the user's home directory if it doesn't exist). The helper
6482// expands a leading `~` and creates any missing parent directories.
6483func (i *Interactive) doSessionExport(dst string) {
6484	if i.cfg.CurrentSessionPath == nil {
6485		i.mu.Lock()
6486		i.statusErr = "export: no session is active (running with --no-session?)"
6487		i.mu.Unlock()
6488		i.invalidate()
6489		return
6490	}
6491	src := i.cfg.CurrentSessionPath()
6492	if src == "" {
6493		i.mu.Lock()
6494		i.statusErr = "export: no session is active (running with --no-session?)"
6495		i.mu.Unlock()
6496		i.invalidate()
6497		return
6498	}
6499	// Persist any in-memory agent messages to the session file so
6500	// the export carries the full conversation. Without this, the
6501	// default lazy-flush-at-exit strategy leaves most of a running
6502	// session unwritten and the export ends up with only the meta.
6503	if i.cfg.FlushSession != nil {
6504		i.cfg.FlushSession()
6505	}
6506	dst = unquotePath(dst)
6507	if dst == "" {
6508		dst = defaultExportDir()
6509	} else {
6510		dst = expandTilde(dst)
6511	}
6512	out, err := core.ExportSession(src, dst)
6513	if err != nil {
6514		i.mu.Lock()
6515		i.statusErr = "export: " + err.Error()
6516		i.mu.Unlock()
6517		i.invalidate()
6518		return
6519	}
6520	i.mu.Lock()
6521	i.statusOK = "exported session to " + friendlyPath(out)
6522	i.statusErr = ""
6523	i.mu.Unlock()
6524	i.invalidate()
6525}
6526
6527// doSessionImport copies the .zotsession file at src into the
6528// running cwd's sessions directory and loads it as the active
6529// session, same as `/sessions` -> pick. When src is empty we ask
6530// the user to pass a path (no usable default here).
6531func (i *Interactive) doSessionImport(src string) {
6532	src = unquotePath(src)
6533	if src == "" {
6534		i.mu.Lock()
6535		i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
6536		i.mu.Unlock()
6537		i.invalidate()
6538		return
6539	}
6540	src = expandTilde(src)
6541	if _, err := os.Stat(src); err != nil {
6542		i.mu.Lock()
6543		i.statusErr = "import: " + err.Error()
6544		i.mu.Unlock()
6545		i.invalidate()
6546		return
6547	}
6548	newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
6549	if err != nil {
6550		i.mu.Lock()
6551		i.statusErr = "import: " + err.Error()
6552		i.mu.Unlock()
6553		i.invalidate()
6554		return
6555	}
6556	if i.cfg.LoadSession == nil {
6557		i.mu.Lock()
6558		i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
6559		i.statusErr = ""
6560		i.mu.Unlock()
6561		i.invalidate()
6562		return
6563	}
6564	if err := i.cfg.LoadSession(newPath); err != nil {
6565		i.mu.Lock()
6566		i.statusErr = "import: load failed: " + err.Error()
6567		i.mu.Unlock()
6568		i.invalidate()
6569		return
6570	}
6571	i.mu.Lock()
6572	i.statusOK = "imported and switched to session " + friendlyPath(newPath)
6573	i.statusErr = ""
6574	i.mu.Unlock()
6575	i.invalidate()
6576}
6577
6578// defaultExportDir returns ~/Downloads when it exists, or ~ as a
6579// fallback, or /tmp on exotic machines with no home dir.
6580func defaultExportDir() string {
6581	home, err := os.UserHomeDir()
6582	if err != nil || home == "" {
6583		return os.TempDir()
6584	}
6585	downloads := filepath.Join(home, "Downloads")
6586	if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
6587		return downloads
6588	}
6589	return home
6590}
6591
6592// expandTilde turns a leading ~ into the user's home directory.
6593// Returns the input unchanged when there's no tilde or no home.
6594func expandTilde(p string) string {
6595	if p == "" || p[0] != '~' {
6596		return p
6597	}
6598	home, err := os.UserHomeDir()
6599	if err != nil || home == "" {
6600		return p
6601	}
6602	if len(p) == 1 {
6603		return home
6604	}
6605	if p[1] == '/' || p[1] == filepath.Separator {
6606		return filepath.Join(home, p[2:])
6607	}
6608	return p
6609}
6610
6611// unquotePath strips a matching pair of surrounding single or
6612// double quotes. Drag-drop paste in the tui auto-quotes dropped
6613// file paths so the shell-like `/session import 'foo bar.zs'`
6614// stays well-formed; when the TUI's own slash handler consumes
6615// the arg, we want the raw path back.
6616func unquotePath(p string) string {
6617	p = strings.TrimSpace(p)
6618	if len(p) >= 2 {
6619		first := p[0]
6620		last := p[len(p)-1]
6621		if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
6622			p = p[1 : len(p)-1]
6623		}
6624	}
6625	return p
6626}
6627
6628// friendlyPath collapses the user's home directory to a leading ~
6629// so status messages read cleanly. Falls back to the raw path when
6630// the home dir is unknown.
6631func friendlyPath(p string) string {
6632	home, err := os.UserHomeDir()
6633	if err != nil || home == "" {
6634		return p
6635	}
6636	if strings.HasPrefix(p, home+string(filepath.Separator)) {
6637		return "~" + p[len(home):]
6638	}
6639	return p
6640}
6641
6642// doSessionFork opens the /jump turn picker in "fork mode". The
6643// next selection branches the current session at that user turn
6644// instead of scrolling the viewport to it.
6645func (i *Interactive) doSessionFork() {
6646	if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
6647		i.mu.Lock()
6648		i.statusErr = "fork: no session is active (running with --no-session?)"
6649		i.mu.Unlock()
6650		i.invalidate()
6651		return
6652	}
6653	msgs := []provider.Message{}
6654	if i.agent != nil {
6655		msgs = i.agent.Messages()
6656	}
6657	if len(msgs) == 0 {
6658		i.mu.Lock()
6659		i.statusErr = "fork: transcript is empty; nothing to fork from"
6660		i.mu.Unlock()
6661		i.invalidate()
6662		return
6663	}
6664	i.pendingFork = true
6665	i.jumpDialog.Open(msgs, "")
6666	i.invalidate()
6667}
6668
6669// doSessionTree shows the current session family as an inline branch tree.
6670// Pick an entry to check out that point into a new branch. Subsequent
6671// prompts use only context up to the selected message.
6672func (i *Interactive) doSessionTree() {
6673	current := ""
6674	if i.cfg.CurrentSessionPath != nil {
6675		current = i.cfg.CurrentSessionPath()
6676	}
6677	if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) {
6678		i.invalidate()
6679		return
6680	}
6681	msgs := []provider.Message{}
6682	if i.agent != nil {
6683		msgs = i.agent.Messages()
6684	}
6685	if len(msgs) == 0 {
6686		i.mu.Lock()
6687		i.statusErr = "tree: no messages in this session yet"
6688		i.mu.Unlock()
6689		i.invalidate()
6690		return
6691	}
6692	if !i.sessionTreeDialog.OpenMessages(msgs) {
6693		i.mu.Lock()
6694		i.statusErr = "tree: no messages to show"
6695		i.mu.Unlock()
6696	}
6697	i.invalidate()
6698}
6699
6700// applySessionTreeMessageSelection checks out the selected message into a
6701// new branch, then switches the running agent to that branch. Unlike /jump,
6702// this changes future model context: only messages up to the selected row are
6703// sent on subsequent turns.
6704func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
6705	if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
6706		i.mu.Lock()
6707		i.statusErr = "tree: session branching is not available in this build"
6708		i.mu.Unlock()
6709		i.invalidate()
6710		return
6711	}
6712	if src == "" {
6713		src = i.cfg.CurrentSessionPath()
6714	}
6715	if src == "" {
6716		i.mu.Lock()
6717		i.statusErr = "tree: no session is active"
6718		i.mu.Unlock()
6719		i.invalidate()
6720		return
6721	}
6722	if i.cfg.FlushSession != nil {
6723		i.cfg.FlushSession()
6724	}
6725	upTo := msgIdx + 1
6726	if role == provider.RoleUser {
6727		upTo = msgIdx
6728	}
6729	newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
6730	if err != nil {
6731		i.mu.Lock()
6732		i.statusErr = "tree: " + err.Error()
6733		i.mu.Unlock()
6734		i.invalidate()
6735		return
6736	}
6737	if err := i.cfg.LoadSession(newPath); err != nil {
6738		i.mu.Lock()
6739		i.statusErr = "tree: checkout failed: " + err.Error()
6740		i.mu.Unlock()
6741		i.invalidate()
6742		return
6743	}
6744	i.scrollToBottom()
6745	i.mu.Lock()
6746	if role == provider.RoleUser {
6747		i.ed.SetValue(prompt)
6748		i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
6749	} else {
6750		i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
6751	}
6752	i.statusErr = ""
6753	i.mu.Unlock()
6754	i.invalidate()
6755}
6756
6757// applyForkSelection branches the current session at msgIdx+1 (so
6758// the selected user message and everything before it is included
6759// in the new branch), then switches the running agent to the new
6760// file. Called from the jump-dialog handler when pendingFork=true.
6761func (i *Interactive) applyForkSelection(msgIdx int) {
6762	i.pendingFork = false
6763	if i.cfg.CurrentSessionPath == nil {
6764		i.mu.Lock()
6765		i.statusErr = "fork: no session is active"
6766		i.mu.Unlock()
6767		i.invalidate()
6768		return
6769	}
6770	src := i.cfg.CurrentSessionPath()
6771	if src == "" {
6772		i.mu.Lock()
6773		i.statusErr = "fork: no session is active"
6774		i.mu.Unlock()
6775		i.invalidate()
6776		return
6777	}
6778	if i.cfg.FlushSession != nil {
6779		i.cfg.FlushSession()
6780	}
6781	// msgIdx is 0-indexed message position; copy msgIdx+1 rows so
6782	// the selected user message is included.
6783	upTo := msgIdx + 1
6784	newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
6785	if err != nil {
6786		i.mu.Lock()
6787		i.statusErr = "fork: " + err.Error()
6788		i.mu.Unlock()
6789		i.invalidate()
6790		return
6791	}
6792	if i.cfg.LoadSession == nil {
6793		i.mu.Lock()
6794		i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
6795		i.statusErr = ""
6796		i.mu.Unlock()
6797		i.invalidate()
6798		return
6799	}
6800	if err := i.cfg.LoadSession(newPath); err != nil {
6801		i.mu.Lock()
6802		i.statusErr = "fork: switch failed: " + err.Error()
6803		i.mu.Unlock()
6804		i.invalidate()
6805		return
6806	}
6807	i.mu.Lock()
6808	i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
6809	i.statusErr = ""
6810	i.mu.Unlock()
6811	i.invalidate()
6812}
6813
6814// formatInt is a tiny strconv.Itoa shim; keeps the handler above
6815// from needing a strconv import just for one call.
6816func formatInt(n int) string {
6817	return fmt.Sprintf("%d", n)
6818}
6819
6820// assistantText returns the concatenated text of every TextBlock in
6821// m. Used by the streaming-view dedupe guard to tell when a live
6822// streamed reply has already been promoted into the transcript.
6823func assistantText(m provider.Message) string {
6824	var sb strings.Builder
6825	for _, c := range m.Content {
6826		if tb, ok := c.(provider.TextBlock); ok {
6827			if sb.Len() > 0 {
6828				sb.WriteByte('\n')
6829			}
6830			sb.WriteString(tb.Text)
6831		}
6832	}
6833	return sb.String()
6834}
6835
6836// resetStreamingStateLocked clears every piece of streaming state
6837// in one shot. Used by abort paths (turn cancel, compact finish,
6838// queue drain) so the pacer doesn't keep draining stale runes from
6839// a prior turn. Must be called with i.mu held.
6840func (i *Interactive) resetStreamingStateLocked() {
6841	i.streaming.Reset()
6842	i.streamPending = i.streamPending[:0]
6843	i.streamFlushPending = false
6844	i.streamOn = false
6845	i.openAllToolGatesLocked()
6846}
6847
6848// openAllToolGatesLocked drops every pending tool gate so that any
6849// tool registered during this turn renders unconditionally from now
6850// on. Called when streaming finalizes (the paced text has fully
6851// drained and `streaming` is about to reset to length 0): without
6852// this, the gate comparison against a freshly-reset streaming buffer
6853// would wrongly re-hide tools that had already cleared their gate.
6854// Must be called with i.mu held.
6855func (i *Interactive) openAllToolGatesLocked() {
6856	for id := range i.toolGate {
6857		i.toolGate[id] = 0
6858	}
6859}
6860
6861// gateToolLocked records the stream position at which a tool call may
6862// become visible. The gate is the total length the streaming buffer
6863// will reach once the pacer has drained everything currently queued
6864// (already painted + still pending). Holding the tool block back
6865// until the pacer crosses that mark guarantees the prose emitted
6866// before the tool call finishes typing out above it, instead of the
6867// tool block snapping in while the paragraph is still filling in.
6868//
6869// We only gate while text is actively streaming. If no stream is in
6870// flight (gate 0), the tool shows immediately, which is the correct
6871// behaviour for tool-only turns and replayed sessions. First
6872// registration wins so a later EvToolCall can't move an existing
6873// gate. Must be called with i.mu held.
6874func (i *Interactive) gateToolLocked(id string) {
6875	if _, ok := i.toolGate[id]; ok {
6876		return
6877	}
6878	if !i.streamOn {
6879		i.toolGate[id] = 0
6880		return
6881	}
6882	i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
6883}
6884
6885// toolGateOpenLocked reports whether the gated tool block may render
6886// yet, i.e. the pacer has drained enough text to reach the position
6887// recorded when the tool call arrived. Must be called with i.mu held.
6888func (i *Interactive) toolGateOpenLocked(id string) bool {
6889	gate, ok := i.toolGate[id]
6890	if !ok || gate == 0 {
6891		return true
6892	}
6893	return i.streaming.Len() >= gate
6894}
6895
6896// assistantMessageSideEffects runs the non-visual hooks attached to
6897// EvAssistantMessage: the host-provided OnAssistant callback and the
6898// telegram-bridge mirror. Called with i.mu held.
6899//
6900// Factored out of handleEvent because the streaming pacer may defer
6901// visual reset until after the last buffered rune has painted, but
6902// the callbacks themselves must fire on message arrival so
6903// downstream observers (session persistence, telegram, cost panels)
6904// don't wait on a UI animation to catch up.
6905func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
6906	if i.cfg.OnAssistant != nil {
6907		i.cfg.OnAssistant(m)
6908	}
6909	if i.telegramBridge != nil && i.telegramBridge.Active() {
6910		var sb strings.Builder
6911		for _, c := range m.Content {
6912			if tb, ok := c.(provider.TextBlock); ok {
6913				if sb.Len() > 0 {
6914					sb.WriteString("\n")
6915				}
6916				sb.WriteString(tb.Text)
6917			}
6918		}
6919		if text := sb.String(); strings.TrimSpace(text) != "" {
6920			go i.telegramBridge.OnAssistantText(text)
6921		}
6922	}
6923}
6924
6925// paintPaceRate is how many runes the streaming pacer releases per
6926// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
6927// that a 500-rune summary finishes in ~1.3s, slow enough to look
6928// like a human typing. Empirically matches the feel of provider
6929// paths that already drip-stream natively.
6930const paintPaceRate = 6
6931
6932// paintPaceInterval is the tick interval for the streaming pacer.
6933// 16ms lines up with the redraw throttle so we never paint faster
6934// than the terminal can keep up.
6935const paintPaceInterval = 16 * time.Millisecond
6936
6937// runStreamPacer drains buffered deltas from streamPending into
6938// streaming a small batch per tick, invalidating after each move.
6939// It stops when the context cancels (tui shutdown).
6940//
6941// Why a pacer: providers differ wildly in how they chunk their
6942// text_delta events. The API-key path on Anthropic emits ~30 drips
6943// for a 400-token summary; the OAuth path can coalesce the same
6944// summary into 3 fat chunks, visually indistinguishable from "the
6945// whole reply just appeared". The pacer normalizes that so every
6946// path looks the same on screen.
6947func (i *Interactive) runStreamPacer(ctx context.Context) {
6948	t := time.NewTicker(paintPaceInterval)
6949	defer t.Stop()
6950	for {
6951		select {
6952		case <-ctx.Done():
6953			return
6954		case <-t.C:
6955			i.mu.Lock()
6956			if len(i.streamPending) == 0 {
6957				// EvAssistantMessage already fired but the pacer
6958				// was still draining a tick ago. Everything is now
6959				// painted; clear the streaming flags so the next
6960				// redraw shows the finalised transcript message
6961				// and hides the streaming overlay.
6962				if i.streamFlushPending {
6963					i.streamFlushPending = false
6964					i.streaming.Reset()
6965					i.streamOn = false
6966					i.openAllToolGatesLocked()
6967					i.mu.Unlock()
6968					i.invalidate()
6969					continue
6970				}
6971				i.mu.Unlock()
6972				continue
6973			}
6974			n := paintPaceRate
6975			if n > len(i.streamPending) {
6976				n = len(i.streamPending)
6977			}
6978			i.streaming.WriteString(string(i.streamPending[:n]))
6979			i.streamPending = i.streamPending[n:]
6980			i.mu.Unlock()
6981			i.invalidate()
6982		}
6983	}
6984}

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