v0.3.10 471dc8c

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

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 476

↑↓ moveenter openesc close