v0.3.6 4657b32

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close