v0.3.9 966d045

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	// Dialogs route keys before the main clipboard handler below. Resolve text
1937	// here when a child interaction owns input so every editor and filter sees
1938	// the same KeyPaste event as terminal-native bracketed paste. Main chat is
1939	// left image-first to preserve macOS clipboard image attachments.
1940	if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() {
1941		resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText)
1942		if err != nil {
1943			i.mu.Lock()
1944			i.statusErr = "clipboard paste failed: " + err.Error()
1945			i.statusOK = ""
1946			i.mu.Unlock()
1947			return false
1948		}
1949		if pastedText {
1950			k = resolved
1951			i.mu.Lock()
1952			i.statusErr = ""
1953			i.statusOK = ""
1954			i.mu.Unlock()
1955		}
1956	}
1957
1958	// Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
1959	// pressing ctrl+c then typing then ctrl+c much later doesn't quit
1960	// unexpectedly. The hint message also goes stale; clear it.
1961	if k.Kind != tui.KeyCtrlC {
1962		i.mu.Lock()
1963		if !i.lastCtrlC.IsZero() {
1964			i.lastCtrlC = time.Time{}
1965			if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
1966				i.statusOK = ""
1967			}
1968		}
1969		i.mu.Unlock()
1970	}
1971
1972	// Dialogs consume keys while open (except ctrl+c, which always closes them).
1973
1974	// Confirmation owns input by default. Pressing / deliberately moves
1975	// focus to the main editor without answering the pending request, so
1976	// slash commands and the dialogs they open can run first.
1977	if i.confirmDialog.Focused() {
1978		if k.Kind == tui.KeyRune && k.Rune == '/' {
1979			i.confirmDialog.Blur()
1980			i.ed.Insert("/")
1981			i.suggest.Reset()
1982			i.invalidate()
1983			return false
1984		}
1985		if k.Kind == tui.KeyCtrlO {
1986			if i.btwDialog != nil && i.btwDialog.Active() {
1987				i.toggleBtwToolExpansion()
1988			} else {
1989				i.toggleToolExpansion()
1990			}
1991			return false
1992		}
1993		i.confirmDialog.HandleKey(k)
1994		i.invalidate()
1995		return false
1996	}
1997	if i.confirmDialog.Active() && !i.confirmChildActive() && k.Kind == tui.KeyEsc {
1998		i.ed.Clear()
1999		i.suggest.Reset()
2000		i.fileSuggest.Reset()
2001		i.confirmDialog.Focus()
2002		i.invalidate()
2003		return false
2004	}
2005	if i.dialog.Active() {
2006		if k.Kind == tui.KeyCtrlC {
2007			i.dialog.Close()
2008			if i.cfg.AuthManager != nil {
2009				i.cfg.AuthManager.CancelOAuth()
2010			}
2011			return false
2012		}
2013		act := i.dialog.HandleKey(k)
2014		if act.StartAPIKey {
2015			i.startAPIKeyFlow(act.Provider)
2016		}
2017		if act.StartOAuth {
2018			i.startOAuthFlow(act.Provider)
2019		}
2020		if act.StartManual {
2021			i.startManualOAuthFlow(act.Provider)
2022		}
2023		if act.SubmitCode != "" {
2024			i.submitManualOAuthCode(act.SubmitCode)
2025		}
2026		if act.SaveLlama {
2027			i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey)
2028		}
2029		return false
2030	}
2031	if i.modelDialog.Active() {
2032		if k.Kind == tui.KeyCtrlC {
2033			i.modelDialog.Close()
2034			i.quickModelAssign = 0
2035			return false
2036		}
2037		act := i.modelDialog.HandleKey(k)
2038		if act.Close {
2039			i.quickModelAssign = 0
2040		}
2041		if act.Select {
2042			if i.quickModelAssign > 0 {
2043				i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model)
2044				i.quickModelAssign = 0
2045			} else {
2046				i.applyModelSelection(act.Provider, act.Model)
2047			}
2048		}
2049		return false
2050	}
2051	if i.llamaDialog.Active() {
2052		i.llamaDialog.HandleKey(k)
2053		i.invalidate()
2054		return false
2055	}
2056	if i.rescueDialog.Active() {
2057		if k.Kind == tui.KeyCtrlC {
2058			i.rescueDialog.Close()
2059			i.invalidate()
2060			return false
2061		}
2062		act := i.rescueDialog.HandleKey(k)
2063		if act.Select {
2064			i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
2065		}
2066		i.invalidate()
2067		return false
2068	}
2069	if i.sessionDialog.Active() {
2070		if k.Kind == tui.KeyCtrlC {
2071			i.sessionDialog.Close()
2072			return false
2073		}
2074		act := i.sessionDialog.HandleKey(k)
2075		if act.Select {
2076			i.applySessionSelection(act.Path)
2077		}
2078		// Always request a redraw after handling a key here: when esc
2079		// closes the picker, the overlay-close detection in the render
2080		// pass needs to run so the tall dialog rows get repainted from
2081		// the chat (otherwise VS Code's retained scrollback leaves a
2082		// duplicate frame on screen).
2083		i.invalidate()
2084		return false
2085	}
2086	if i.swarmDialog.Active() {
2087		if k.Kind == tui.KeyCtrlC {
2088			i.swarmDialog.Close()
2089			i.invalidate()
2090			return false
2091		}
2092		_, msg, errMsg := i.swarmDialog.HandleKey(k)
2093		if msg != "" || errMsg != "" {
2094			i.mu.Lock()
2095			i.statusOK = msg
2096			i.statusErr = errMsg
2097			i.mu.Unlock()
2098		}
2099		i.invalidate()
2100		return false
2101	}
2102	if i.logoutDialog.Active() {
2103		if k.Kind == tui.KeyCtrlC {
2104			i.logoutDialog.Close()
2105			i.invalidate()
2106			return false
2107		}
2108		act := i.logoutDialog.HandleKey(k)
2109		if act.Select {
2110			i.doLogout(act.Target)
2111		}
2112		i.invalidate()
2113		return false
2114	}
2115	if i.telegramDialog.Active() {
2116		if k.Kind == tui.KeyCtrlC {
2117			i.telegramDialog.Close()
2118			i.invalidate()
2119			return false
2120		}
2121		act := i.telegramDialog.HandleKey(k)
2122		if act.Select {
2123			i.doTelegram(act.Action)
2124		}
2125		i.invalidate()
2126		return false
2127	}
2128	if i.settingsDialog.Active() {
2129		if k.Kind == tui.KeyCtrlC {
2130			i.settingsDialog.Close()
2131			i.invalidate()
2132			return false
2133		}
2134		act := i.settingsDialog.HandleKey(k)
2135		if act.ModelShortcutSlot > 0 {
2136			i.openQuickModelPicker(act.ModelShortcutSlot)
2137		}
2138		if act.Toggle {
2139			i.applySettingChange(act)
2140		}
2141		i.invalidate()
2142		return false
2143	}
2144	if i.sessionOpsDialog.Active() {
2145		if k.Kind == tui.KeyCtrlC {
2146			i.sessionOpsDialog.Close()
2147			i.invalidate()
2148			return false
2149		}
2150		act := i.sessionOpsDialog.HandleKey(k)
2151		if act.Select {
2152			i.doSessionOp(act.Action, "")
2153		}
2154		i.invalidate()
2155		return false
2156	}
2157	if i.sessionTreeDialog.Active() {
2158		if k.Kind == tui.KeyCtrlC {
2159			i.sessionTreeDialog.Close()
2160			i.invalidate()
2161			return false
2162		}
2163		act := i.sessionTreeDialog.HandleKey(k)
2164		if act.Select {
2165			i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
2166		}
2167		i.invalidate()
2168		return false
2169	}
2170	if i.extPanel.Active() {
2171		if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
2172			if i.cfg.Extensions != nil {
2173				_ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
2174			}
2175			i.extPanel.Close()
2176			i.invalidate()
2177			return false
2178		}
2179		if i.cfg.Extensions != nil {
2180			_ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
2181		}
2182		return false
2183	}
2184	if i.jumpDialog.Active() {
2185		if k.Kind == tui.KeyCtrlC {
2186			i.jumpDialog.Close()
2187			i.pendingFork = false
2188			return false
2189		}
2190		act := i.jumpDialog.HandleKey(k)
2191		if act.Select {
2192			if i.pendingFork {
2193				i.applyForkSelection(act.MessageIdx)
2194			} else {
2195				i.applyJumpSelection(act.MessageIdx, act.TurnNo)
2196			}
2197		}
2198		// If the user dismissed the dialog without selecting, also
2199		// clear the pending-fork flag so a later plain /jump isn't
2200		// hijacked.
2201		if act.Close {
2202			i.pendingFork = false
2203		}
2204		return false
2205	}
2206	if i.btwDialog.Active() {
2207		if k.Kind == tui.KeyCtrlC {
2208			i.btwDialog.Close()
2209			i.invalidate()
2210			return false
2211		}
2212		if k.Kind == tui.KeyCtrlO {
2213			i.toggleBtwToolExpansion()
2214			return false
2215		}
2216		i.btwDialog.HandleKey(k, i.invalidate)
2217		return false
2218	}
2219	if i.skillsDialog.Active() {
2220		if k.Kind == tui.KeyCtrlC {
2221			i.skillsDialog.Close()
2222			i.invalidate()
2223			return false
2224		}
2225		i.skillsDialog.HandleKey(k)
2226		i.invalidate()
2227		return false
2228	}
2229	if i.changelogDialog.Active() {
2230		if closed := i.changelogDialog.HandleKey(k); closed {
2231			// User dismissed; let the parent persist the
2232			// LastChangelogShown marker via the close callback.
2233			if i.cfg.OnChangelogDismiss != nil {
2234				i.cfg.OnChangelogDismiss()
2235			}
2236		}
2237		i.invalidate()
2238		return false
2239	}
2240
2241	if slot := quickModelShortcutSlot(k); slot > 0 {
2242		i.applyQuickModelShortcut(slot)
2243		return false
2244	}
2245
2246	// Global keys.
2247	switch k.Kind {
2248	case tui.KeyCtrlC:
2249		i.mu.Lock()
2250		loadingSession := i.sessionLoading
2251		i.mu.Unlock()
2252		if loadingSession {
2253			return true
2254		}
2255		// While busy: do NOT cancel the turn. ctrl+c during a
2256		// running turn is almost always reflex muscle memory
2257		// ("be quiet" in a shell) rather than a deliberate
2258		// decision to kill a multi-minute model call that's
2259		// already cost tokens. Use esc to interrupt a turn; use
2260		// a deliberate double-ctrl+c to exit zot entirely. First
2261		// press arms the exit hint, second press within
2262		// ctrlCExitWindow quits.
2263		if i.busy {
2264			if i.ctrlCExitArmed() {
2265				return true
2266			}
2267			i.mu.Lock()
2268			i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
2269			i.statusErr = ""
2270			i.mu.Unlock()
2271			i.armCtrlCExit()
2272			return false
2273		}
2274		// Idle: first press clears the editor (and any queued
2275		// follow-up messages); a second press within ctrlCExitWindow
2276		// exits. With both an empty editor and no queue the first
2277		// press still just arms — require a deliberate double-tap.
2278		ag := i.agent
2279		pending := 0
2280		if ag != nil {
2281			pending = ag.QueuedMessageCount()
2282		}
2283		hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
2284		if hadInput {
2285			i.ed.Clear()
2286			i.clipboardImages = nil
2287			i.suggest.Reset()
2288			if ag != nil {
2289				ag.DrainQueuedMessages()
2290			}
2291			i.mu.Lock()
2292			i.queued = nil
2293			i.statusOK = "input cleared"
2294			i.statusErr = ""
2295			i.mu.Unlock()
2296			i.armCtrlCExit()
2297			return false
2298		}
2299		if i.ctrlCExitArmed() {
2300			return true
2301		}
2302		i.mu.Lock()
2303		i.statusOK = "press ctrl+c again to exit"
2304		i.statusErr = ""
2305		i.mu.Unlock()
2306		i.armCtrlCExit()
2307		return false
2308	case tui.KeyEsc:
2309		// Esc interrupts a running turn — but only when nothing
2310		// else on screen wants to consume the key first. The slash
2311		// popup has its own Esc behaviour (close + clear editor),
2312		// and transient overlays like the /help block and extension
2313		// notes should dismiss on Esc before we even consider the
2314		// turn. Without these guards, a casual Esc press after
2315		// running /help on a busy turn rips the turn away.
2316		if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
2317			break
2318		}
2319		i.mu.Lock()
2320		hadHelp := len(i.helpBlock) > 0
2321		hadNotes := len(i.extNotes) > 0
2322		// Only dismiss a parked shell-escape log on esc when nothing is
2323		// running; if a !command is in flight, esc must fall through to
2324		// the cancel path below instead of just hiding the (empty) block.
2325		hadShell := len(i.shellBlock) > 0 && !i.shellRunning
2326		if hadHelp {
2327			i.helpBlock = nil
2328		}
2329		if hadNotes {
2330			i.extNotes = nil
2331		}
2332		if hadShell {
2333			i.shellBlock = nil
2334		}
2335		i.mu.Unlock()
2336		if hadHelp || hadNotes || hadShell {
2337			i.invalidate()
2338			return false
2339		}
2340		if i.busy && i.cancelTurn != nil {
2341			i.cancelTurn()
2342			// If a confirm dialog is pending, refuse it so the agent
2343			// goroutine unblocks and the context cancellation can
2344			// actually take effect.
2345			i.confirmDialog.CancelAll("turn cancelled")
2346			return false
2347		}
2348	case tui.KeyCtrlD:
2349		if i.ed.IsEmpty() && !i.busy {
2350			return true
2351		}
2352	case tui.KeyCtrlL:
2353		i.rend.Clear()
2354		i.invalidate()
2355		return false
2356	case tui.KeyPasteClipboard:
2357		i.pasteClipboard()
2358		return false
2359	case tui.KeyCtrlO:
2360		i.toggleToolExpansion()
2361		return false
2362	case tui.KeyPageUp:
2363		i.scrollBy(+i.chatPage())
2364		return false
2365	case tui.KeyPageDown:
2366		i.scrollBy(-i.chatPage())
2367		return false
2368	case tui.KeyUp:
2369		// Alt/Option+Up: pop the most recently queued ("sliding in")
2370		// message back into the editor so the user can edit and
2371		// resend it. Repeated presses keep peeling messages off the
2372		// tail of the queue; each press *replaces* the editor
2373		// contents (we don't append/push). When the queue is empty
2374		// the keypress falls through to the normal scroll behavior.
2375		if k.Alt {
2376			i.mu.Lock()
2377			var text string
2378			if i.agent != nil {
2379				text, _ = i.agent.PopQueuedMessage()
2380			}
2381			if text == "" {
2382				if n := len(i.queued); n > 0 {
2383					text = i.queued[n-1]
2384					i.queued = i.queued[:n-1]
2385				}
2386			}
2387			i.mu.Unlock()
2388			if text != "" {
2389				i.ed.SetValue(text)
2390				i.inputHistoryIndex = -1
2391				i.invalidate()
2392				return false
2393			}
2394		}
2395		// In multi-line / wrapped input, Up first moves inside the editor.
2396		// At the editor's top edge, plain Up can browse input history when
2397		// history browsing is safe/active; otherwise it falls back to chat
2398		// scrolling, preserving the old single-line scroll behavior.
2399		if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2400			if i.ed.MoveVertical(-1) {
2401				i.invalidate()
2402				return false
2403			}
2404			if !k.Alt && i.handleInputHistoryKey(k) {
2405				return false
2406			}
2407			i.scrollBy(+3)
2408			return false
2409		}
2410	case tui.KeyDown:
2411		if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2412			if i.ed.MoveVertical(+1) {
2413				i.invalidate()
2414				return false
2415			}
2416			if !k.Alt && i.handleInputHistoryKey(k) {
2417				return false
2418			}
2419			if i.scrollOffset > 0 {
2420				i.scrollBy(-3)
2421			}
2422			return false
2423		}
2424	}
2425
2426	// Note: we intentionally do NOT gate the editor on i.busy here.
2427	// Typing while the agent is working is supported — submitted
2428	// messages are queued and delivered as follow-up turns when the
2429	// current turn ends. See the submit handler below.
2430
2431	if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) {
2432		i.ed.HandleKey(k)
2433		return false
2434	}
2435
2436	// Slash suggestions: intercept up/down/tab/enter when the popup is visible.
2437	if i.suggest.Active(i.ed.Value()) {
2438		switch k.Kind {
2439		case tui.KeyUp:
2440			i.suggest.Up()
2441			return false
2442		case tui.KeyDown:
2443			i.suggest.Down()
2444			return false
2445		case tui.KeyPageUp:
2446			i.suggest.PageUp()
2447			return false
2448		case tui.KeyPageDown:
2449			i.suggest.PageDown()
2450			return false
2451		case tui.KeyTab:
2452			if name := i.suggest.Selection(i.ed.Value()); name != "" {
2453				i.ed.SetValue(name)
2454				i.suggest.Reset()
2455			}
2456			return false
2457		case tui.KeyEnter:
2458			// Enter on an ambiguous or partial slash prefix: complete to the
2459			// currently highlighted command and run it. That way typing
2460			// "/lo" + enter picks whichever of /login or /logout is selected
2461			// in the popup instead of submitting "/lo" as unknown. Also
2462			// clear the editor so the command doesn't linger after the
2463			// dialog opens/closes.
2464			if name := i.suggest.Selection(i.ed.Value()); name != "" {
2465				i.ed.Clear()
2466				i.suggest.Reset()
2467				return i.runSlash(ctx, name)
2468			}
2469		case tui.KeyEsc:
2470			i.ed.Clear()
2471			i.suggest.Reset()
2472			return false
2473		}
2474	}
2475
2476	// File suggestions: intercept up/down/tab/enter when the @-popup is visible.
2477	if i.fileSuggest.Active(i.ed.Value()) {
2478		switch k.Kind {
2479		case tui.KeyUp:
2480			i.fileSuggest.Up()
2481			return false
2482		case tui.KeyDown:
2483			i.fileSuggest.Down()
2484			return false
2485		case tui.KeyRight:
2486			// Open selected directory. The filter the user typed picked
2487			// that directory at the current level; once we descend it no
2488			// longer applies to the directory's contents, so clear it.
2489			// Otherwise typing "@eda" then right would re-filter inside
2490			// eda/ by "eda" and show nothing.
2491			if i.fileSuggest.Right() {
2492				i.clearFileSuggestQuery()
2493			}
2494			return false
2495		case tui.KeyLeft:
2496			// Go back to parent directory. Clear the filter for the same
2497			// reason as Right: it was scoped to the level we just left.
2498			if i.fileSuggest.Left() {
2499				i.clearFileSuggestQuery()
2500			}
2501			return false
2502		case tui.KeyEnter:
2503			if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
2504				var chip string
2505				if entry.isDir {
2506					chip = "[dir:" + entry.rel + "/]"
2507				} else {
2508					chip = "[file:" + entry.rel + "]"
2509				}
2510				val := i.ed.Value()
2511				if idx := strings.LastIndex(val, "@"); idx >= 0 {
2512					val = val[:idx]
2513				}
2514				i.ed.SetValue(val + chip + " ")
2515				i.fileSuggest.Reset()
2516			}
2517			return false
2518		case tui.KeyEsc:
2519			val := i.ed.Value()
2520			if idx := strings.LastIndex(val, "@"); idx >= 0 {
2521				i.ed.SetValue(val[:idx])
2522			}
2523			i.fileSuggest.Reset()
2524			return false
2525		}
2526	}
2527
2528	// Tab-complete a path token in the editor when no popup is open.
2529	// Recognises tokens that look like paths (start with ~, /, ./, ../
2530	// or contain a slash); shell-style completion expands ~, lists the
2531	// parent dir, and completes the basename to the longest common
2532	// prefix. Single match: full replace and trailing / for dirs.
2533	// No match: no-op. Plain bare words (no slash, no tilde) fall
2534	// through so Tab keeps its current no-op behaviour outside paths.
2535	if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2536		if i.tryPathTabComplete() {
2537			return false
2538		}
2539	}
2540
2541	if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
2542		i.inputHistoryIndex = -1
2543	}
2544
2545	if k.Kind == tui.KeyEsc {
2546		i.clipboardImages = nil
2547	}
2548	if submit := i.ed.HandleKey(k); submit {
2549		// SubmitValue() expands any [pasted text #N +L lines]
2550		// placeholders back into their bodies; the raw Value()
2551		// is only what the user sees on screen.
2552		text := strings.TrimRight(i.ed.SubmitValue(), "\n")
2553		// Expand [file:name] and [dir:name/] chips to full paths.
2554		text = expandFileChips(text, i.cfg.CWD)
2555		text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
2556		if text == "" && len(images) == 0 {
2557			return false
2558		}
2559		i.clipboardImages = nil
2560		i.ed.Clear()
2561		i.inputHistoryIndex = -1
2562		i.suggest.Reset()
2563		i.fileSuggest.Reset()
2564
2565		if cmd, ok := shellEscapeCommand(text); ok {
2566			i.startShellEscape(ctx, cmd)
2567			return false
2568		}
2569
2570		if looksLikeSlashCommand(text) {
2571			head := text
2572			rest := ""
2573			if idx := strings.IndexAny(text, " \t"); idx >= 0 {
2574				head = text[:idx]
2575				rest = strings.TrimSpace(text[idx:])
2576			}
2577			if !isKnownSlashCommand(text) {
2578				// Try extensions before giving up. Extensions register
2579				// commands by bare name (no leading slash); strip it here.
2580				extName := strings.TrimPrefix(head, "/")
2581				if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
2582					go i.invokeExtensionCommand(ctx, extName, rest)
2583					return false
2584				}
2585				i.mu.Lock()
2586				i.statusErr = "unknown command " + head + " — type /help to see the list"
2587				i.statusOK = ""
2588				i.mu.Unlock()
2589				return false
2590			}
2591			// Slash commands run regardless of busy state. Commands that
2592			// would mutate the transcript or replace the agent (/clear,
2593			// /compact, /logout, /login, /model) cancel the active turn
2594			// first and wait for the goroutine to wind down so they don't
2595			// race with a streaming response. Safe commands (/help,
2596			// /jump, /sessions, /jail, /unjail, /exit) run immediately
2597			// without disturbing the active turn.
2598			if slashCancelsTurn(head) {
2599				i.cancelAndWaitForIdle()
2600			}
2601			return i.runSlash(ctx, text)
2602		}
2603
2604		if i.agent == nil {
2605			i.mu.Lock()
2606			i.statusErr = "not logged in. type /login first."
2607			i.mu.Unlock()
2608			return false
2609		}
2610		// Mirror the user's typed prompt into the paired Telegram
2611		// chat (when the bridge is active) so the Telegram thread
2612		// stays a complete record of the session, not just the half
2613		// that originated on the phone. On a goroutine so the
2614		// network write doesn't delay the local turn.
2615		if i.telegramBridge != nil && i.telegramBridge.Active() {
2616			go i.telegramBridge.OnUserTyped(text)
2617		}
2618		// If a turn is already in flight, queue this prompt inside the
2619		// agent loop so it is delivered at the next safe model-call
2620		// boundary instead of waiting for the whole run to finish.
2621		i.mu.Lock()
2622		busy := i.busy
2623		ag := i.agent
2624		i.mu.Unlock()
2625		if busy {
2626			if len(images) > 0 {
2627				i.mu.Lock()
2628				i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
2629				i.mu.Unlock()
2630				i.invalidate()
2631				return false
2632			}
2633			if ag != nil {
2634				ag.QueueMessage(text)
2635			} else {
2636				i.mu.Lock()
2637				i.queued = append(i.queued, text)
2638				i.mu.Unlock()
2639			}
2640			i.invalidate()
2641			return false
2642		}
2643		i.startTurnWithImages(ctx, text, images)
2644	}
2645	return false
2646}
2647
2648func (i *Interactive) pasteClipboard() {
2649	_, data, ok, err := tui.ReadClipboardImagePNG()
2650	if err != nil {
2651		i.mu.Lock()
2652		i.statusErr = "clipboard paste failed: " + err.Error()
2653		i.statusOK = ""
2654		i.mu.Unlock()
2655		return
2656	}
2657	if ok {
2658		i.mu.Lock()
2659		marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
2660		i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
2661			Marker: marker,
2662			Image:  provider.ImageBlock{MimeType: "image/png", Data: data},
2663		})
2664		i.ed.Insert(marker + " ")
2665		i.statusOK = ""
2666		i.statusErr = ""
2667		i.mu.Unlock()
2668		return
2669	}
2670	key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText)
2671	i.mu.Lock()
2672	defer i.mu.Unlock()
2673	if err != nil {
2674		i.statusErr = "clipboard paste failed: " + err.Error()
2675		i.statusOK = ""
2676		return
2677	}
2678	if pastedText {
2679		i.ed.HandleKey(key)
2680		i.statusErr = ""
2681		i.statusOK = ""
2682		return
2683	}
2684	i.statusErr = "clipboard does not contain text or an image"
2685	i.statusOK = ""
2686}
2687
2688func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
2689	if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
2690		return false
2691	}
2692	// Do not steal normal vertical cursor movement. History browsing can only
2693	// start from an empty editor; once active, Up/Down keep walking
2694	// the ring so repeated presses work even though the editor now
2695	// contains the selected historical prompt.
2696	if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
2697		return false
2698	}
2699	hist := i.inputHistory()
2700	if len(hist) == 0 {
2701		return false
2702	}
2703
2704	if i.inputHistoryIndex < 0 {
2705		// Start just after the newest item so Up lands on the most
2706		// recent user prompt. A lone Down from an empty editor is not
2707		// history navigation; let the caller fall through to normal UI
2708		// behavior instead.
2709		if k.Kind != tui.KeyUp {
2710			return false
2711		}
2712		i.inputHistoryIndex = len(hist)
2713	}
2714
2715	switch k.Kind {
2716	case tui.KeyUp:
2717		if i.inputHistoryIndex > 0 {
2718			i.inputHistoryIndex--
2719		}
2720	case tui.KeyDown:
2721		if i.inputHistoryIndex < len(hist) {
2722			i.inputHistoryIndex++
2723		}
2724	}
2725
2726	if i.inputHistoryIndex >= len(hist) {
2727		i.ed.Clear()
2728	} else {
2729		i.ed.SetValue(hist[i.inputHistoryIndex])
2730	}
2731	return true
2732}
2733
2734func (i *Interactive) inputHistory() []string {
2735	if i.agent == nil {
2736		return nil
2737	}
2738	msgs := i.agent.Messages()
2739	hist := make([]string, 0, len(msgs))
2740	for _, m := range msgs {
2741		if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) {
2742			continue
2743		}
2744		text := userMessageText(m)
2745		if strings.TrimSpace(text) == "" {
2746			continue
2747		}
2748		hist = append(hist, text)
2749	}
2750	return hist
2751}
2752
2753func userMessageText(m provider.Message) string {
2754	var sb strings.Builder
2755	for _, c := range m.Content {
2756		if tb, ok := c.(provider.TextBlock); ok {
2757			if sb.Len() > 0 {
2758				sb.WriteByte('\n')
2759			}
2760			sb.WriteString(tb.Text)
2761		}
2762	}
2763	return sb.String()
2764}
2765
2766// invokeExtensionCommand fires an extension-registered slash command
2767// in a background goroutine, awaits the response, and applies the
2768// requested action (prompt / insert / display / noop). Errors and
2769// timeouts surface as a status_err line.
2770
2771func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
2772	resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
2773	if err != nil {
2774		i.mu.Lock()
2775		i.statusErr = "extension /" + name + ": " + err.Error()
2776		i.statusOK = ""
2777		i.mu.Unlock()
2778		i.invalidate()
2779		return
2780	}
2781	if resp.Error != "" {
2782		i.mu.Lock()
2783		i.statusErr = "extension /" + name + ": " + resp.Error
2784		i.statusOK = ""
2785		i.mu.Unlock()
2786		i.invalidate()
2787		return
2788	}
2789	switch resp.Action {
2790	case "open_panel":
2791		if resp.OpenPanel != nil {
2792			extName := name
2793			if i.cfg.Extensions != nil {
2794				if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
2795					extName = owner
2796				}
2797			}
2798			i.OpenPanel(extName, *resp.OpenPanel)
2799		}
2800	case "prompt":
2801		if strings.TrimSpace(resp.Prompt) == "" {
2802			return
2803		}
2804		i.startTurn(i.runCtx, resp.Prompt)
2805	case "insert":
2806		i.ed.Insert(resp.Insert)
2807		i.invalidate()
2808	case "display":
2809		i.appendExtensionNote(name, resp.Display, "info")
2810	case "noop", "":
2811		// nothing
2812	default:
2813		i.mu.Lock()
2814		i.statusErr = "extension /" + name + ": unknown action " + resp.Action
2815		i.mu.Unlock()
2816		i.invalidate()
2817	}
2818}
2819
2820// appendExtensionNote renders an extension-originated note in the
2821// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
2822// "success" (tool/ok green).
2823func (i *Interactive) appendExtensionNote(extName, msg, level string) {
2824	if msg == "" {
2825		return
2826	}
2827	i.mu.Lock()
2828	defer i.mu.Unlock()
2829	color := i.cfg.Theme.Muted
2830	switch level {
2831	case "warn":
2832		color = i.cfg.Theme.Warning
2833	case "error":
2834		color = i.cfg.Theme.Error
2835	case "success":
2836		color = i.cfg.Theme.Tool
2837	}
2838	prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
2839	for _, line := range strings.Split(msg, "\n") {
2840		i.statusOK = "" // clear any stale ok
2841		i.statusErr = ""
2842		i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
2843	}
2844	// Extension slash commands complete asynchronously. If their result is
2845	// displayed while confirmation is waiting, keep Esc assigned to the note
2846	// until it is dismissed rather than letting it answer the tool call.
2847	i.confirmDialog.Blur()
2848}
2849
2850// HostHooks implementation for the extension manager. The manager
2851// holds an interface, not a concrete *Interactive, so these methods
2852// are the only thing the manager sees.
2853
2854// Notify is the manager's NotifyFromExt entry point.
2855func (i *Interactive) Notify(extName, level, message string) {
2856	i.appendExtensionNote(extName, message, level)
2857	i.invalidate()
2858}
2859
2860// ClearNotes removes every note line owned by extName from the
2861// bottom-sticky ext-notes block. Extensions use this to retract a
2862// transient status line (e.g. an approval prompt) once it no longer
2863// applies, instead of leaving it stacked forever. Notes from other
2864// extensions and internal notes (auto-compact) are left untouched.
2865func (i *Interactive) ClearNotes(extName string) {
2866	marker := "[" + extName + "] "
2867	i.mu.Lock()
2868	if len(i.extNotes) == 0 {
2869		i.mu.Unlock()
2870		return
2871	}
2872	kept := i.extNotes[:0:0]
2873	changed := false
2874	for _, line := range i.extNotes {
2875		if strings.Contains(line, marker) {
2876			changed = true
2877			continue
2878		}
2879		kept = append(kept, line)
2880	}
2881	if changed {
2882		i.extNotes = kept
2883	}
2884	i.mu.Unlock()
2885	if changed {
2886		i.invalidate()
2887	}
2888}
2889
2890// Submit feeds text through the agent loop as if the user had typed it.
2891func (i *Interactive) Submit(text string) {
2892	if cmd, ok := shellEscapeCommand(text); ok {
2893		i.startShellEscape(i.runCtx, cmd)
2894		return
2895	}
2896	i.startTurn(i.runCtx, text)
2897}
2898
2899// ApplyChangedCWD is called by hosts after a successful /cd hook that do
2900// not provide startup context metadata.
2901func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
2902	i.applyChangedCWD(ag, provider, model, cwd, nil)
2903}
2904
2905// ApplyChangedCWDWithStartupContext swaps a rebuilt agent and its display-only
2906// startup context into the running TUI after a successful /cd hook.
2907func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
2908	i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths)
2909}
2910
2911func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
2912	i.mu.Lock()
2913	i.agent = ag
2914	i.cfg.CWD = cwd
2915	i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
2916	i.view.StartupContextPaths = nil
2917	if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
2918		i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
2919	}
2920	// Re-report the working directory to the terminal so "new tab here"
2921	// tracks the /cd change (OSC 7, see issue #38).
2922	if i.cfg.Terminal != nil {
2923		if seq := tui.ReportCWD(cwd); seq != "" {
2924			_, _ = i.cfg.Terminal.Write([]byte(seq))
2925		}
2926	}
2927	i.cfg.Provider = provider
2928	i.cfg.Model = model
2929	i.toolCalls = map[string]*tui.ToolCallView{}
2930	i.toolOrder = nil
2931	i.toolGate = map[string]int{}
2932	i.helpBlock = nil
2933	i.parkedTurn = 0
2934	i.statusErr = ""
2935	i.mu.Unlock()
2936	i.fileSuggest.Reset()
2937	i.fileSuggest.SetCWD(cwd)
2938	i.invalidate()
2939}
2940
2941// SubmitSlash runs text as a slash command in the TUI as if the user
2942// had typed it. text must start with '/' — callers that hand it
2943// plain prose silently get a no-op so a misbehaving extension can't
2944// run a stray prompt through this path. Read-only commands run in
2945// place; commands that would mutate the transcript or replace the
2946// agent cancel the active turn first via the same path the editor
2947// uses for typed slash commands.
2948func (i *Interactive) SubmitSlash(text string) {
2949	text = strings.TrimSpace(text)
2950	if !strings.HasPrefix(text, "/") {
2951		return
2952	}
2953	head := text
2954	if idx := strings.IndexAny(text, " \t"); idx >= 0 {
2955		head = text[:idx]
2956	}
2957	if slashCancelsTurn(head) {
2958		i.cancelAndWaitForIdle()
2959	}
2960	i.runSlash(i.runCtx, text)
2961	i.invalidate()
2962}
2963
2964// SubmitOrQueue runs text immediately if the agent is idle, or
2965// appends it to the pending queue if a turn is already in flight.
2966// Used by the telegram bridge (and by the editor submit path) so
2967// both input sources share the same "queue behind an active turn"
2968// semantics. Images are ignored for now — only the text prompt is
2969// forwarded — because the queued-prompt path is text-only; a
2970// follow-up can expand the queue entry to carry images.
2971func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
2972	if cmd, ok := shellEscapeCommand(text); ok {
2973		i.startShellEscape(i.runCtx, cmd)
2974		return
2975	}
2976	i.mu.Lock()
2977	if i.agent == nil {
2978		i.statusErr = "not logged in. type /login first."
2979		i.mu.Unlock()
2980		i.invalidate()
2981		return
2982	}
2983	if i.busy {
2984		// Queue text only; images are dropped for queued prompts.
2985		ag := i.agent
2986		i.mu.Unlock()
2987		if ag != nil {
2988			ag.QueueMessage(text)
2989		} else {
2990			i.mu.Lock()
2991			i.queued = append(i.queued, text)
2992			i.mu.Unlock()
2993		}
2994		i.invalidate()
2995		return
2996	}
2997	i.mu.Unlock()
2998	i.startTurnWithImages(i.runCtx, text, images)
2999}
3000
3001// CancelTurn aborts the active turn if one is running. Used by the
3002// telegram bridge when the paired user sends /stop.
3003// ChangelogVersion returns the version string of the changelog
3004// currently shown (or last shown). Used by the dismiss callback
3005// to store the correct version for dev builds.
3006func (i *Interactive) ChangelogVersion() string {
3007	if i.changelogDialog != nil {
3008		return i.changelogDialog.version
3009	}
3010	return ""
3011}
3012
3013func (i *Interactive) CancelTurn() {
3014	i.mu.Lock()
3015	cancel := i.cancelTurn
3016	i.mu.Unlock()
3017	if cancel != nil {
3018		cancel()
3019		i.confirmDialog.CancelAll("turn cancelled")
3020	}
3021}
3022
3023// Insert places text at the cursor in the editor.
3024func (i *Interactive) Insert(text string) {
3025	i.ed.Insert(text)
3026	i.invalidate()
3027}
3028
3029// Display appends a styled note from extName to the chat without a
3030// model call.
3031func (i *Interactive) Display(extName, text string) {
3032	i.appendExtensionNote(extName, text, "info")
3033	i.invalidate()
3034}
3035
3036func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
3037	i.mu.Lock()
3038	defer i.mu.Unlock()
3039	i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
3040	i.confirmDialog.Blur()
3041	if i.cfg.Extensions != nil {
3042		cols, rows := i.cfg.Terminal.Size()
3043		_ = cols
3044		_ = rows
3045	}
3046	i.invalidate()
3047}
3048
3049func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
3050	i.mu.Lock()
3051	defer i.mu.Unlock()
3052	if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
3053		i.extPanel.Update(title, lines, footer)
3054		i.invalidate()
3055	}
3056}
3057
3058func (i *Interactive) ClosePanel(extName, panelID string) {
3059	i.mu.Lock()
3060	defer i.mu.Unlock()
3061	if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
3062		i.extPanel.Close()
3063		i.invalidate()
3064	}
3065}
3066
3067func effectiveImageProtocol(override *bool) tui.ImageProtocol {
3068	detected := tui.DetectImageProtocol()
3069	if override == nil {
3070		return detected
3071	}
3072	if !*override {
3073		return tui.ImageProtocolNone
3074	}
3075	return detected
3076}
3077
3078func imageProtocolName(p tui.ImageProtocol) string {
3079	switch p {
3080	case tui.ImageProtocolKitty:
3081		return "kitty/ghostty"
3082	case tui.ImageProtocolITerm2:
3083		return "iTerm2"
3084	default:
3085		return "none"
3086	}
3087}
3088
3089func onOff(v bool) string {
3090	if v {
3091		return "enabled"
3092	}
3093	return "disabled"
3094}
3095
3096func (i *Interactive) applyInputCursorColor() {
3097	if i == nil || i.cfg.Terminal == nil {
3098		return
3099	}
3100	_, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock()))
3101}
3102
3103func (i *Interactive) openSettingsDialog() {
3104	detected := tui.DetectImageProtocol()
3105	imgEnabled := detected != tui.ImageProtocolNone
3106	if i.cfg.InlineImagesEnabled != nil {
3107		imgEnabled = *i.cfg.InlineImagesEnabled
3108	}
3109	imgDisabled := detected == tui.ImageProtocolNone
3110	imgHint := ""
3111	if imgDisabled {
3112		imgEnabled = false
3113		imgHint = "this terminal does not support inline images"
3114	} else {
3115		imgHint = "terminal supports " + imageProtocolName(detected)
3116	}
3117
3118	autoSwarm := false
3119	if i.cfg.AutoSwarmEnabled != nil {
3120		autoSwarm = *i.cfg.AutoSwarmEnabled
3121	}
3122	autoSwarmDisabled := i.cfg.Swarm == nil
3123	autoSwarmHint := ""
3124	if autoSwarmDisabled {
3125		autoSwarm = false
3126		autoSwarmHint = "swarm supervisor not available in this mode"
3127	}
3128
3129	jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
3130	recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
3131	respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
3132	compactMode := i.compactModeEnabled()
3133	showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup
3134	inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
3135	statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
3136	workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
3137	quickItems := i.quickModelSettingItems()
3138
3139	reasoningOptions := []settingsOption{
3140		{value: "", label: "off", desc: "no reasoning"},
3141		{value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},
3142		{value: "low", label: "low", desc: "light (~2k tokens)"},
3143		{value: "medium", label: "medium", desc: "moderate (~8k tokens)"},
3144		{value: "high", label: "high", desc: "deep (~16k tokens)"},
3145		{value: "xhigh", label: "xhigh", desc: "extra-high effort"},
3146		{value: "max", label: "max", desc: "unconstrained effort on supported models"},
3147	}
3148	reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
3149	reasoningChoice := 0
3150	for idx, opt := range reasoningOptions {
3151		if opt.value == reasoning {
3152			reasoningChoice = idx
3153			break
3154		}
3155	}
3156	reasoningHint := ""
3157	if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil && !m.Reasoning {
3158		reasoningHint = "current model does not support thinking"
3159	}
3160
3161	themeName := i.cfg.ThemeName
3162	if themeName == "" {
3163		themeName = "auto"
3164	}
3165	if themeName != "auto" && !tui.ThemeExists(i.cfg.ZotHome, themeName) {
3166		themeName = "auto"
3167		i.cfg.ThemeName = ""
3168		if i.cfg.SettingsStore != nil {
3169			_ = i.cfg.SettingsStore.SetTheme("auto")
3170		}
3171		i.applyThemeNow("auto")
3172	}
3173	themeOptions := []settingsOption{}
3174	themeChoice := 0
3175	availableThemes := tui.AvailableThemes(i.cfg.ZotHome)
3176	if i.cfg.ExtensionThemes != nil {
3177		availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...)
3178	}
3179	for idx, opt := range availableThemes {
3180		themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description})
3181		if opt.Value == themeName {
3182			themeChoice = idx
3183		}
3184	}
3185
3186	inputStyleOptions := []settingsOption{
3187		{value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
3188		{value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
3189		{value: tui.InputStyleBlock, label: "block", desc: "render the input as a user-bubble-style block"},
3190	}
3191	inputStyleChoice := 0
3192	for idx, opt := range inputStyleOptions {
3193		if opt.value == inputStyle {
3194			inputStyleChoice = idx
3195			break
3196		}
3197	}
3198	statusPositionOptions := []settingsOption{
3199		{value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
3200		{value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
3201	}
3202	statusPositionChoice := 0
3203	for idx, opt := range statusPositionOptions {
3204		if opt.value == statusPosition {
3205			statusPositionChoice = idx
3206			break
3207		}
3208	}
3209
3210	workingPositionOptions := []settingsOption{
3211		{value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
3212		{value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
3213	}
3214	workingPositionChoice := 0
3215	for idx, opt := range workingPositionOptions {
3216		if opt.value == workingPosition {
3217			workingPositionChoice = idx
3218			break
3219		}
3220	}
3221
3222	items := []settingsItem{
3223		{
3224			key:      "inline_images_enabled",
3225			label:    "render images when supported",
3226			desc:     "draw screenshots inline instead of showing a text placeholder",
3227			value:    imgEnabled,
3228			disabled: imgDisabled,
3229			hint:     imgHint,
3230		},
3231		{
3232			key:      "auto_swarm_enabled",
3233			label:    "auto-swarm",
3234			desc:     "let the agent spawn background sub-agents in parallel via the swarm_spawn tool",
3235			value:    autoSwarm,
3236			disabled: autoSwarmDisabled,
3237			hint:     autoSwarmHint,
3238		},
3239		{
3240			key:   "jail_by_default",
3241			label: "jail new sessions by default",
3242			desc:  "confine tools to the session working directory unless /unjail is used",
3243			value: jailByDefault,
3244		},
3245		{
3246			key:   "recursive_file_suggest",
3247			label: "recursive @-file search",
3248			desc:  "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
3249			value: recursiveFiles,
3250		},
3251		{
3252			key:   "respect_gitignore",
3253			label: "hide gitignored files in @-picker",
3254			desc:  "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
3255			value: respectGitignore,
3256		},
3257		{
3258			key:   "compact_mode",
3259			label: "compact transcript rendering",
3260			desc:  "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
3261			value: compactMode,
3262		},
3263		{
3264			key:   "show_instructions_at_startup",
3265			label: "show loaded resources at startup",
3266			desc:  "list loaded context files, extensions, and user-installed skills above the transcript",
3267			value: showInstructions,
3268		},
3269		{
3270			key:   "tui_settings",
3271			label: "tui settings",
3272			desc:  "choose input chrome and where status information appears",
3273			children: []settingsItem{
3274				{
3275					key:     "tui_input_style",
3276					label:   "input style",
3277					desc:    "choose between the plain prompt, lines, and a block input area",
3278					options: inputStyleOptions,
3279					choice:  inputStyleChoice,
3280				},
3281				{
3282					key:     "tui_status_position",
3283					label:   "status position",
3284					desc:    "place model, usage, and working directory above or below the input",
3285					options: statusPositionOptions,
3286					choice:  statusPositionChoice,
3287				},
3288				{
3289					key:     "tui_working_position",
3290					label:   "working spinner position",
3291					desc:    "place the working spinner above or below the input",
3292					options: workingPositionOptions,
3293					choice:  workingPositionChoice,
3294				},
3295			},
3296		},
3297		{
3298			key:     "reasoning",
3299			label:   "thinking level",
3300			desc:    "reasoning depth for thinking-capable models",
3301			options: reasoningOptions,
3302			choice:  reasoningChoice,
3303			hint:    reasoningHint,
3304		},
3305		{
3306			key:     "theme",
3307			label:   "color theme",
3308			desc:    "choose a theme from $ZOT_HOME/themes or a loaded extension",
3309			options: themeOptions,
3310			choice:  themeChoice,
3311		},
3312	}
3313	if len(quickItems) > 0 {
3314		items = append(items, settingsItem{
3315			key:      "quick_models",
3316			label:    "model shortcuts",
3317			desc:     "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches",
3318			children: quickItems,
3319		})
3320	}
3321	i.settingsDialog.Open(items)
3322}
3323
3324func (i *Interactive) applySettingChange(act settingsAction) {
3325	switch {
3326	case strings.HasPrefix(act.Key, "quick_model_"):
3327		i.applyQuickModelSetting(act.Key, act.StringValue)
3328	case act.Key == "reasoning":
3329		i.applyReasoningSetting(act.StringValue)
3330	case act.Key == "theme":
3331		i.applyThemeSetting(act.StringValue)
3332	case act.Key == "tui_input_style":
3333		i.applyTUIInputStyleSetting(act.StringValue)
3334	case act.Key == "tui_status_position":
3335		i.applyTUIStatusPositionSetting(act.StringValue)
3336	case act.Key == "tui_working_position":
3337		i.applyTUIWorkingPositionSetting(act.StringValue)
3338	default:
3339		i.applySettingToggle(act.Key, act.Value)
3340	}
3341}
3342
3343func (i *Interactive) quickModelSettingItems() []settingsItem {
3344	if len(i.cfg.QuickModelShortcuts) < 9 {
3345		next := make([]QuickModelShortcut, 9)
3346		copy(next, i.cfg.QuickModelShortcuts)
3347		i.cfg.QuickModelShortcuts = next
3348	}
3349	items := make([]settingsItem, 0, 9)
3350	for slot := 1; slot <= 9; slot++ {
3351		items = append(items, i.quickModelSettingItem(slot))
3352	}
3353	return items
3354}
3355
3356func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
3357	current := QuickModelShortcut{}
3358	if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot {
3359		current = i.cfg.QuickModelShortcuts[slot-1]
3360	}
3361	hint := "not assigned"
3362	if current.Provider != "" && current.Model != "" {
3363		hint = current.Provider + " / " + current.Model
3364	}
3365	return settingsItem{
3366		key:    "quick_model_" + strconv.Itoa(slot),
3367		label:  "model " + strconv.Itoa(slot),
3368		desc:   quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.",
3369		picker: true,
3370		hint:   hint,
3371	}
3372}
3373
3374func (i *Interactive) compactModeEnabled() bool {
3375	return i.cfg.CompactMode != nil && *i.cfg.CompactMode
3376}
3377
3378func quickModelShortcutSlot(k tui.Key) int {
3379	if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' {
3380		return 0
3381	}
3382	if runtime.GOOS == "darwin" {
3383		if !k.Super && !k.Ctrl {
3384			return 0
3385		}
3386	} else if !k.Ctrl {
3387		return 0
3388	}
3389	return int(k.Rune - '0')
3390}
3391
3392func quickModelShortcutPrefix() string {
3393	return "Ctrl"
3394}
3395
3396func quickModelShortcutLabel(slot int) string {
3397	return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot)
3398}
3399
3400func (i *Interactive) openQuickModelPicker(slot int) {
3401	if slot < 1 || slot > 9 {
3402		return
3403	}
3404	i.quickModelAssign = slot
3405	current := i.cfg.Model
3406	if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
3407		current = i.cfg.QuickModelShortcuts[slot-1].Model
3408	}
3409	var loggedIn []string
3410	if i.cfg.LoggedInProviders != nil {
3411		loggedIn = i.cfg.LoggedInProviders()
3412	}
3413	i.modelDialog.Open(current, loggedIn)
3414}
3415
3416func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
3417	i.setQuickModelShortcut(slot, providerName, model)
3418}
3419
3420func (i *Interactive) applyQuickModelShortcut(slot int) {
3421	if slot < 1 || slot > 9 {
3422		return
3423	}
3424	if i.busy {
3425		i.mu.Lock()
3426		i.statusErr = "cannot switch model while a turn is running"
3427		i.statusOK = ""
3428		i.mu.Unlock()
3429		i.invalidate()
3430		return
3431	}
3432	if len(i.cfg.QuickModelShortcuts) < slot {
3433		i.mu.Lock()
3434		i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
3435		i.statusOK = ""
3436		i.mu.Unlock()
3437		i.invalidate()
3438		return
3439	}
3440	shortcut := i.cfg.QuickModelShortcuts[slot-1]
3441	if shortcut.Provider == "" || shortcut.Model == "" {
3442		i.mu.Lock()
3443		i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
3444		i.statusOK = ""
3445		i.mu.Unlock()
3446		i.invalidate()
3447		return
3448	}
3449	i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false)
3450	i.invalidate()
3451}
3452
3453func (i *Interactive) applyQuickModelSetting(key, value string) {
3454	slotText := strings.TrimPrefix(key, "quick_model_")
3455	slot, err := strconv.Atoi(slotText)
3456	if err != nil || slot < 1 || slot > 9 {
3457		return
3458	}
3459	providerName, model := "", ""
3460	if value != "" {
3461		parts := strings.SplitN(value, "\t", 2)
3462		if len(parts) == 2 {
3463			providerName, model = parts[0], parts[1]
3464		}
3465	}
3466	i.setQuickModelShortcut(slot, providerName, model)
3467}
3468
3469func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) {
3470	if len(i.cfg.QuickModelShortcuts) < slot {
3471		next := make([]QuickModelShortcut, slot)
3472		copy(next, i.cfg.QuickModelShortcuts)
3473		i.cfg.QuickModelShortcuts = next
3474	}
3475	i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model}
3476	if i.cfg.SettingsStore != nil {
3477		if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil {
3478			i.mu.Lock()
3479			i.statusErr = "settings: " + err.Error()
3480			i.mu.Unlock()
3481			return
3482		}
3483	}
3484	i.mu.Lock()
3485	if model == "" {
3486		i.statusOK = quickModelShortcutLabel(slot) + " cleared"
3487	} else {
3488		i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model
3489	}
3490	i.statusErr = ""
3491	i.mu.Unlock()
3492	i.refreshQuickModelSettingsItem(slot)
3493	i.invalidate()
3494}
3495
3496func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
3497	if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 {
3498		return
3499	}
3500	key := "quick_model_" + strconv.Itoa(slot)
3501	for idx, it := range i.settingsDialog.items {
3502		if it.key == key {
3503			i.settingsDialog.items[idx] = i.quickModelSettingItem(slot)
3504			return
3505		}
3506	}
3507}
3508
3509func (i *Interactive) applySettingToggle(key string, value bool) {
3510	// Every setting toggle forces a full repaint at the end — same
3511	// effect as the user pressing Ctrl+L — so any per-setting visual
3512	// change (image rendering, status copy, future toggles) lands
3513	// immediately instead of waiting for the next diff frame.
3514	defer func() {
3515		if i.rend != nil {
3516			i.rend.Clear()
3517		}
3518		i.invalidate()
3519	}()
3520	switch key {
3521	case "inline_images_enabled":
3522		val := value
3523		i.cfg.InlineImagesEnabled = &val
3524		if i.cfg.SettingsStore != nil {
3525			if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
3526				i.mu.Lock()
3527				i.statusErr = "settings: " + err.Error()
3528				i.mu.Unlock()
3529				return
3530			}
3531		}
3532		i.mu.Lock()
3533		i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
3534		i.view.InvalidateRenderCache()
3535		i.statusOK = "inline image rendering " + onOff(value)
3536		i.statusErr = ""
3537		i.mu.Unlock()
3538	case "auto_swarm_enabled":
3539		val := value
3540		i.cfg.AutoSwarmEnabled = &val
3541		if i.cfg.SettingsStore != nil {
3542			if err := i.cfg.SettingsStore.SetAutoSwarm(value); err != nil {
3543				i.mu.Lock()
3544				i.statusErr = "settings: " + err.Error()
3545				i.mu.Unlock()
3546				return
3547			}
3548		}
3549		// Add/remove the swarm_spawn tool on the live agent so the
3550		// model's tools[] list reflects the toggle on the next turn.
3551		// Without this the tool stays advertised after a disable and
3552		// the model keeps trying to call it.
3553		i.applyAutoSwarmTool(value)
3554		// Also swap the system-prompt addendum in/out so the model
3555		// knows to use the tool proactively (or stops referencing it
3556		// after a disable).
3557		i.applyAutoSwarmSystemPrompt(value)
3558		i.mu.Lock()
3559		i.statusOK = "auto-swarm " + onOff(value)
3560		i.statusErr = ""
3561		i.mu.Unlock()
3562	case "jail_by_default":
3563		val := value
3564		i.cfg.JailByDefault = &val
3565		if i.cfg.SettingsStore != nil {
3566			if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil {
3567				i.mu.Lock()
3568				i.statusErr = "settings: " + err.Error()
3569				i.mu.Unlock()
3570				return
3571			}
3572		}
3573		if i.cfg.Sandbox != nil {
3574			if value {
3575				i.cfg.Sandbox.Lock()
3576			} else {
3577				i.cfg.Sandbox.Unlock()
3578			}
3579		}
3580		i.mu.Lock()
3581		i.statusOK = "jail by default " + onOff(value)
3582		i.statusErr = ""
3583		i.mu.Unlock()
3584	case "recursive_file_suggest":
3585		val := value
3586		i.cfg.RecursiveFileSuggest = &val
3587		if i.cfg.SettingsStore != nil {
3588			if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
3589				i.mu.Lock()
3590				i.statusErr = "settings: " + err.Error()
3591				i.mu.Unlock()
3592				return
3593			}
3594		}
3595		// Flip the live picker so the next @ reflects the new mode
3596		// without restarting zot. SetRecursive drops its cache.
3597		i.fileSuggest.SetRecursive(value)
3598		i.mu.Lock()
3599		i.statusOK = "recursive @-file search " + onOff(value)
3600		i.statusErr = ""
3601		i.mu.Unlock()
3602	case "respect_gitignore":
3603		val := value
3604		i.cfg.RespectGitignore = &val
3605		if i.cfg.SettingsStore != nil {
3606			if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
3607				i.mu.Lock()
3608				i.statusErr = "settings: " + err.Error()
3609				i.mu.Unlock()
3610				return
3611			}
3612		}
3613		i.fileSuggest.SetRespectGitignore(value)
3614		i.mu.Lock()
3615		i.statusOK = "hide gitignored files in @-picker " + onOff(value)
3616		i.statusErr = ""
3617		i.mu.Unlock()
3618	case "compact_mode":
3619		val := value
3620		i.cfg.CompactMode = &val
3621		if i.cfg.SettingsStore != nil {
3622			if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
3623				i.mu.Lock()
3624				i.statusErr = "settings: " + err.Error()
3625				i.mu.Unlock()
3626				return
3627			}
3628		}
3629		i.mu.Lock()
3630		i.view.CompactMode = value
3631		i.view.InvalidateRenderCache()
3632		i.statusOK = "compact transcript rendering " + onOff(value)
3633		i.statusErr = ""
3634		i.mu.Unlock()
3635	case "show_instructions_at_startup":
3636		val := value
3637		i.cfg.ShowInstructionsAtStartup = &val
3638		if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok {
3639			if err := store.SetShowInstructionsAtStartup(value); err != nil {
3640				i.mu.Lock()
3641				i.statusErr = "settings: " + err.Error()
3642				i.mu.Unlock()
3643				return
3644			}
3645		}
3646		i.mu.Lock()
3647		i.view.StartupContextPaths = nil
3648		i.view.StartupExtensionNames = nil
3649		i.view.StartupSkillNames = nil
3650		if value {
3651			i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
3652			i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...)
3653			i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...)
3654		}
3655		i.statusOK = "show loaded resources at startup " + onOff(value)
3656		i.statusErr = ""
3657		i.mu.Unlock()
3658	}
3659}
3660
3661func (i *Interactive) applyTUIInputStyleSetting(style string) {
3662	defer func() {
3663		if i.rend != nil {
3664			i.rend.Clear()
3665		}
3666		i.invalidate()
3667	}()
3668	style = tui.NormalizeInputStyle(style)
3669	i.cfg.TUIInputStyle = style
3670	i.applyInputCursorColor()
3671	if i.cfg.SettingsStore != nil {
3672		if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
3673			i.mu.Lock()
3674			i.statusErr = "settings: " + err.Error()
3675			i.mu.Unlock()
3676			return
3677		}
3678	}
3679	i.mu.Lock()
3680	i.statusOK = "input style " + style
3681	i.statusErr = ""
3682	i.mu.Unlock()
3683}
3684
3685func (i *Interactive) applyTUIStatusPositionSetting(position string) {
3686	defer func() {
3687		if i.rend != nil {
3688			i.rend.Clear()
3689		}
3690		i.invalidate()
3691	}()
3692	position = tui.NormalizeStatusPosition(position)
3693	i.cfg.TUIStatusPosition = position
3694	if i.cfg.SettingsStore != nil {
3695		if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
3696			i.mu.Lock()
3697			i.statusErr = "settings: " + err.Error()
3698			i.mu.Unlock()
3699			return
3700		}
3701	}
3702	i.mu.Lock()
3703	label := "above input"
3704	if position == tui.StatusPositionBelowInput {
3705		label = "below input"
3706	}
3707	i.statusOK = "status position " + label
3708	i.statusErr = ""
3709	i.mu.Unlock()
3710}
3711
3712func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
3713	defer func() {
3714		if i.rend != nil {
3715			i.rend.Clear()
3716		}
3717		i.invalidate()
3718	}()
3719	position = tui.NormalizeWorkingPosition(position)
3720	i.cfg.TUIWorkingPosition = position
3721	if i.cfg.SettingsStore != nil {
3722		if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
3723			i.mu.Lock()
3724			i.statusErr = "settings: " + err.Error()
3725			i.mu.Unlock()
3726			return
3727		}
3728	}
3729	i.mu.Lock()
3730	label := "above input"
3731	if position == tui.WorkingPositionBelowInput {
3732		label = "below input"
3733	}
3734	i.statusOK = "working spinner position " + label
3735	i.statusErr = ""
3736	i.mu.Unlock()
3737}
3738
3739func (i *Interactive) applyThemeSetting(name string) {
3740	if i.cfg.SettingsStore != nil {
3741		if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
3742			i.mu.Lock()
3743			i.statusErr = "settings: " + err.Error()
3744			i.mu.Unlock()
3745			return
3746		}
3747	}
3748	i.cfg.ThemeName = name
3749	if name == "auto" {
3750		i.cfg.ThemeName = ""
3751	}
3752	i.applyThemeNow(name)
3753}
3754
3755func (i *Interactive) applyThemeNow(name string) {
3756	if name == "" {
3757		name = "auto"
3758	}
3759	detected := tui.Dark
3760	if tui.IsLightTheme(i.cfg.Theme) {
3761		detected = tui.Light
3762	}
3763	th, applied, err := tui.LoadThemeFromHome(i.cfg.ZotHome, name, detected)
3764	if err != nil {
3765		if i.cfg.SettingsStore != nil {
3766			_ = i.cfg.SettingsStore.SetTheme("auto")
3767		}
3768		i.cfg.ThemeName = ""
3769		th, applied, _ = tui.LoadThemeFromHome(i.cfg.ZotHome, "auto", detected)
3770		i.mu.Lock()
3771		i.statusErr = "theme missing; reset to default"
3772		i.mu.Unlock()
3773	} else {
3774		i.mu.Lock()
3775		label := applied
3776		if label == "" {
3777			label = "auto"
3778		}
3779		i.statusOK = "theme " + label
3780		i.statusErr = ""
3781		i.mu.Unlock()
3782	}
3783	i.cfg.Theme = th
3784	i.view.Theme = th
3785	i.view.InvalidateRenderCache()
3786	i.ed.Prompt = th.AccentBar(th.Accent)
3787	i.applyInputCursorColor()
3788	i.spin.Configure(th)
3789	if i.rend != nil {
3790		i.rend.SetTheme(th)
3791		i.rend.Clear()
3792	}
3793	i.invalidate()
3794}
3795
3796func (i *Interactive) applyReasoningSetting(level string) {
3797	defer func() {
3798		if i.rend != nil {
3799			i.rend.Clear()
3800		}
3801		i.invalidate()
3802	}()
3803	level = provider.NormalizeReasoning(level)
3804	i.cfg.Reasoning = level
3805	if i.cfg.SettingsStore != nil {
3806		if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
3807			i.mu.Lock()
3808			i.statusErr = "settings: " + err.Error()
3809			i.mu.Unlock()
3810			return
3811		}
3812	}
3813	i.mu.Lock()
3814	if i.agent != nil {
3815		i.agent.Reasoning = level
3816	}
3817	label := level
3818	if label == "" {
3819		label = "off"
3820	}
3821	i.statusOK = "thinking level " + label
3822	i.statusErr = ""
3823	i.mu.Unlock()
3824}
3825
3826// buildStudyPrompt returns the canned prompt the /study command
3827// submits to the agent.
3828//
3829// With no argument, /study targets the current directory — the
3830// historical behaviour. With an argument, /study targets that path
3831// instead; either a directory ("read every file in here") or a
3832// single file ("read this file"). The argument can be:
3833//
3834//   - a relative path (resolved against cwd)
3835//   - an absolute path
3836//   - an @-picker chip, which has already been expanded to an
3837//     absolute path by expandFileChips before runSlash sees it
3838//
3839// The path is stat'd to pick the right wording ("directory" vs
3840// "file"). If the path doesn't exist, we still build a sensible
3841// prompt rather than erroring — the agent will surface the
3842// missing-file failure itself when it tries to read it, which is
3843// more useful than a refusal here.
3844func buildStudyPrompt(arg, cwd string) string {
3845	arg = strings.TrimSpace(arg)
3846	if arg == "" {
3847		return "Read and understand everything in the current directory."
3848	}
3849	abs := arg
3850	if !filepath.IsAbs(abs) {
3851		abs = filepath.Join(cwd, abs)
3852	}
3853	display := arg
3854	if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
3855		display = rel
3856	}
3857	if info, err := os.Stat(abs); err == nil && !info.IsDir() {
3858		return "Read and understand the file " + display + "."
3859	}
3860	return "Read and understand everything in the directory " + display + "."
3861}
3862
3863// tryPathTabCompleteEditor looks at ed's current value, finds the
3864// path-like token immediately before the cursor (the cursor is always
3865// at the end of the buffer after a keystroke, so "before the cursor"
3866// is the trailing non-whitespace run), and rewrites it to its shell-
3867// style completion against the filesystem.
3868//
3869// Returns true when it consumed the Tab keystroke (token recognised,
3870// completion attempted — even if no candidates matched, the keystroke
3871// is still consumed so it doesn't insert a literal tab character).
3872// Returns false when the token doesn't look like a path; callers then
3873// let Tab fall through to its normal no-op.
3874//
3875// Recognised path shapes:
3876//   - ~ or ~/foo                  expanded via os.UserHomeDir()
3877//   - /abs/path or /abs/path/foo  absolute
3878//   - ./foo, ../foo, foo/bar      relative to cwd
3879//
3880// A bare word like "hello" is not treated as a path so plain text
3881// keeps Tab as a literal no-op.
3882//
3883// Free function (not a method) so the same logic runs against the
3884// editor instances owned by btwDialog and swarmDialog without each
3885// dialog needing its own copy.
3886func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
3887	if ed == nil {
3888		return false
3889	}
3890	val := ed.Value()
3891	// Find the trailing run of non-whitespace.
3892	start := len(val)
3893	for start > 0 {
3894		r := val[start-1]
3895		if r == ' ' || r == '\t' || r == '\n' {
3896			break
3897		}
3898		start--
3899	}
3900	token := val[start:]
3901	if token == "" {
3902		return false
3903	}
3904	if !looksLikePathToken(token) {
3905		return false
3906	}
3907
3908	// Resolve the absolute parent directory + base prefix to match.
3909	parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
3910	if !ok {
3911		return true
3912	}
3913	entries, err := os.ReadDir(parentAbs)
3914	if err != nil {
3915		return true
3916	}
3917	var names []string
3918	var isDir []bool
3919	for _, e := range entries {
3920		name := e.Name()
3921		if !strings.HasPrefix(name, basePrefix) {
3922			continue
3923		}
3924		// Hide dotfiles unless the user explicitly typed a leading dot,
3925		// mirroring bash's default behaviour.
3926		if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
3927			continue
3928		}
3929		names = append(names, name)
3930		isDir = append(isDir, e.IsDir())
3931	}
3932	if len(names) == 0 {
3933		return true
3934	}
3935
3936	var completed string
3937	var completedIsDir bool
3938	if len(names) == 1 {
3939		completed = names[0]
3940		completedIsDir = isDir[0]
3941	} else {
3942		completed = longestCommonPrefix(names)
3943		if completed == basePrefix {
3944			// Already at the deepest unambiguous prefix; nothing to add.
3945			return true
3946		}
3947	}
3948
3949	// Build the replacement token in the same display form the user
3950	// typed (preserve ~ vs absolute vs relative).
3951	newToken := displayParent + completed
3952	if len(names) == 1 && completedIsDir {
3953		newToken += "/"
3954	}
3955
3956	ed.SetValue(val[:start] + newToken)
3957	return true
3958}
3959
3960// tryPathTabComplete is the Interactive-bound convenience wrapper.
3961// It calls the free helper against the main editor and invalidates
3962// the frame on a successful rewrite.
3963func (i *Interactive) tryPathTabComplete() bool {
3964	if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
3965		i.invalidate()
3966		return true
3967	}
3968	return false
3969}
3970
3971// looksLikePathToken reports whether tok is shaped like a filesystem
3972// path. Paths must either start with ~, /, ./, ../ or contain a /.
3973// Plain words are excluded so Tab on "hello" stays a no-op.
3974func looksLikePathToken(tok string) bool {
3975	if tok == "" {
3976		return false
3977	}
3978	if tok[0] == '~' || tok[0] == '/' {
3979		return true
3980	}
3981	if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
3982		return true
3983	}
3984	return strings.Contains(tok, "/")
3985}
3986
3987// resolvePathTabToken splits tok into (absolute parent dir, basename
3988// prefix to match, display-form parent the user typed). ok is false
3989// when the parent dir can't be resolved (e.g. ~ with no $HOME).
3990func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
3991	// Detect ~ expansion.
3992	expanded := tok
3993	homePrefix := ""
3994	if tok == "~" {
3995		home, err := os.UserHomeDir()
3996		if err != nil || home == "" {
3997			return "", "", "", false
3998		}
3999		// "~" alone: complete in $HOME. parent = home, base = "".
4000		return home, "", "~/", true
4001	}
4002	if strings.HasPrefix(tok, "~/") {
4003		home, err := os.UserHomeDir()
4004		if err != nil || home == "" {
4005			return "", "", "", false
4006		}
4007		expanded = home + tok[1:]
4008		homePrefix = "~"
4009	}
4010
4011	dir, base := splitDirBase(expanded)
4012	if !filepath.IsAbs(dir) {
4013		dir = filepath.Join(cwd, dir)
4014	}
4015
4016	// Reconstruct the display form the user typed for the parent,
4017	// keeping ~ when they used it. The base is dropped — the caller
4018	// substitutes the completed name.
4019	display := tok[:len(tok)-len(base)]
4020	if homePrefix != "" && !strings.HasPrefix(display, "~") {
4021		display = homePrefix + display[len(homePrefix):]
4022	}
4023	return dir, base, display, true
4024}
4025
4026// splitDirBase is like filepath.Split but preserves the trailing
4027// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
4028// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
4029// the trailing separator when non-empty so callers can rebuild paths
4030// by concatenation.
4031func splitDirBase(p string) (dir, base string) {
4032	if p == "" {
4033		return ".", ""
4034	}
4035	i := strings.LastIndex(p, "/")
4036	if i < 0 {
4037		return ".", p
4038	}
4039	return p[:i+1], p[i+1:]
4040}
4041
4042func longestCommonPrefix(ss []string) string {
4043	if len(ss) == 0 {
4044		return ""
4045	}
4046	prefix := ss[0]
4047	for _, s := range ss[1:] {
4048		n := 0
4049		for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
4050			n++
4051		}
4052		prefix = prefix[:n]
4053		if prefix == "" {
4054			return ""
4055		}
4056	}
4057	return prefix
4058}
4059
4060func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4061	parts := strings.Fields(cmd)
4062	switch strings.ToLower(parts[0]) {
4063	case "/exit":
4064		return true
4065	case "/clear":
4066		if i.agent != nil {
4067			i.agent.SetMessages(nil)
4068		}
4069		i.mu.Lock()
4070		i.toolCalls = map[string]*tui.ToolCallView{}
4071		i.toolOrder = nil
4072		i.toolGate = map[string]int{}
4073		i.statusErr = ""
4074		i.statusOK = ""
4075		i.helpBlock = nil
4076		i.parkedTurn = 0
4077		i.parkedTotal = 0
4078		i.scrollOffset = 0
4079		i.extNotes = nil
4080		i.shellBlock = nil
4081		i.view.InvalidateRenderCache()
4082		i.mu.Unlock()
4083	case "/help":
4084		i.mu.Lock()
4085		i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
4086		i.statusErr = ""
4087		i.statusOK = ""
4088		// Pin the viewport to the newest content so the help block,
4089		// which we just appended to the end of the transcript, is
4090		// what the user actually sees.
4091		i.scrollOffset = 0
4092		i.mu.Unlock()
4093	case "/login":
4094		i.dialog.Open(i.cfg.ZotHome)
4095	case "/logout":
4096		if len(parts) >= 2 {
4097			// Explicit target: /logout anthropic | openai | all
4098			i.doLogout(parts[1])
4099			break
4100		}
4101		// No arg: open the picker over whichever providers are
4102		// currently logged in. If nothing's logged in, bail with a
4103		// status line.
4104		i.openLogoutDialog()
4105	case "/model":
4106		if len(parts) >= 2 {
4107			i.applyModelSelection("", parts[1])
4108		} else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
4109			i.mu.Lock()
4110			i.statusOK = "refreshing models"
4111			i.statusErr = ""
4112			i.mu.Unlock()
4113			go func() {
4114				refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
4115				defer cancel()
4116				i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)}
4117			}()
4118		} else {
4119			i.openModelPickerAfterRefresh(nil)
4120		}
4121	case "/llama":
4122		if i.cfg.LlamaCPPConfig == nil {
4123			i.mu.Lock()
4124			i.statusErr = "llama.cpp configuration is unavailable"
4125			i.statusOK = ""
4126			i.mu.Unlock()
4127			break
4128		}
4129		baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
4130		if configErr == nil {
4131			configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
4132		}
4133		if configErr != nil {
4134			i.mu.Lock()
4135			i.statusErr = configErr.Error()
4136			i.statusOK = ""
4137			i.mu.Unlock()
4138		}
4139	case "/settings":
4140		i.openSettingsDialog()
4141	case "/sessions":
4142		i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
4143	case "/jump":
4144		i.openJumpDialog(parts[1:])
4145	case "/btw":
4146		i.openBtwDialog(parts[1:])
4147	case "/skills":
4148		i.openSkillsDialog()
4149	case "/compact":
4150		i.runCompact(ctx, false)
4151	case "/study":
4152		// Canned prompt that tells the agent to read every file
4153		// in some target so its later turns have the whole thing
4154		// in context. With no argument, the target is the current
4155		// directory. With an argument, the target is whatever the
4156		// user passed — typed by hand, drag-dropped, or selected
4157		// via the @ file picker (which is why we accept both files
4158		// and directories; the @-picker chips for both have already
4159		// been expanded to absolute paths by expandFileChips above).
4160		// Dispatched through the normal queue-or-start path so it
4161		// behaves identically to typing the prompt by hand.
4162		studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
4163		i.mu.Lock()
4164		busy := i.busy
4165		ag := i.agent
4166		i.mu.Unlock()
4167		if busy {
4168			if ag != nil {
4169				ag.QueueMessage(studyPrompt)
4170			} else {
4171				i.mu.Lock()
4172				i.queued = append(i.queued, studyPrompt)
4173				i.mu.Unlock()
4174			}
4175			i.invalidate()
4176			break
4177		}
4178		i.startTurn(ctx, studyPrompt)
4179	case "/cd":
4180		// Hidden command: switch the running session's cwd. Not in
4181		// slash_suggest, not in /help. Used by the workspaces
4182		// extension's panel-key Enter handler so picking a row
4183		// jumps zot into that directory without relaunching.
4184		//
4185		// Recovers the raw argument (path) from the original cmd
4186		// string rather than parts, so paths with spaces survive.
4187		// The host's ChangeCWD hook handles validation, session
4188		// close + reopen, agent rebuild, sandbox re-rooting, and
4189		// re-jail-if-jailed semantics.
4190		if i.cfg.ChangeCWD == nil {
4191			i.mu.Lock()
4192			i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
4193			i.mu.Unlock()
4194			break
4195		}
4196		path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
4197		if path == "" {
4198			i.mu.Lock()
4199			i.statusErr = "/cd: missing path"
4200			i.mu.Unlock()
4201			break
4202		}
4203		if err := i.cfg.ChangeCWD(path); err != nil {
4204			i.mu.Lock()
4205			i.statusErr = "/cd: " + err.Error()
4206			i.statusOK = ""
4207			i.mu.Unlock()
4208			break
4209		}
4210		// ChangeCWD has already updated i.cfg.CWD and swapped the
4211		// agent + session. Reset transient TUI state so the new
4212		// session opens clean.
4213		i.mu.Lock()
4214		i.toolCalls = map[string]*tui.ToolCallView{}
4215		i.toolOrder = nil
4216		i.toolGate = map[string]int{}
4217		i.helpBlock = nil
4218		i.parkedTurn = 0
4219		i.statusOK = "cwd " + i.cfg.CWD
4220		i.statusErr = ""
4221		i.mu.Unlock()
4222		i.fileSuggest.Reset()
4223		i.fileSuggest.SetCWD(i.cfg.CWD)
4224		i.invalidate()
4225	case "/jail":
4226		if i.cfg.Sandbox == nil {
4227			i.mu.Lock()
4228			i.statusErr = "sandbox not available in this build"
4229			i.mu.Unlock()
4230			break
4231		}
4232		i.cfg.Sandbox.Lock()
4233		i.mu.Lock()
4234		i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
4235		i.statusErr = ""
4236		i.mu.Unlock()
4237	case "/unjail":
4238		if i.cfg.Sandbox == nil {
4239			i.mu.Lock()
4240			i.statusErr = "sandbox not available in this build"
4241			i.mu.Unlock()
4242			break
4243		}
4244		i.cfg.Sandbox.Unlock()
4245		i.mu.Lock()
4246		i.statusOK = "unjailed"
4247		i.statusErr = ""
4248		i.mu.Unlock()
4249	case "/reload-ext":
4250		i.runReloadExt(ctx)
4251	case "/telegram", "/tg":
4252		if len(parts) >= 2 {
4253			i.doTelegram(parts[1])
4254			break
4255		}
4256		i.openTelegramDialog()
4257	case "/session":
4258		if len(parts) >= 2 {
4259			action := parts[1]
4260			arg := ""
4261			if len(parts) >= 3 {
4262				arg = strings.Join(parts[2:], " ")
4263			}
4264			i.doSessionOp(action, arg)
4265			break
4266		}
4267		i.openSessionOpsDialog()
4268	case "/swarm":
4269		i.runSwarm(ctx, parts[1:])
4270	default:
4271		// Last-resort fallback: try the extension manager. Built-in
4272		// cases above always win; this branch only fires for slash
4273		// commands the extension manager registered. Same routing as
4274		// the editor's submit-handler dispatch path so the autocomplete
4275		// "enter on highlighted suggestion" flow also works.
4276		extName := strings.TrimPrefix(parts[0], "/")
4277		if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
4278			rest := ""
4279			if len(parts) > 1 {
4280				rest = strings.Join(parts[1:], " ")
4281			}
4282			go i.invokeExtensionCommand(ctx, extName, rest)
4283			return false
4284		}
4285		i.mu.Lock()
4286		i.statusErr = "unknown command: " + parts[0]
4287		i.mu.Unlock()
4288	}
4289	return false
4290}
4291
4292func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
4293	var loggedIn []string
4294	if i.cfg.LoggedInProviders != nil {
4295		loggedIn = i.cfg.LoggedInProviders()
4296	}
4297	i.modelDialog.Open(i.cfg.Model, loggedIn)
4298	i.mu.Lock()
4299	if refreshErr != nil {
4300		i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
4301		i.statusOK = ""
4302	} else if i.statusOK == "refreshing models" {
4303		i.statusOK = ""
4304	}
4305	i.mu.Unlock()
4306}
4307
4308// openLogoutDialog shows the provider picker for `/logout` with no
4309// argument. Only providers the user is currently logged into are
4310// listed, plus an "all" entry when more than one is present. If
4311// nothing's logged in, writes a status line instead of opening an
4312// empty dialog.
4313func (i *Interactive) openLogoutDialog() {
4314	if i.cfg.AuthManager == nil {
4315		i.mu.Lock()
4316		i.statusErr = "no auth manager configured"
4317		i.mu.Unlock()
4318		i.invalidate()
4319		return
4320	}
4321	store := i.cfg.AuthManager.Store()
4322	if store == nil {
4323		i.mu.Lock()
4324		i.statusErr = "auth store is not available"
4325		i.mu.Unlock()
4326		i.invalidate()
4327		return
4328	}
4329	creds, err := store.Load()
4330	if err != nil {
4331		i.mu.Lock()
4332		i.statusErr = "read auth store: " + err.Error()
4333		i.mu.Unlock()
4334		i.invalidate()
4335		return
4336	}
4337
4338	var items []logoutItem
4339	for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
4340		if creds.Has(p) {
4341			method := creds.Method(p)
4342			if method == "oauth" {
4343				method = "subscription"
4344			}
4345			items = append(items, logoutItem{
4346				label:  providerLabel(p),
4347				target: p,
4348				method: method,
4349			})
4350		}
4351	}
4352	if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
4353		items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
4354	}
4355	if creds.OpenAI.OAuth != nil {
4356		items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
4357	}
4358	for p, c := range creds.AdditionalAPIKeyCreds {
4359		if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
4360			items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
4361		}
4362	}
4363	if len(items) == 0 {
4364		i.mu.Lock()
4365		i.statusOK = "no credentials stored; already logged out"
4366		i.statusErr = ""
4367		i.mu.Unlock()
4368		i.invalidate()
4369		return
4370	}
4371	if len(items) > 1 {
4372		items = append(items, logoutItem{label: "all", target: "all"})
4373	}
4374
4375	i.logoutDialog.Open(items)
4376	i.invalidate()
4377}
4378
4379// doLogout clears credentials for the given provider (or all providers)
4380// from auth.json. If the active agent was using those credentials, it
4381// is torn down so the user is forced through /login before their next
4382// prompt.
4383//
4384// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
4385func (i *Interactive) doLogout(target string) {
4386	if i.cfg.AuthManager == nil {
4387		i.mu.Lock()
4388		i.statusErr = "no auth manager configured"
4389		i.mu.Unlock()
4390		return
4391	}
4392	store := i.cfg.AuthManager.Store()
4393	if store == nil {
4394		i.mu.Lock()
4395		i.statusErr = "auth store is not available"
4396		i.mu.Unlock()
4397		return
4398	}
4399
4400	var providers []string
4401	switch target {
4402	case "", "all":
4403		providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
4404	case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
4405		providers = []string{target}
4406	default:
4407		known := false
4408		for _, p := range auth.APIKeyProviders() {
4409			if target == p {
4410				known = true
4411				break
4412			}
4413		}
4414		if !known {
4415			i.mu.Lock()
4416			i.statusErr = "unknown provider: " + target
4417			i.mu.Unlock()
4418			return
4419		}
4420		providers = []string{target}
4421	}
4422
4423	var errs []string
4424	clearedCurrent := false
4425	for _, p := range providers {
4426		var err error
4427		switch p {
4428		case "openai":
4429			err = store.ClearAPIKey("openai")
4430		case "openai-codex":
4431			err = store.ClearOAuth("openai")
4432		default:
4433			err = store.Clear(p)
4434		}
4435		if err != nil {
4436			errs = append(errs, p+": "+err.Error())
4437			continue
4438		}
4439		if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
4440			if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
4441				errs = append(errs, p+": "+err.Error())
4442				continue
4443			}
4444		}
4445		if p == i.cfg.Provider {
4446			clearedCurrent = true
4447		}
4448	}
4449
4450	llamaConfigured := i.llamaConfigured
4451	if i.cfg.LlamaCPPConfig != nil {
4452		baseURL, _, err := i.cfg.LlamaCPPConfig()
4453		llamaConfigured = err == nil && baseURL != ""
4454	}
4455
4456	i.mu.Lock()
4457	defer i.mu.Unlock()
4458	i.llamaConfigured = llamaConfigured
4459	if len(errs) > 0 {
4460		i.statusErr = "logout errors: " + strings.Join(errs, "; ")
4461		return
4462	}
4463	i.statusErr = ""
4464	if clearedCurrent {
4465		// The running agent was using a credential we just wiped. Drop
4466		// it so prompts can't go out with the stale client, and hint at
4467		// /login.
4468		i.agent = nil
4469		i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
4470	} else {
4471		i.statusOK = "logged out of " + strings.Join(providers, ", ")
4472	}
4473}
4474
4475func providerSetupInfo(provider string) (string, []string, bool) {
4476	const docsURL = "https://raw.githubusercontent.com/patriceckhart/zot/main/docs/providers.md"
4477	switch provider {
4478	case "amazon-bedrock":
4479		return "Amazon Bedrock setup", []string{
4480			"Amazon Bedrock uses AWS credentials instead of a generic zot API-key entry.",
4481			"Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
4482			"",
4483			"For Bedrock API keys, set:",
4484			"  AWS_BEARER_TOKEN_BEDROCK=...",
4485			"  AWS_REGION=us-east-1",
4486			"",
4487			"Docs:",
4488			"  " + docsURL,
4489		}, true
4490	case "google-vertex":
4491		return "Google Vertex AI setup", []string{
4492			"Google Vertex AI usually uses Google Cloud credentials and project settings.",
4493			"Set a Google API key, application-default credentials, or a service account.",
4494			"",
4495			"Common environment:",
4496			"  GOOGLE_CLOUD_API_KEY=...",
4497			"  GOOGLE_CLOUD_PROJECT=...",
4498			"  GOOGLE_CLOUD_LOCATION=us-central1",
4499			"",
4500			"Docs:",
4501			"  " + docsURL,
4502		}, true
4503	case "cloudflare-workers-ai":
4504		return "Cloudflare Workers AI setup", []string{
4505			"Cloudflare Workers AI needs both an API token and an account ID.",
4506			"",
4507			"Set:",
4508			"  CLOUDFLARE_API_KEY=...",
4509			"  CLOUDFLARE_ACCOUNT_ID=...",
4510			"",
4511			"Docs:",
4512			"  " + docsURL,
4513		}, true
4514	case "cloudflare-ai-gateway":
4515		return "Cloudflare AI Gateway setup", []string{
4516			"Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
4517			"",
4518			"Set:",
4519			"  CLOUDFLARE_API_KEY=...",
4520			"  CLOUDFLARE_ACCOUNT_ID=...",
4521			"  CLOUDFLARE_GATEWAY_ID=...",
4522			"",
4523			"Docs:",
4524			"  " + docsURL,
4525		}, true
4526	case "azure-openai-responses":
4527		return "Azure OpenAI Responses setup", []string{
4528			"Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
4529			"",
4530			"Set:",
4531			"  AZURE_OPENAI_API_KEY=...",
4532			"  AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
4533			"  AZURE_OPENAI_API_VERSION=2024-02-01",
4534			"",
4535			"Docs:",
4536			"  " + docsURL,
4537		}, true
4538	default:
4539		return "", nil, false
4540	}
4541}
4542
4543func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
4544	if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
4545		i.dialog.ShowResult(false, "auth store is unavailable")
4546		return
4547	}
4548	if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
4549		i.dialog.ShowResult(false, err.Error())
4550		return
4551	}
4552	i.mu.Lock()
4553	i.llamaConfigured = true
4554	i.statusErr = ""
4555	i.statusOK = "configured llama.cpp router"
4556	i.mu.Unlock()
4557	i.dialog.ShowResult(true, "")
4558}
4559
4560func (i *Interactive) startAPIKeyFlow(provider string) {
4561	if title, lines, ok := providerSetupInfo(provider); ok {
4562		i.dialog.ShowInfo(title, lines)
4563		return
4564	}
4565	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
4566		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
4567	}
4568	url, err := i.cfg.AuthManager.StartAPIKey(provider)
4569	if err != nil {
4570		i.dialog.ShowResult(false, err.Error())
4571		return
4572	}
4573	i.dialog.ShowWaiting(url)
4574}
4575
4576func (i *Interactive) startOAuthFlow(provider string) {
4577	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
4578		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
4579	}
4580	// Device-code providers already support headless login and must only
4581	// start one polling flow.
4582	if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
4583		loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
4584		if err != nil {
4585			i.dialog.ShowResult(false, err.Error())
4586			return
4587		}
4588		i.dialog.ShowWaiting(loginURL)
4589		return
4590	}
4591	// Always run the manual/copy-code flow in parallel with the local
4592	// callback server so headless environments (docker, SSH) can paste
4593	// the authorization code directly without first pressing 'p'.
4594	_, err := i.cfg.AuthManager.StartOAuth(provider)
4595	if err != nil {
4596		i.dialog.ShowResult(false, err.Error())
4597		return
4598	}
4599	manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
4600	if mErr == nil {
4601		i.dialog.ShowWaiting(manualURL)
4602	} else {
4603		i.dialog.ShowResult(false, mErr.Error())
4604	}
4605}
4606
4607func (i *Interactive) startManualOAuthFlow(provider string) {
4608	if i.cfg.AuthManager == nil {
4609		return
4610	}
4611	i.cfg.AuthManager.CancelOAuth()
4612	url, err := i.cfg.AuthManager.StartManualOAuth(provider)
4613	if err != nil {
4614		i.dialog.ShowResult(false, err.Error())
4615		return
4616	}
4617	i.dialog.url = url
4618	i.invalidate()
4619}
4620
4621func (i *Interactive) submitManualOAuthCode(code string) {
4622	if i.cfg.AuthManager == nil {
4623		return
4624	}
4625	go func() {
4626		if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
4627			i.dialog.ShowResult(false, err.Error())
4628			i.invalidate()
4629		}
4630	}()
4631}
4632
4633// applyModelSelection switches the active model (and provider, if the
4634// new model belongs to a different one). It rebuilds the underlying
4635// client when needed so the provider wire-protocol matches.
4636// cancelAndWaitForIdle cancels the active turn (if any) and blocks
4637// briefly until the turn goroutine has updated i.busy = false. Used
4638// before destructive slash commands so transcript-mutating work
4639// (/clear, /compact, /logout, /login completion, cross-provider
4640// /model swap) doesn't race with the still-running stream.
4641//
4642// The wait is bounded; if the turn doesn't release within the timeout
4643// we proceed anyway. Worst case is a brief overlap that the agent's
4644// own mutex protects against.
4645func (i *Interactive) cancelAndWaitForIdle() {
4646	i.mu.Lock()
4647	busy := i.busy
4648	cancel := i.cancelTurn
4649	i.mu.Unlock()
4650	if !busy {
4651		return
4652	}
4653	if cancel != nil {
4654		cancel()
4655	}
4656	// ConfirmToolCall waits on its response channel rather than the turn
4657	// context, so cancellation must also resolve every pending prompt.
4658	i.confirmDialog.CancelAll("turn cancelled")
4659	deadline := time.Now().Add(2 * time.Second)
4660	for time.Now().Before(deadline) {
4661		i.mu.Lock()
4662		done := !i.busy
4663		i.mu.Unlock()
4664		if done {
4665			return
4666		}
4667		time.Sleep(10 * time.Millisecond)
4668	}
4669}
4670
4671// openBtwDialog opens the side-chat overlay with a frozen snapshot
4672// of the current main session. The optional argument is auto-
4673// submitted as the first question, so '/btw does X work?' fires the
4674// model call immediately instead of just opening an empty dialog.
4675func (i *Interactive) openBtwDialog(args []string) {
4676	if i.agent == nil {
4677		i.mu.Lock()
4678		i.statusErr = "not logged in. type /login first."
4679		i.mu.Unlock()
4680		return
4681	}
4682	seed := strings.TrimSpace(strings.Join(args, " "))
4683	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)
4684	i.invalidate()
4685}
4686
4687// openSkillsDialog opens the skill inspector. The picker reflects
4688// whatever SkillSnapshot returns at call time, so edits to a
4689// SKILL.md made during a session show up on the next /skills.
4690func (i *Interactive) openSkillsDialog() {
4691	var list []*skills.Skill
4692	if i.cfg.SkillSnapshot != nil {
4693		list = i.cfg.SkillSnapshot()
4694	}
4695	i.skillsDialog.Open(list)
4696	i.invalidate()
4697}
4698
4699// openJumpDialog builds a /jump picker from the current transcript.
4700// If the user typed "/jump foo" with a filter and it matches exactly
4701// one turn, jump there directly without showing the dialog.
4702func (i *Interactive) openJumpDialog(args []string) {
4703	if i.view == nil || len(i.view.Messages) == 0 {
4704		i.mu.Lock()
4705		i.statusErr = "nothing to jump to \u2014 the session is empty"
4706		i.mu.Unlock()
4707		return
4708	}
4709	filter := strings.TrimSpace(strings.Join(args, " "))
4710	i.jumpDialog.Open(i.view.Messages, filter)
4711	// Shortcut: with a filter argument that matches exactly one turn,
4712	// jump immediately and skip the picker.
4713	if filter != "" {
4714		if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
4715			t := tgts[0]
4716			i.jumpDialog.Close()
4717			i.applyJumpSelection(t.MessageIdx, t.TurnNo)
4718		}
4719	}
4720}
4721
4722// applyJumpSelection scrolls the chat viewport so the user message at
4723// msgIdx is visible at (or near) the top of the chat area. Uses the
4724// anchor slice returned by view.BuildWithAnchors so the mapping from
4725// message index to row is exact, regardless of variable-height tool
4726// blocks above the target.
4727func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
4728	cols := i.lastCols()
4729	chat, anchors := i.view.BuildWithAnchors(cols)
4730	var row int
4731	found := false
4732	for _, a := range anchors {
4733		if a.MessageIdx == msgIdx {
4734			row = a.Row
4735			found = true
4736			break
4737		}
4738	}
4739	if !found {
4740		i.mu.Lock()
4741		i.statusErr = "could not resolve jump target"
4742		i.mu.Unlock()
4743		return
4744	}
4745
4746	chatLen := len(chat)
4747	page := i.chatPage()
4748	if page < 1 {
4749		page = 1
4750	}
4751	// scrollOffset is measured from the bottom of the chat slice, so
4752	// to place `row` at the top of the viewport we want:
4753	//     chatLen - scrollOffset - page == row
4754	// Solve for scrollOffset and clamp to [0, chatLen-page].
4755	offset := chatLen - (row + page)
4756	if offset < 0 {
4757		offset = 0
4758	}
4759	maxOffset := chatLen - page
4760	if maxOffset < 0 {
4761		maxOffset = 0
4762	}
4763	if offset > maxOffset {
4764		offset = maxOffset
4765	}
4766
4767	i.mu.Lock()
4768	i.scrollOffset = offset
4769	i.parkedTurn = turnNo
4770	i.parkedTotal = totalTurnsLocked(i.view.Messages)
4771	i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
4772	i.statusErr = ""
4773	i.mu.Unlock()
4774}
4775
4776// totalTurnsLocked counts user messages in the transcript. Caller is
4777// assumed to hold i.mu (the name is a mild reminder; this function
4778// itself doesn't touch shared state beyond the slice it's handed).
4779func totalTurnsLocked(msgs []provider.Message) int {
4780	n := 0
4781	for _, m := range msgs {
4782		if m.Role == provider.RoleUser {
4783			n++
4784		}
4785	}
4786	return n
4787}
4788
4789// applySessionSelection loads the given session via the cli-provided
4790// callback and snaps the viewport to the bottom (the latest message)
4791// so the user lands at the live tail of the resumed conversation.
4792func (i *Interactive) applySessionSelection(path string) {
4793	if i.cfg.LoadSession == nil {
4794		i.mu.Lock()
4795		i.statusErr = "session loading is not wired in this build"
4796		i.mu.Unlock()
4797		return
4798	}
4799	i.mu.Lock()
4800	if i.sessionLoading {
4801		i.statusErr = "already resuming a session"
4802		i.mu.Unlock()
4803		i.invalidate()
4804		return
4805	}
4806	i.sessionLoading = true
4807	i.statusOK = "resuming session: " + path
4808	i.statusErr = ""
4809	i.mu.Unlock()
4810	i.invalidate()
4811
4812	go func() {
4813		err := i.cfg.LoadSession(path)
4814		i.mu.Lock()
4815		defer i.mu.Unlock()
4816		i.sessionLoading = false
4817		if err != nil {
4818			i.statusErr = err.Error()
4819			i.statusOK = ""
4820			i.invalidate()
4821			return
4822		}
4823		i.statusOK = "resumed session: " + path
4824		i.statusErr = ""
4825		i.parkedTurn = 0
4826		i.parkedTotal = 0
4827		i.scrollOffset = 0
4828		// Fresh transcript swapped in: drop the auto-follow baseline so
4829		// the next render's follow guard doesn't see the wholesale
4830		// length change as a delta and jump the viewport.
4831		i.prevChatLen = 0
4832		i.prevChatCols = 0
4833		i.extNotes = nil
4834		i.view.InvalidateRenderCache()
4835		if i.agent != nil {
4836			i.view.Messages = i.agent.Messages()
4837			i.cumUsage = i.agent.Cost()
4838			if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
4839				i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
4840			} else {
4841				i.lastCtxInput = 0
4842			}
4843			// Snap to the tail again — the swap brought in a fresh
4844			// transcript whose markdown / chroma cost we don't want
4845			// blocking the redraw.
4846			if len(i.view.Messages) > initialResumeTailLimit {
4847				i.view.TailLimit = initialResumeTailLimit
4848			} else {
4849				i.view.TailLimit = 0
4850			}
4851		}
4852		i.invalidate()
4853	}()
4854}
4855
4856// scrollToLastTurn parks the viewport at the most recent user turn,
4857// or at the top if the transcript has no user messages. Used after
4858// resume so the user lands looking at where they left off.
4859func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
4860	if len(msgs) == 0 {
4861		i.mu.Lock()
4862		i.scrollOffset = 0
4863		i.mu.Unlock()
4864		return
4865	}
4866	// Find the last user message index.
4867	lastUser := -1
4868	turnNo, totalTurns := 0, 0
4869	for idx, m := range msgs {
4870		if m.Role == provider.RoleUser {
4871			totalTurns++
4872			lastUser = idx
4873		}
4874	}
4875	if lastUser < 0 {
4876		i.mu.Lock()
4877		i.scrollOffset = 0
4878		i.mu.Unlock()
4879		return
4880	}
4881	turnNo = totalTurns
4882
4883	cols := i.lastCols()
4884	chat, anchors := i.view.BuildWithAnchors(cols)
4885	var row int
4886	found := false
4887	for _, a := range anchors {
4888		if a.MessageIdx == lastUser {
4889			row = a.Row
4890			found = true
4891			break
4892		}
4893	}
4894	if !found {
4895		i.mu.Lock()
4896		i.scrollOffset = 0
4897		i.mu.Unlock()
4898		return
4899	}
4900
4901	chatLen := len(chat)
4902	page := i.chatPage()
4903	if page < 1 {
4904		page = 1
4905	}
4906	offset := chatLen - (row + page)
4907	if offset < 0 {
4908		offset = 0
4909	}
4910	maxOffset := chatLen - page
4911	if maxOffset < 0 {
4912		maxOffset = 0
4913	}
4914	if offset > maxOffset {
4915		offset = maxOffset
4916	}
4917
4918	i.mu.Lock()
4919	i.scrollOffset = offset
4920	// Mark the parked-turn footer so the user sees "viewing turn N of
4921	// M - pgdn to catch up" — same affordance as /jump. Tells them at
4922	// a glance that they're looking at history, not the live tail.
4923	if offset > 0 {
4924		i.parkedTurn = turnNo
4925		i.parkedTotal = totalTurns
4926	}
4927	i.mu.Unlock()
4928	i.invalidate()
4929}
4930
4931func (i *Interactive) applyModelSelection(prov, model string) {
4932	i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
4933}
4934
4935// applyRescueModelSelection is like applyModelSelection but routes
4936// through BuildAgentForRescue so launch-time --api-key / --base-url
4937// overrides are dropped before the new agent is built. Falls back to
4938// the regular builder when the host doesn't wire a rescue builder.
4939func (i *Interactive) applyRescueModelSelection(prov, model string) {
4940	builder := i.cfg.BuildAgentForRescue
4941	if builder == nil {
4942		builder = i.cfg.BuildAgentFor
4943	}
4944	i.swapModel(prov, model, builder, true)
4945}
4946
4947// swapModel applies a /model selection (or a rescue selection) using
4948// the supplied builder. rescue=true tags the success message so the
4949// user can see that launch-time overrides were ignored.
4950func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
4951	if model == "" {
4952		return
4953	}
4954	m, err := provider.FindModel(prov, model)
4955	if err != nil {
4956		i.mu.Lock()
4957		i.statusErr = err.Error()
4958		i.mu.Unlock()
4959		return
4960	}
4961	// Same provider AND not a rescue retry: just swap the model on
4962	// the existing agent. Mixed-API providers dispatch from model metadata,
4963	// so the client remains reusable. Rescue retries always rebuild so a stale
4964	// auth header / base URL can't carry over.
4965	if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
4966		i.mu.Lock()
4967		i.cfg.Model = m.ID
4968		i.agent.Model = m.ID
4969		i.statusOK = "model: " + m.ID
4970		i.statusErr = ""
4971		i.mu.Unlock()
4972		if i.cfg.PersistModel != nil {
4973			i.cfg.PersistModel(i.cfg.Provider, m.ID)
4974		}
4975		return
4976	}
4977	if builder == nil {
4978		i.mu.Lock()
4979		i.statusErr = "cannot switch provider: no builder configured"
4980		i.mu.Unlock()
4981		return
4982	}
4983	// Snapshot the current transcript and cumulative usage BEFORE we
4984	// build the replacement agent so we can hand them off. Without
4985	// this the user perceives the entire session as wiped on a
4986	// cross-provider /model swap.
4987	var carryMsgs []provider.Message
4988	var carryCost provider.Usage
4989	if i.agent != nil {
4990		carryMsgs = i.agent.Messages()
4991		carryCost = i.agent.Cost()
4992	}
4993
4994	ag, p, md, err := builder(m.Provider, m.ID)
4995	if err != nil {
4996		i.mu.Lock()
4997		i.statusErr = err.Error()
4998		i.mu.Unlock()
4999		return
5000	}
5001
5002	// Replay the transcript and seed the cost on the freshly-built
5003	// agent. Messages travel cleanly between providers because they
5004	// use the same provider.Message shape; tool-call ids are local
5005	// to a turn so cross-provider continuation never confuses the
5006	// new model (it just sees the assistant's reply, no orphan
5007	// tool_use blocks because /model swaps are gated to idle state).
5008	if len(carryMsgs) > 0 {
5009		ag.SetMessages(carryMsgs)
5010	}
5011	ag.SeedCost(carryCost)
5012
5013	i.mu.Lock()
5014	i.agent = ag
5015	i.cfg.Provider = p
5016	i.cfg.Model = md
5017	if rescue {
5018		i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
5019	} else {
5020		i.statusOK = "switched to " + p + " / " + md
5021	}
5022	i.statusErr = ""
5023	// Render cache keys are width+content based, so the new agent's
5024	// identical messages will reuse the existing entries. Nothing
5025	// to invalidate.
5026	i.mu.Unlock()
5027	// The new agent was built off the base tool registry, so any
5028	// dynamically-registered tools need to be reattached. The apply
5029	// helpers are no-ops when their feature is inactive, so the
5030	// cross-provider path still works on a vanilla setup.
5031	i.applyAutoSwarmTool(i.autoSwarmEnabled())
5032	i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5033	if i.cfg.PersistModel != nil {
5034		i.cfg.PersistModel(p, md)
5035	}
5036}
5037
5038func (i *Interactive) handleAuthEvent(ev auth.Event) {
5039	switch ev.Kind {
5040	case "started":
5041		i.dialog.ShowWaiting(ev.URL)
5042	case "browser_open":
5043		// no-op
5044	case "error":
5045		i.dialog.ShowResult(false, ev.Message)
5046	case "success":
5047		// Rebuild the agent with the fresh credential.
5048		ag, prov, model, err := i.cfg.BuildAgent()
5049		if err != nil {
5050			i.dialog.ShowResult(false, err.Error())
5051			return
5052		}
5053		i.mu.Lock()
5054		i.agent = ag
5055		i.cfg.Provider = prov
5056		i.cfg.Model = model
5057		i.statusErr = ""
5058		i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
5059		i.mu.Unlock()
5060		i.applyAutoSwarmTool(i.autoSwarmEnabled())
5061		i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5062		i.dialog.ShowResult(true, "")
5063	}
5064}
5065
5066// runCompact invokes core.Agent.Compact and reflects the progress in
5067// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
5068// cancel via the same cancelTurn channel used for normal turns.
5069//
5070// When auto is true the spinner message is pinned to "condensing
5071// history" and the status bar surfaces "(auto)" next to the context
5072// percentage so it's obvious the system triggered this, not the user.
5073func (i *Interactive) runCompact(parent context.Context, auto bool) {
5074	if i.agent == nil {
5075		i.mu.Lock()
5076		i.statusErr = "not logged in. type /login first."
5077		i.mu.Unlock()
5078		return
5079	}
5080	ctx, cancel := context.WithCancel(parent)
5081	i.mu.Lock()
5082	i.busy = true
5083	if auto {
5084		i.spin.StartFixed("condensing history")
5085		i.autoCompacting = true
5086	} else {
5087		i.spin.StartFixed("compacting")
5088	}
5089	i.cancelTurn = cancel
5090	i.statusErr = ""
5091	i.statusOK = ""
5092	// Do NOT set streamOn: the summary text should not be visible
5093	// in the chat while compacting. The user just sees the spinner
5094	// and can keep typing / queue prompts.
5095	i.scrollOffset = 0
5096	i.helpBlock = nil
5097	i.mu.Unlock()
5098	i.invalidate()
5099
5100	go func() {
5101		// Sink discards deltas — we don't stream the summary to the UI.
5102		sink := func(delta string) {}
5103		summary, err := i.agent.Compact(ctx, 4, sink)
5104		_ = summary
5105		i.mu.Lock()
5106		i.busy = false
5107		i.resetStreamingStateLocked()
5108		i.cancelTurn = nil
5109		i.autoCompacting = false
5110
5111		// Drain the queue: if the user typed a prompt while compacting,
5112		// fire it now that the transcript is clean.
5113		var next string
5114		var hasNext bool
5115
5116		switch {
5117		case err != nil && ctx.Err() != nil:
5118			i.statusErr = ""
5119			if auto {
5120				i.statusOK = "auto-condense cancelled"
5121			} else {
5122				i.statusOK = "compaction cancelled"
5123			}
5124			i.queued = nil // drop queue on cancel
5125			if i.agent != nil {
5126				i.agent.DrainQueuedMessages()
5127			}
5128		case err != nil:
5129			i.statusErr = "compaction failed: " + err.Error()
5130			i.statusOK = ""
5131			i.queued = nil // drop queue on error
5132			if i.agent != nil {
5133				i.agent.DrainQueuedMessages()
5134			}
5135		default:
5136			i.statusErr = ""
5137			// Read token count from the compaction message meta.
5138			tokens := ""
5139			msgs := i.agent.Messages()
5140			if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
5141				tokens = msgs[0].Meta["tokens_before"]
5142			}
5143			switch {
5144			case i.pendingPostCompactNote != "":
5145				i.statusOK = i.pendingPostCompactNote
5146			case tokens != "":
5147				i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
5148			default:
5149				i.statusOK = "compacted (ctrl+o to expand)"
5150			}
5151			i.pendingPostCompactNote = ""
5152			i.extNotes = stripAutoCompactNotes(i.extNotes)
5153			i.lastCtxInput = 0
5154			i.toolCalls = map[string]*tui.ToolCallView{}
5155			i.toolOrder = nil
5156			i.toolGate = map[string]int{}
5157			i.view.InvalidateRenderCache()
5158			// Pop queued prompt if any.
5159			if len(i.queued) > 0 {
5160				next, i.queued = i.queued[0], i.queued[1:]
5161				hasNext = true
5162			}
5163		}
5164		i.mu.Unlock()
5165		i.invalidate()
5166
5167		if hasNext {
5168			p := i.runCtx
5169			if p == nil {
5170				p = context.Background()
5171			}
5172			i.startTurn(p, next)
5173		}
5174	}()
5175}
5176
5177// shellEscapeCommand reports whether text is a "!command" shell
5178// escape and, if so, returns the command with the leading '!' (and
5179// surrounding whitespace) stripped. A bare "!" with no command is
5180// treated as not an escape so it falls through to the normal prompt
5181// path rather than running an empty shell.
5182func shellEscapeCommand(text string) (string, bool) {
5183	trimmed := strings.TrimLeft(text, " \t")
5184	if !strings.HasPrefix(trimmed, "!") {
5185		return "", false
5186	}
5187	cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
5188	if cmd == "" {
5189		return "", false
5190	}
5191	return cmd, true
5192}
5193
5194// startShellEscape runs a "!command" in the same shell the bash tool
5195// uses, in the session working directory, honoring the /jail sandbox.
5196// It shares the busy/cancel state with the agent: esc cancels it, and
5197// it refuses to start while a turn or another shell escape is already
5198// in flight. The terminal-log output is parked in i.shellBlock below
5199// the transcript until the next prompt or /clear, so it never enters
5200// the model conversation.
5201func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5202	i.mu.Lock()
5203	if i.busy || i.shellRunning {
5204		i.statusErr = "busy — wait for the current turn to finish before running a shell command"
5205		i.statusOK = ""
5206		i.mu.Unlock()
5207		i.invalidate()
5208		return
5209	}
5210	if parent == nil {
5211		parent = i.runCtx
5212	}
5213	if parent == nil {
5214		parent = context.Background()
5215	}
5216	ctx, cancel := context.WithCancel(parent)
5217	i.busy = true
5218	i.shellRunning = true
5219	i.cancelTurn = cancel
5220	i.statusErr = ""
5221	i.statusOK = ""
5222	i.spin.StartFixed("running shell command")
5223	// A new shell escape replaces the previous block; clear stale
5224	// extension notes the same way a new turn would so the screen
5225	// doesn't accumulate transient state.
5226	i.shellBlock = nil
5227	i.scrollOffset = 0
5228	i.parkedTurn = 0
5229	i.parkedTotal = 0
5230	i.helpBlock = nil
5231	sandbox := i.cfg.Sandbox
5232	cwd := i.cfg.CWD
5233	i.mu.Unlock()
5234	i.invalidate()
5235
5236	go func() {
5237		defer cancel()
5238		raw, _ := json.Marshal(map[string]any{"command": cmd})
5239		bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
5240		res, err := bash.Execute(ctx, raw, nil)
5241
5242		var out string
5243		if err != nil {
5244			out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
5245		} else {
5246			for _, c := range res.Content {
5247				if tb, ok := c.(provider.TextBlock); ok {
5248					out += tb.Text
5249				}
5250			}
5251		}
5252		cancelled := ctx.Err() != nil
5253		failed := err != nil || res.IsError || cancelled
5254		if cancelled {
5255			out += "\n\n[cancelled]"
5256		}
5257
5258		block := i.renderShellBlock(out, failed)
5259
5260		i.mu.Lock()
5261		i.shellRunning = false
5262		i.busy = false
5263		i.cancelTurn = nil
5264		i.shellBlock = block
5265		if failed {
5266			if cancelled {
5267				i.statusErr = "shell command cancelled"
5268			} else {
5269				i.statusErr = "shell command failed"
5270			}
5271			i.statusOK = ""
5272		} else {
5273			i.statusOK = "shell command finished"
5274			i.statusErr = ""
5275		}
5276		i.mu.Unlock()
5277		i.invalidate()
5278	}()
5279}
5280
5281// renderShellBlock turns merged bash output into a styled terminal-log
5282// block: each line colored by overall success (tool/green) or failure
5283// (error/red), with the [exit ...] / [error] footer dimmed via the
5284// muted color so it reads as metadata.
5285func (i *Interactive) renderShellBlock(out string, failed bool) []string {
5286	th := i.cfg.Theme
5287	base := th.Tool
5288	if failed {
5289		base = th.Error
5290	}
5291	out = strings.TrimRight(out, "\n")
5292	lines := strings.Split(out, "\n")
5293	styled := make([]string, 0, len(lines))
5294	for _, line := range lines {
5295		color := base
5296		trimmed := strings.TrimSpace(line)
5297		if strings.HasPrefix(trimmed, "[exit ") || strings.HasPrefix(trimmed, "[error]") || strings.HasPrefix(trimmed, "[cancelled]") {
5298			color = th.Muted
5299		}
5300		styled = append(styled, th.FG256(color, line))
5301	}
5302	return styled
5303}
5304
5305func (i *Interactive) startTurn(parent context.Context, prompt string) {
5306	i.startTurnWithImages(parent, prompt, nil)
5307}
5308
5309func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
5310	if i.agent == nil {
5311		return
5312	}
5313	// Pre-turn safety: if the most recent context measurement is
5314	// already past the auto-compact threshold, condense before
5315	// sending so the next outbound request stays under the limit.
5316	// The condense flow re-fires the user's queued prompt for us, so
5317	// we just hand it off and exit.
5318	i.mu.Lock()
5319	needsPreCompact := !i.autoCompacting && i.shouldAutoCompactLocked()
5320	if needsPreCompact {
5321		if prompt != "" {
5322			i.queued = append([]string{prompt}, i.queued...)
5323		}
5324		i.statusErr = ""
5325		i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
5326		i.pendingPostCompactNote = "context auto-compacted; sending your last message"
5327		i.mu.Unlock()
5328		i.invalidate()
5329		i.runCompact(parent, true)
5330		return
5331	}
5332	i.mu.Unlock()
5333
5334	ctx, cancel := context.WithCancel(parent)
5335	i.mu.Lock()
5336	i.busy = true
5337	i.spin.Start()
5338	i.cancelTurn = cancel
5339	i.statusErr = ""
5340	i.statusOK = ""
5341	i.streaming.Reset()
5342	i.streamOn = true
5343	i.toolCalls = map[string]*tui.ToolCallView{}
5344	i.toolOrder = nil
5345	i.toolGate = map[string]int{}
5346	i.shellBlock = nil // sending a prompt clears any parked shell-escape log
5347	i.extNotes = nil   // ext notes are one-shot; a new prompt clears them
5348	i.scrollOffset = 0 // jump back to the bottom on new turn
5349	// Lift the resume tail cap once the user starts interacting. The
5350	// cap is purely a first-paint optimization (don't markdown the
5351	// whole history before showing anything). Keeping it active during
5352	// a turn makes the rendered chat a sliding window: appended
5353	// messages push older ones off the TOP of the buffer, which the
5354	// renderer must treat as a change above the viewport and repaint
5355	// fully, snapping the terminal's native scrollback to the bottom on
5356	// every streamed chunk. A fresh session has no cap (append-only),
5357	// which is why the jump only shows up in resumed sessions. Dropping
5358	// the cap here makes resumed turns append-only too.
5359	i.view.TailLimit = 0
5360	// Reset the auto-follow baseline so the very next render at
5361	// interactive.go:1053 doesn't see a synthetic shrink between
5362	// "last frame had the previous turn's tool overlay" and
5363	// "this frame had it cleared above". Without this, the guard
5364	// reads delta = -(rows in cleared overlay) and decrements
5365	// scrollOffset, which on terminals that mirror zot's pane
5366	// scroll into the host scrollbar visibly yanks the viewport.
5367	// See autofollow_shrink_test.go for the exact arithmetic.
5368	i.prevChatLen = 0
5369	i.prevChatCols = 0
5370	i.parkedTurn = 0 // starting a turn clears the /jump parked state
5371	i.parkedTotal = 0
5372	i.helpBlock = nil // hide the help block once the user asks something
5373	i.mu.Unlock()
5374	i.invalidate()
5375
5376	sink := func(ev core.AgentEvent) {
5377		i.handleEvent(ev)
5378		i.invalidate()
5379	}
5380
5381	go func() {
5382		err := i.agent.Prompt(ctx, prompt, images, sink)
5383		i.mu.Lock()
5384		i.busy = false
5385		// Don't touch streamPending / streamFlushPending here — the
5386		// pacer may still be draining the final deltas and needs to
5387		// paint them even though Prompt has returned. It will reset
5388		// streamOn on its own once the buffer empties.
5389		if len(i.streamPending) == 0 {
5390			i.streamOn = false
5391		}
5392		i.cancelTurn = nil
5393		if err != nil && ctx.Err() == nil {
5394			i.statusErr = err.Error()
5395		}
5396		// Decide whether to offer a model rescue picker for recoverable
5397		// provider failures (auth/rate/temporary). The picker opens after
5398		// the mutex is released so it can take its own locks freely.
5399		var (
5400			offer       bool
5401			rescueWhy   string
5402			rescueImgs  []provider.ImageBlock
5403			rescueModel string
5404			rescueProv  string
5405			rescueFprov string
5406		)
5407		if err != nil && ctx.Err() == nil {
5408			if ok, reason := classifyRescueError(err); ok {
5409				offer = true
5410				rescueWhy = reason
5411				rescueImgs = images
5412				rescueModel = i.cfg.Model
5413				rescueProv = i.cfg.Provider
5414				rescueFprov = extractFailedProvider(err)
5415				if rescueFprov == "" {
5416					rescueFprov = i.cfg.Provider
5417				}
5418				// Suppress the red banner — the rescue dialog already
5419				// surfaces the failure.
5420				i.statusErr = ""
5421			}
5422		}
5423		// Detect HTTP 413 "payload too large" responses. The provider
5424		// rejected the request because the request body exceeded its
5425		// per-request limit. Token-based auto-compact can miss this
5426		// because the limit is on raw bytes, not tokens. Re-queue the
5427		// prompt so it survives the condense pass and trigger one.
5428		payloadTooLarge := err != nil && ctx.Err() == nil && isPayloadTooLargeError(err)
5429		if payloadTooLarge {
5430			i.statusErr = ""
5431			i.queued = append([]string{prompt}, i.queued...)
5432			i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
5433			i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
5434		}
5435		// Persist the assistant's reply (and every tool row before
5436		// it) to the session file while the turn memory is hot.
5437		// Without this, WriteNewTranscript only fires at zot exit,
5438		// meaning a crash or ungraceful kill drops the whole
5439		// conversation. FlushSession is idempotent (it advances the
5440		// baseline so subsequent flushes only write new rows).
5441		flush := i.cfg.FlushSession
5442		i.mu.Unlock()
5443		if flush != nil {
5444			flush()
5445		}
5446		i.mu.Lock()
5447		// Pop the next queued message, if any, and relaunch.
5448		var next string
5449		var hasNext bool
5450		if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
5451			next, i.queued = i.queued[0], i.queued[1:]
5452			hasNext = true
5453		}
5454		// If the turn was cancelled or errored, drop the queue so the
5455		// user isn't bombarded with stale messages after an interrupt.
5456		if ctx.Err() != nil || err != nil {
5457			i.queued = nil
5458			if i.agent != nil {
5459				i.agent.DrainQueuedMessages()
5460			}
5461		}
5462		// Decide whether the next thing to do is an auto-compaction.
5463		// Only fires when the turn completed cleanly AND no host-side
5464		// or agent-side queued messages are waiting (otherwise a queued
5465		// message would race the condense).
5466		agentQueued := 0
5467		if i.agent != nil {
5468			agentQueued = i.agent.QueuedMessageCount()
5469		}
5470		shouldAutoCompact := !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
5471		i.mu.Unlock()
5472		i.invalidate()
5473		parent := i.runCtx
5474		if parent == nil {
5475			parent = context.Background()
5476		}
5477		switch {
5478		case hasNext:
5479			i.startTurn(parent, next)
5480		case offer:
5481			i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
5482		case payloadTooLarge:
5483			i.runCompact(parent, true)
5484		case shouldAutoCompact:
5485			i.runCompact(parent, true)
5486		}
5487	}()
5488}
5489
5490// openRescueDialog surfaces the rescue model picker after a recoverable
5491// provider failure. The pending prompt + images are stashed on the
5492// Interactive so a later applyRescueSelection can re-run the same turn
5493// against the freshly-picked model. activeProvider/failedProvider are
5494// usually the same, but some clients embed different prefixes in their
5495// errors than the configured provider id, so we accept both.
5496func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
5497	if i.rescueDialog == nil {
5498		return
5499	}
5500	loggedIn := []string{}
5501	if i.cfg.LoggedInProviders != nil {
5502		loggedIn = i.cfg.LoggedInProviders()
5503	}
5504	fprov := failedProvider
5505	if fprov == "" {
5506		fprov = activeProvider
5507	}
5508	i.mu.Lock()
5509	i.pendingRescuePrompt = prompt
5510	i.pendingRescueImages = images
5511	i.mu.Unlock()
5512	i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
5513	i.invalidate()
5514}
5515
5516// applyRescueSelection switches model (cross-provider if needed) and
5517// re-runs the same prompt+images that just failed. Mirrors
5518// applyModelSelection's transcript-carry logic so the user keeps full
5519// session continuity across the swap.
5520func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
5521	if model == "" {
5522		return
5523	}
5524	i.applyRescueModelSelection(prov, model)
5525	i.mu.Lock()
5526	images := i.pendingRescueImages
5527	if prompt == "" {
5528		prompt = i.pendingRescuePrompt
5529	}
5530	i.pendingRescuePrompt = ""
5531	i.pendingRescueImages = nil
5532	i.mu.Unlock()
5533	parent := i.runCtx
5534	if parent == nil {
5535		parent = context.Background()
5536	}
5537	i.startTurnWithImages(parent, prompt, images)
5538}
5539
5540func stripAutoCompactNotes(notes []string) []string {
5541	if len(notes) == 0 {
5542		return notes
5543	}
5544	out := notes[:0]
5545	for _, n := range notes {
5546		if strings.Contains(n, "condensing history") {
5547			continue
5548		}
5549		out = append(out, n)
5550	}
5551	return out
5552}
5553
5554// autoCompactNoteLine returns a styled chat-area note for the
5555// inline auto-compact heads-up. Lives in extNotes so it survives
5556// the busy-spinner overwrite of the status row.
5557func autoCompactNoteLine(th tui.Theme, msg string) string {
5558	return "  " + th.FG256(th.Warning, "⚠ "+msg)
5559}
5560
5561// isPayloadTooLargeError matches HTTP 413 responses surfaced by the
5562// provider clients. The error formatting differs slightly between
5563// providers (anthropic and openai both prepend the status code), so
5564// we look for the canonical 413 marker as well as the conventional
5565// 'payload too large' phrase.
5566func isPayloadTooLargeError(err error) bool {
5567	if err == nil {
5568		return false
5569	}
5570	msg := strings.ToLower(err.Error())
5571	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")
5572}
5573
5574// autoCompactThreshold is the context-window fraction at which the
5575// agent will auto-compact after a turn ends. 0.85 leaves enough
5576// headroom for one more user prompt + response before we bump the
5577// hard limit.
5578const autoCompactThreshold = 0.85
5579
5580// shouldAutoCompactLocked reports whether the last turn pushed context
5581// usage past the auto-compact threshold. Must be called with i.mu
5582// held; it reads lastCtxInput and the current model's context window.
5583func (i *Interactive) shouldAutoCompactLocked() bool {
5584	if i.agent == nil {
5585		return false
5586	}
5587	if i.autoCompacting {
5588		return false
5589	}
5590	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
5591	if err != nil || m.ContextWindow <= 0 {
5592		return false
5593	}
5594	if i.lastCtxInput <= 0 {
5595		return false
5596	}
5597	return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
5598}
5599
5600func (i *Interactive) handleEvent(ev core.AgentEvent) {
5601	i.mu.Lock()
5602	defer i.mu.Unlock()
5603	switch e := ev.(type) {
5604	case core.EvAssistantStart:
5605		// Fires at the top of every oneTurn, including follow-up
5606		// turns after tool use. Without this, the streaming buffer
5607		// is still marked off from the previous assistant message
5608		// and the final summary text pops in all at once instead
5609		// of typewriter-streaming delta by delta.
5610		i.streaming.Reset()
5611		i.streamPending = i.streamPending[:0]
5612		i.streamFlushPending = false
5613		i.streamOn = true
5614		// Clear the live tool-call overlay. Any tools from the
5615		// previous round are now fully folded into the transcript
5616		// (assistant tool_use block + tool role message with the
5617		// result), so keeping them in the overlay would duplicate
5618		// them in the view — once inside the finalised transcript
5619		// and once below the streaming block, with the streaming
5620		// summary sandwiched in between. The next EvToolUseStart
5621		// will populate fresh entries for this turn's tools.
5622		i.toolCalls = map[string]*tui.ToolCallView{}
5623		i.toolOrder = nil
5624		i.toolGate = map[string]int{}
5625	case core.EvTextDelta:
5626		// Buffer into streamPending; the paintPace ticker drains
5627		// it into i.streaming a few runes at a time for a smooth
5628		// typewriter effect independent of upstream chunk size.
5629		i.streamPending = append(i.streamPending, []rune(e.Delta)...)
5630		i.streamOn = true
5631	case core.EvAssistantMessage:
5632		// OnAssistant + telegram mirroring always fire on message
5633		// arrival — they read the FINAL message content, which is
5634		// complete regardless of what's still in the pacer.
5635		i.assistantMessageSideEffects(e.Message)
5636		// If the pacer still has characters to drain, keep streamOn
5637		// true and mark flush pending; the paintPace ticker will
5638		// drain the remainder and reset streaming state when done.
5639		// Otherwise (rare: full-replay sessions, abort paths) clear
5640		// synchronously so a later render doesn't show stale text.
5641		if len(i.streamPending) > 0 {
5642			i.streamFlushPending = true
5643			return
5644		}
5645		i.resetStreamingStateLocked()
5646	case core.EvToolUseStart:
5647		// Live streaming: pre-create the view so the user sees the
5648		// tool call being composed in real time. Any subsequent
5649		// EvToolCall for the same ID updates the same struct (the
5650		// final parsed args + name are already known here).
5651		if _, exists := i.toolCalls[e.ID]; !exists {
5652			i.toolCalls[e.ID] = &tui.ToolCallView{
5653				ID:        e.ID,
5654				Name:      e.Name,
5655				Streaming: true,
5656			}
5657			i.toolOrder = append(i.toolOrder, e.ID)
5658			i.gateToolLocked(e.ID)
5659		}
5660	case core.EvToolUseArgs:
5661		if tc, ok := i.toolCalls[e.ID]; ok {
5662			tc.RawJSONBuf += e.Delta
5663			// Refresh the live path as soon as it parses; used in
5664			// the header (write /Users/pat/Desktop/demo.ts)
5665			// while the content is still streaming.
5666			if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
5667				tc.LivePath = p
5668			} else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
5669				tc.LivePath = p
5670			}
5671		}
5672	case core.EvToolUseEnd:
5673		if tc, ok := i.toolCalls[e.ID]; ok {
5674			tc.Streaming = false
5675		}
5676	case core.EvToolCall:
5677		// If we already pre-created the view during streaming, just
5678		// refresh the final Args summary. Otherwise create a new one
5679		// (non-streaming providers or legacy paths).
5680		if tc, ok := i.toolCalls[e.ID]; ok {
5681			tc.Args = tui.ShortArgs(e.Name, e.Args)
5682			if tc.RawJSONBuf == "" {
5683				tc.RawJSONBuf = string(e.Args)
5684			}
5685			tc.Streaming = false
5686		} else {
5687			i.toolCalls[e.ID] = &tui.ToolCallView{
5688				ID:         e.ID,
5689				Name:       e.Name,
5690				Args:       tui.ShortArgs(e.Name, e.Args),
5691				RawJSONBuf: string(e.Args),
5692			}
5693			i.toolOrder = append(i.toolOrder, e.ID)
5694			i.gateToolLocked(e.ID)
5695		}
5696	case core.EvToolResult:
5697		if tc, ok := i.toolCalls[e.ID]; ok {
5698			tc.Done = true
5699			tc.Error = e.Result.IsError
5700			tc.Preview = ""
5701			var text strings.Builder
5702			for _, c := range e.Result.Content {
5703				if tb, ok := c.(provider.TextBlock); ok {
5704					if text.Len() > 0 {
5705						text.WriteString("\n")
5706					}
5707					text.WriteString(tb.Text)
5708				}
5709			}
5710			tc.Result = text.String()
5711		}
5712		if i.cfg.OnToolResult != nil {
5713			i.cfg.OnToolResult(e.ID, e.Result)
5714		}
5715	case core.EvUsage:
5716		i.cumUsage = e.Cumulative
5717		if e.Usage.InputTokens > 0 {
5718			i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
5719		}
5720	case core.EvTurnEnd:
5721		if e.Stop == provider.StopAborted {
5722			i.resetStreamingStateLocked()
5723			i.statusErr = ""
5724			i.statusOK = "cancelled"
5725			return
5726		}
5727		if e.Stop == provider.StopLength {
5728			// The model hit its output-token cap mid-response, so the
5729			// reply (often a long write/edit) is truncated. Surface it
5730			// explicitly, otherwise the turn just ends and reads like
5731			// the UI gave up. The agent already requests the model's
5732			// full MaxOutput budget, so this means the response genuinely
5733			// exceeded that ceiling; ask the user to continue.
5734			i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
5735			i.statusOK = ""
5736			return
5737		}
5738		// Don't surface mid-loop stream errors as a red banner here.
5739		// EvTurnEnd fires after every step in a multi-step tool loop,
5740		// so a transient 503 / network blip would briefly paint a red
5741		// banner over the still-streaming chat before the agent loop
5742		// either retries or exits. The final error (if any) is set by
5743		// startTurnWithImages once Prompt() returns, and recoverable
5744		// failures are routed to the rescue picker instead — which
5745		// keeps the chat clean while the agent is working.
5746		_ = e.Err
5747	}
5748}
5749
5750// Agent returns the current agent, if any. Used by cli.go to flush the
5751// final transcript to the session file.
5752func (i *Interactive) Agent() *core.Agent {
5753	i.mu.Lock()
5754	defer i.mu.Unlock()
5755	return i.agent
5756}
5757
5758// silence unused import in some build configs
5759var _ = fmt.Sprintf
5760
5761// runReloadExt triggers a live reload of every extension (discovered
5762// + explicit). Runs on a goroutine so the TUI stays responsive; the
5763// Manager.Reload takes a couple of hundred ms to shut down subprocs
5764// and respawn them. Shows a temporary status line throughout.
5765func (i *Interactive) runReloadExt(ctx context.Context) {
5766	if i.cfg.Extensions == nil {
5767		i.mu.Lock()
5768		i.statusErr = "no extension manager in this build"
5769		i.mu.Unlock()
5770		i.invalidate()
5771		return
5772	}
5773	i.mu.Lock()
5774	i.statusOK = "reloading extensions..."
5775	i.statusErr = ""
5776	i.mu.Unlock()
5777	i.invalidate()
5778
5779	go func() {
5780		stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
5781		msg, failed := formatReloadStatus(stats)
5782		seq := i.setReloadStatus(msg, failed)
5783		i.invalidate()
5784		go i.dismissReloadStatus(ctx, seq, msg, failed)
5785	}()
5786}
5787
5788// Confirm implements core.Confirmer. The agent goroutine calls
5789// this synchronously before every tool invocation when --no-yolo is
5790// active. We push the request onto the confirmDialog queue, trigger
5791// a redraw, and block the caller until the user answers.
5792//
5793// If the session is cancelled or the TUI exits mid-prompt, any
5794// pending request is refused via CancelAll so the agent doesn't
5795// deadlock.
5796func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
5797	return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
5798}
5799
5800// ConfirmToolCall attaches a side-effect-free preview to the matching live
5801// tool panel, then blocks until the user approves or refuses the call.
5802func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
5803	resp := make(chan core.ConfirmDecision, 1)
5804	returnToChild := false
5805	if call.ID != "" {
5806		i.mu.Lock()
5807		if tc, ok := i.toolCalls[call.ID]; ok {
5808			tc.Args = call.Summary
5809			tc.Preview = call.Content
5810		}
5811		i.mu.Unlock()
5812		if i.btwDialog != nil && i.btwDialog.Active() {
5813			returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content)
5814		}
5815	}
5816	i.confirmDialog.Enqueue(&confirmRequest{
5817		toolName:      call.Name,
5818		preview:       call.Summary,
5819		resp:          resp,
5820		returnToChild: returnToChild,
5821	})
5822	i.invalidate()
5823	return <-resp
5824}
5825
5826// openTelegramDialog shows the picker for `/telegram` with no arg.
5827// Items depend on current state: disconnect + status when running,
5828// connect + status when stopped.
5829func (i *Interactive) openTelegramDialog() {
5830	items := i.telegramMenuItems()
5831	if len(items) == 0 {
5832		i.mu.Lock()
5833		i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
5834		i.mu.Unlock()
5835		i.invalidate()
5836		return
5837	}
5838	i.telegramDialog.Open(items)
5839	i.invalidate()
5840}
5841
5842// telegramMenuItems builds the dialog entries for the current
5843// bridge state. Returns empty when no bot.json exists so the
5844// caller can show a helpful status line instead of an empty menu.
5845func (i *Interactive) telegramMenuItems() []telegramItem {
5846	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
5847	if err != nil || cfg.BotToken == "" {
5848		return nil
5849	}
5850	var items []telegramItem
5851	if i.telegramBridge != nil && i.telegramBridge.Active() {
5852		items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
5853		st := i.telegramBridge.State()
5854		hint := "active"
5855		if st.Username != "" {
5856			hint += " as @" + st.Username
5857		}
5858		items = append(items, telegramItem{label: "status", action: "status", hint: hint})
5859	} else {
5860		label := "connect"
5861		hint := "start mirroring dms into this session"
5862		if cfg.AllowedUserID == 0 {
5863			hint = "awaiting pairing (send /start to the bot once connected)"
5864		}
5865		items = append(items, telegramItem{label: label, action: "connect", hint: hint})
5866		items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
5867	}
5868	return items
5869}
5870
5871// doTelegram dispatches one of the three explicit actions. Called
5872// from /telegram <action> or after the picker selects a row.
5873func (i *Interactive) doTelegram(action string) {
5874	switch action {
5875	case "connect":
5876		i.telegramConnect()
5877	case "disconnect":
5878		i.telegramDisconnect()
5879	case "status":
5880		i.telegramStatus()
5881	default:
5882		i.mu.Lock()
5883		i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
5884		i.mu.Unlock()
5885		i.invalidate()
5886	}
5887}
5888
5889// telegramConnect starts the bridge. Refuses if it's already
5890// running or if the on-disk bot.json is missing a token.
5891func (i *Interactive) telegramConnect() {
5892	if i.telegramBridge != nil && i.telegramBridge.Active() {
5893		i.mu.Lock()
5894		i.statusOK = "telegram already connected"
5895		i.statusErr = ""
5896		i.mu.Unlock()
5897		i.invalidate()
5898		return
5899	}
5900	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
5901	if err != nil {
5902		i.mu.Lock()
5903		i.statusErr = "telegram: " + err.Error()
5904		i.mu.Unlock()
5905		i.invalidate()
5906		return
5907	}
5908	if cfg.BotToken == "" {
5909		i.mu.Lock()
5910		i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
5911		i.mu.Unlock()
5912		i.invalidate()
5913		return
5914	}
5915	// Refuse to start when a background daemon is already polling
5916	// the same bot. Two concurrent long-poll consumers race each
5917	// update and one always loses, so DMs get half-delivered. The
5918	// user can `zot telegram-bot stop` first, then /telegram connect.
5919	if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
5920		i.mu.Lock()
5921		i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
5922		i.mu.Unlock()
5923		i.invalidate()
5924		return
5925	}
5926	i.telegramBridge = &telegram.Bridge{
5927		Client: telegram.NewClient(cfg.BotToken),
5928		Config: cfg,
5929		Save: func(next telegram.Config) error {
5930			return telegram.SaveConfig(i.cfg.ZotHome, next)
5931		},
5932		Host: &telegramHost{iv: i},
5933	}
5934	if err := i.telegramBridge.Start(i.runCtx); err != nil {
5935		i.telegramBridge = nil
5936		i.mu.Lock()
5937		i.statusErr = "telegram connect failed: " + err.Error()
5938		i.mu.Unlock()
5939		i.invalidate()
5940		return
5941	}
5942	i.applyTelegramTools(true)
5943	state := i.telegramBridge.State()
5944	label := "telegram connected"
5945	if state.Username != "" {
5946		label += " as @" + state.Username
5947	}
5948	if state.PairedID == 0 {
5949		label += " — send /start to the bot from your phone to claim it"
5950	}
5951	i.mu.Lock()
5952	i.statusOK = label
5953	i.statusErr = ""
5954	i.mu.Unlock()
5955	i.invalidate()
5956}
5957
5958// telegramDisconnect stops the bridge. No-op when already stopped.
5959func (i *Interactive) telegramDisconnect() {
5960	if i.telegramBridge == nil || !i.telegramBridge.Active() {
5961		i.mu.Lock()
5962		i.statusOK = "telegram already disconnected"
5963		i.statusErr = ""
5964		i.mu.Unlock()
5965		i.invalidate()
5966		return
5967	}
5968	i.telegramBridge.Stop()
5969	i.applyTelegramTools(false)
5970	i.mu.Lock()
5971	i.statusOK = "telegram disconnected"
5972	i.statusErr = ""
5973	i.mu.Unlock()
5974	i.invalidate()
5975}
5976
5977// telegramSenderAdapter wraps the bridge so the tools package can
5978// drive it without importing telegram directly. The Active() check
5979// is forwarded to the bridge so the tool can fail clearly with a
5980// model-readable error when the user disconnected mid-turn.
5981type telegramSenderAdapter struct {
5982	bridge *telegram.Bridge
5983}
5984
5985func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
5986	if a.bridge == nil {
5987		return fmt.Errorf("telegram bridge is not connected")
5988	}
5989	return a.bridge.SendImage(ctx, path, caption)
5990}
5991
5992func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
5993	if a.bridge == nil {
5994		return fmt.Errorf("telegram bridge is not connected")
5995	}
5996	return a.bridge.SendDocument(ctx, path, caption)
5997}
5998
5999func (a telegramSenderAdapter) Active() bool {
6000	return a.bridge != nil && a.bridge.Active()
6001}
6002
6003// swarmWatchEntry is one tracked auto-swarm sub-agent. Filled in at
6004// spawn time and finalised in trackSwarmAgent's waiter goroutine.
6005type swarmWatchEntry struct {
6006	agent *swarm.Agent
6007	task  string
6008	done  bool
6009	err   string
6010}
6011
6012// TrackSwarmAgent is the exported entry point used by the cli to
6013// hand a freshly-spawned auto-swarm agent off to the watcher.
6014func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) {
6015	i.trackSwarmAgent(a, task)
6016}
6017
6018// trackSwarmAgent records a freshly-spawned auto-swarm agent and
6019// subscribes to its prompt-level task completion events. Sub-agents
6020// are long-lived daemons that keep running on the inbox after the
6021// initial task, so we can't wait on agent.Wait() — it never returns
6022// until the whole daemon dies. Instead we mark each entry done when
6023// the swarm daemon reports the initial prompt has finished, and when
6024// every tracked entry has reported in, flush a single summary into
6025// the main chat.
6026//
6027// Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto-
6028// swarm is enabled, so this is a no-op when the feature is off.
6029func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) {
6030	if i == nil || a == nil {
6031		return
6032	}
6033	entry := &swarmWatchEntry{agent: a, task: task}
6034	i.swarmWatchMu.Lock()
6035	i.swarmWatch = append(i.swarmWatch, entry)
6036	i.swarmWatchMu.Unlock()
6037
6038	a.SetOnTurnEnd(func(step int, errMsg string) {
6039		i.swarmWatchMu.Lock()
6040		if entry.done {
6041			i.swarmWatchMu.Unlock()
6042			return
6043		}
6044		entry.done = true
6045		entry.err = errMsg
6046		allDone := true
6047		for _, e := range i.swarmWatch {
6048			if !e.done {
6049				allDone = false
6050				break
6051			}
6052		}
6053		var batch []*swarmWatchEntry
6054		if allDone {
6055			batch = i.swarmWatch
6056			i.swarmWatch = nil
6057		}
6058		i.swarmWatchMu.Unlock()
6059		if len(batch) == 0 {
6060			return
6061		}
6062		i.flushSwarmSummary(batch)
6063	})
6064}
6065
6066// flushSwarmSummary composes a synthetic user turn describing every
6067// sub-agent's outcome and injects it via SubmitOrQueue so the main
6068// agent picks it up at the next safe boundary. The summary is
6069// phrased as a system update ("Auto-swarm finished: ...") so the
6070// model treats it as observed state, not as a fresh user request.
6071func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) {
6072	if len(batch) == 0 {
6073		return
6074	}
6075	var sb strings.Builder
6076	fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch))
6077	for idx, e := range batch {
6078		snap := e.agent.Snapshot()
6079		status := string(snap.Status)
6080		task := snap.Task
6081		if task == "" {
6082			task = e.task
6083		}
6084		fmt.Fprintf(&sb, "%d. agent %s \u2014 status: %s\n", idx+1, snap.ID, status)
6085		fmt.Fprintf(&sb, "   task: %s\n", truncateForSummary(task, 240))
6086		if snap.Err != "" {
6087			fmt.Fprintf(&sb, "   error: %s\n", truncateForSummary(snap.Err, 240))
6088		} else if e.err != "" {
6089			fmt.Fprintf(&sb, "   turn error: %s\n", truncateForSummary(e.err, 240))
6090		}
6091		if tail := strings.TrimSpace(snap.Tail); tail != "" {
6092			fmt.Fprintf(&sb, "   tail: %s\n", truncateForSummary(tail, 600))
6093		}
6094		sb.WriteString("\n")
6095	}
6096	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.")
6097	i.SubmitOrQueue(sb.String(), nil)
6098}
6099
6100func truncateForSummary(s string, n int) string {
6101	s = strings.TrimSpace(s)
6102	if len(s) <= n {
6103		return s
6104	}
6105	return s[:n-3] + "..."
6106}
6107
6108// applyAutoSwarmSystemPrompt appends (active=true) or strips
6109// (active=false) the auto-swarm system-prompt block on the running
6110// agent so the model proactively considers swarm_spawn when the user
6111// flips the toggle. The block lives at the tail of agent.System so
6112// stripping is a plain suffix-trim; idempotent in both directions.
6113func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
6114	if i.agent == nil {
6115		return
6116	}
6117	addendum := i.cfg.AutoSwarmSystemAddendum
6118	if addendum == "" {
6119		return
6120	}
6121	sys := i.agent.System
6122	has := strings.Contains(sys, addendum)
6123	switch {
6124	case active && !has:
6125		if sys != "" && !strings.HasSuffix(sys, "\n\n") {
6126			sys += "\n\n"
6127		}
6128		i.agent.System = sys + addendum
6129	case !active && has:
6130		i.agent.System = strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n"
6131	}
6132}
6133
6134// applyAutoSwarmTool registers (active=true) or removes (active=false)
6135// the swarm_spawn tool on the running agent so the model only sees it
6136// when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'
6137// snapshot+mutate pattern so extension tools and /reload-ext additions
6138// survive a toggle.
6139func (i *Interactive) autoSwarmEnabled() bool {
6140	return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled
6141}
6142
6143func (i *Interactive) applyAutoSwarmTool(active bool) {
6144	if i.agent == nil {
6145		return
6146	}
6147	current := i.agent.Tools
6148	next := core.Registry{}
6149	for name, t := range current {
6150		if name == "swarm_spawn" {
6151			continue
6152		}
6153		next[name] = t
6154	}
6155	if active && i.cfg.Swarm != nil {
6156		next["swarm_spawn"] = &tools.SwarmSpawnTool{
6157			Swarm:           i.cfg.Swarm,
6158			Enabled:         func() bool { return true },
6159			DefaultModel:    func() string { return i.cfg.Model },
6160			DefaultProvider: func() string { return i.cfg.Provider },
6161			OnSpawned:       i.trackSwarmAgent,
6162		}
6163	}
6164	i.agent.SetTools(next)
6165}
6166
6167// applyTelegramTools registers (active=true) or removes (active=false)
6168// the telegram_send_image and telegram_send_file tools on the running
6169// agent so the model only sees them while the bridge is connected.
6170// Snapshots and mutates the live tool registry so any extension or
6171// /reload-ext additions made while Telegram is connected survive a
6172// later /telegram disconnect (we only add or strip the two telegram
6173// entries, never the rest).
6174func (i *Interactive) applyTelegramTools(active bool) {
6175	if i.agent == nil {
6176		return
6177	}
6178	current := i.agent.Tools
6179	next := core.Registry{}
6180	for name, t := range current {
6181		if name == "telegram_send_image" || name == "telegram_send_file" {
6182			continue
6183		}
6184		next[name] = t
6185	}
6186	if active {
6187		sender := telegramSenderAdapter{bridge: i.telegramBridge}
6188		next["telegram_send_image"] = &tools.TelegramSendImageTool{
6189			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
6190		}
6191		next["telegram_send_file"] = &tools.TelegramSendFileTool{
6192			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
6193		}
6194	}
6195	i.agent.SetTools(next)
6196}
6197
6198// telegramStatus writes a one-liner describing the bridge state.
6199// Reports on both the in-tui bridge and the background daemon so
6200// the user isn't confused when the daemon owns the poll loop.
6201func (i *Interactive) telegramStatus() {
6202	var msg string
6203	if i.telegramBridge != nil && i.telegramBridge.Active() {
6204		s := i.telegramBridge.State()
6205		msg = "telegram: connected (tui bridge)"
6206		if s.Username != "" {
6207			msg += " as @" + s.Username
6208		}
6209		if s.PairedID != 0 {
6210			msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
6211		} else {
6212			msg += " - awaiting pairing"
6213		}
6214	} else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
6215		msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
6216	} else {
6217		cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
6218		if cfg.BotToken == "" {
6219			msg = "telegram: not configured. run `zot telegram-bot setup` first."
6220		} else {
6221			msg = "telegram: disconnected"
6222			if cfg.BotUsername != "" {
6223				msg += " (@" + cfg.BotUsername + " ready to connect)"
6224			}
6225		}
6226	}
6227	i.mu.Lock()
6228	i.statusOK = msg
6229	i.statusErr = ""
6230	i.mu.Unlock()
6231	i.invalidate()
6232}
6233
6234// telegramHost adapts *Interactive to telegram.Host so the bridge
6235// can call back into the TUI without importing modes directly.
6236type telegramHost struct{ iv *Interactive }
6237
6238func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
6239	h.iv.SubmitOrQueue(prompt, images)
6240}
6241
6242func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
6243
6244func (h *telegramHost) Status() string {
6245	h.iv.mu.Lock()
6246	providerName := h.iv.cfg.Provider
6247	model := h.iv.cfg.Model
6248	cwd := h.iv.cfg.CWD
6249	usage := h.iv.cumUsage
6250	subscription := h.iv.cfg.AuthMethod == "oauth"
6251	ctxUsed := h.iv.lastCtxInput
6252	busy := h.iv.busy
6253	queued := len(h.iv.queued)
6254	h.iv.mu.Unlock()
6255
6256	ctxMax := 0
6257	if m, err := provider.FindModel(providerName, model); err == nil {
6258		ctxMax = m.ContextWindow
6259	}
6260	return telegram.FormatStatus(telegram.StatusSnapshot{
6261		Provider:     providerName,
6262		Model:        model,
6263		CWD:          cwd,
6264		Usage:        usage,
6265		Subscription: subscription,
6266		ContextUsed:  ctxUsed,
6267		ContextMax:   ctxMax,
6268		Busy:         busy,
6269		Queued:       queued,
6270	})
6271}
6272
6273func (h *telegramHost) Notify(level, message string) {
6274	h.iv.mu.Lock()
6275	switch level {
6276	case "error", "warn":
6277		h.iv.statusErr = message
6278		h.iv.statusOK = ""
6279	default:
6280		h.iv.statusOK = message
6281		h.iv.statusErr = ""
6282	}
6283	h.iv.mu.Unlock()
6284	h.iv.invalidate()
6285}
6286
6287// openSessionOpsDialog shows the picker for `/session` with no arg.
6288// Always offers export, import, fork, tree; the handlers bail with
6289// a clear status message when the precondition isn't met (empty
6290// transcript on fork; no parent/siblings on tree).
6291func (i *Interactive) openSessionOpsDialog() {
6292	items := []sessionOpsItem{
6293		{label: "export", action: "export", hint: "write the current session to a .zotsession file"},
6294		{label: "import", action: "import", hint: "load a .zotsession file into this directory"},
6295		{label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
6296		{label: "tree", action: "tree", hint: "switch between branches in this directory"},
6297	}
6298	i.sessionOpsDialog.Open(items)
6299	i.invalidate()
6300}
6301
6302// doSessionOp dispatches export, import, fork, or tree. arg is the
6303// optional positional argument from e.g. /session export <path>
6304// or /session import <path>; fork and tree ignore it.
6305func (i *Interactive) doSessionOp(action, arg string) {
6306	switch action {
6307	case "export":
6308		i.doSessionExport(arg)
6309	case "import":
6310		i.doSessionImport(arg)
6311	case "fork":
6312		i.doSessionFork()
6313	case "tree":
6314		i.doSessionTree()
6315	default:
6316		i.mu.Lock()
6317		i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
6318		i.mu.Unlock()
6319		i.invalidate()
6320	}
6321}
6322
6323// doSessionExport writes the live session file to destination path
6324// dst. When dst is empty we default to ~/Downloads (falling back to
6325// the user's home directory if it doesn't exist). The helper
6326// expands a leading `~` and creates any missing parent directories.
6327func (i *Interactive) doSessionExport(dst string) {
6328	if i.cfg.CurrentSessionPath == nil {
6329		i.mu.Lock()
6330		i.statusErr = "export: no session is active (running with --no-session?)"
6331		i.mu.Unlock()
6332		i.invalidate()
6333		return
6334	}
6335	src := i.cfg.CurrentSessionPath()
6336	if src == "" {
6337		i.mu.Lock()
6338		i.statusErr = "export: no session is active (running with --no-session?)"
6339		i.mu.Unlock()
6340		i.invalidate()
6341		return
6342	}
6343	// Persist any in-memory agent messages to the session file so
6344	// the export carries the full conversation. Without this, the
6345	// default lazy-flush-at-exit strategy leaves most of a running
6346	// session unwritten and the export ends up with only the meta.
6347	if i.cfg.FlushSession != nil {
6348		i.cfg.FlushSession()
6349	}
6350	dst = unquotePath(dst)
6351	if dst == "" {
6352		dst = defaultExportDir()
6353	} else {
6354		dst = expandTilde(dst)
6355	}
6356	out, err := core.ExportSession(src, dst)
6357	if err != nil {
6358		i.mu.Lock()
6359		i.statusErr = "export: " + err.Error()
6360		i.mu.Unlock()
6361		i.invalidate()
6362		return
6363	}
6364	i.mu.Lock()
6365	i.statusOK = "exported session to " + friendlyPath(out)
6366	i.statusErr = ""
6367	i.mu.Unlock()
6368	i.invalidate()
6369}
6370
6371// doSessionImport copies the .zotsession file at src into the
6372// running cwd's sessions directory and loads it as the active
6373// session, same as `/sessions` -> pick. When src is empty we ask
6374// the user to pass a path (no usable default here).
6375func (i *Interactive) doSessionImport(src string) {
6376	src = unquotePath(src)
6377	if src == "" {
6378		i.mu.Lock()
6379		i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
6380		i.mu.Unlock()
6381		i.invalidate()
6382		return
6383	}
6384	src = expandTilde(src)
6385	if _, err := os.Stat(src); err != nil {
6386		i.mu.Lock()
6387		i.statusErr = "import: " + err.Error()
6388		i.mu.Unlock()
6389		i.invalidate()
6390		return
6391	}
6392	newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version)
6393	if err != nil {
6394		i.mu.Lock()
6395		i.statusErr = "import: " + err.Error()
6396		i.mu.Unlock()
6397		i.invalidate()
6398		return
6399	}
6400	if i.cfg.LoadSession == nil {
6401		i.mu.Lock()
6402		i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
6403		i.statusErr = ""
6404		i.mu.Unlock()
6405		i.invalidate()
6406		return
6407	}
6408	if err := i.cfg.LoadSession(newPath); err != nil {
6409		i.mu.Lock()
6410		i.statusErr = "import: load failed: " + err.Error()
6411		i.mu.Unlock()
6412		i.invalidate()
6413		return
6414	}
6415	i.mu.Lock()
6416	i.statusOK = "imported and switched to session " + friendlyPath(newPath)
6417	i.statusErr = ""
6418	i.mu.Unlock()
6419	i.invalidate()
6420}
6421
6422// defaultExportDir returns ~/Downloads when it exists, or ~ as a
6423// fallback, or /tmp on exotic machines with no home dir.
6424func defaultExportDir() string {
6425	home, err := os.UserHomeDir()
6426	if err != nil || home == "" {
6427		return os.TempDir()
6428	}
6429	downloads := filepath.Join(home, "Downloads")
6430	if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
6431		return downloads
6432	}
6433	return home
6434}
6435
6436// expandTilde turns a leading ~ into the user's home directory.
6437// Returns the input unchanged when there's no tilde or no home.
6438func expandTilde(p string) string {
6439	if p == "" || p[0] != '~' {
6440		return p
6441	}
6442	home, err := os.UserHomeDir()
6443	if err != nil || home == "" {
6444		return p
6445	}
6446	if len(p) == 1 {
6447		return home
6448	}
6449	if p[1] == '/' || p[1] == filepath.Separator {
6450		return filepath.Join(home, p[2:])
6451	}
6452	return p
6453}
6454
6455// unquotePath strips a matching pair of surrounding single or
6456// double quotes. Drag-drop paste in the tui auto-quotes dropped
6457// file paths so the shell-like `/session import 'foo bar.zs'`
6458// stays well-formed; when the TUI's own slash handler consumes
6459// the arg, we want the raw path back.
6460func unquotePath(p string) string {
6461	p = strings.TrimSpace(p)
6462	if len(p) >= 2 {
6463		first := p[0]
6464		last := p[len(p)-1]
6465		if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
6466			p = p[1 : len(p)-1]
6467		}
6468	}
6469	return p
6470}
6471
6472// friendlyPath collapses the user's home directory to a leading ~
6473// so status messages read cleanly. Falls back to the raw path when
6474// the home dir is unknown.
6475func friendlyPath(p string) string {
6476	home, err := os.UserHomeDir()
6477	if err != nil || home == "" {
6478		return p
6479	}
6480	if strings.HasPrefix(p, home+string(filepath.Separator)) {
6481		return "~" + p[len(home):]
6482	}
6483	return p
6484}
6485
6486// doSessionFork opens the /jump turn picker in "fork mode". The
6487// next selection branches the current session at that user turn
6488// instead of scrolling the viewport to it.
6489func (i *Interactive) doSessionFork() {
6490	if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
6491		i.mu.Lock()
6492		i.statusErr = "fork: no session is active (running with --no-session?)"
6493		i.mu.Unlock()
6494		i.invalidate()
6495		return
6496	}
6497	msgs := []provider.Message{}
6498	if i.agent != nil {
6499		msgs = i.agent.Messages()
6500	}
6501	if len(msgs) == 0 {
6502		i.mu.Lock()
6503		i.statusErr = "fork: transcript is empty; nothing to fork from"
6504		i.mu.Unlock()
6505		i.invalidate()
6506		return
6507	}
6508	i.pendingFork = true
6509	i.jumpDialog.Open(msgs, "")
6510	i.invalidate()
6511}
6512
6513// doSessionTree shows the current session family as an inline branch tree.
6514// Pick an entry to check out that point into a new branch. Subsequent
6515// prompts use only context up to the selected message.
6516func (i *Interactive) doSessionTree() {
6517	current := ""
6518	if i.cfg.CurrentSessionPath != nil {
6519		current = i.cfg.CurrentSessionPath()
6520	}
6521	if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.cfg.ZotHome, i.cfg.CWD, current) {
6522		i.invalidate()
6523		return
6524	}
6525	msgs := []provider.Message{}
6526	if i.agent != nil {
6527		msgs = i.agent.Messages()
6528	}
6529	if len(msgs) == 0 {
6530		i.mu.Lock()
6531		i.statusErr = "tree: no messages in this session yet"
6532		i.mu.Unlock()
6533		i.invalidate()
6534		return
6535	}
6536	if !i.sessionTreeDialog.OpenMessages(msgs) {
6537		i.mu.Lock()
6538		i.statusErr = "tree: no messages to show"
6539		i.mu.Unlock()
6540	}
6541	i.invalidate()
6542}
6543
6544// applySessionTreeMessageSelection checks out the selected message into a
6545// new branch, then switches the running agent to that branch. Unlike /jump,
6546// this changes future model context: only messages up to the selected row are
6547// sent on subsequent turns.
6548func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
6549	if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
6550		i.mu.Lock()
6551		i.statusErr = "tree: session branching is not available in this build"
6552		i.mu.Unlock()
6553		i.invalidate()
6554		return
6555	}
6556	if src == "" {
6557		src = i.cfg.CurrentSessionPath()
6558	}
6559	if src == "" {
6560		i.mu.Lock()
6561		i.statusErr = "tree: no session is active"
6562		i.mu.Unlock()
6563		i.invalidate()
6564		return
6565	}
6566	if i.cfg.FlushSession != nil {
6567		i.cfg.FlushSession()
6568	}
6569	upTo := msgIdx + 1
6570	if role == provider.RoleUser {
6571		upTo = msgIdx
6572	}
6573	newPath, err := core.BranchSessionHidden(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
6574	if err != nil {
6575		i.mu.Lock()
6576		i.statusErr = "tree: " + err.Error()
6577		i.mu.Unlock()
6578		i.invalidate()
6579		return
6580	}
6581	if err := i.cfg.LoadSession(newPath); err != nil {
6582		i.mu.Lock()
6583		i.statusErr = "tree: checkout failed: " + err.Error()
6584		i.mu.Unlock()
6585		i.invalidate()
6586		return
6587	}
6588	i.scrollToBottom()
6589	i.mu.Lock()
6590	if role == provider.RoleUser {
6591		i.ed.SetValue(prompt)
6592		i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
6593	} else {
6594		i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
6595	}
6596	i.statusErr = ""
6597	i.mu.Unlock()
6598	i.invalidate()
6599}
6600
6601// applyForkSelection branches the current session at msgIdx+1 (so
6602// the selected user message and everything before it is included
6603// in the new branch), then switches the running agent to the new
6604// file. Called from the jump-dialog handler when pendingFork=true.
6605func (i *Interactive) applyForkSelection(msgIdx int) {
6606	i.pendingFork = false
6607	if i.cfg.CurrentSessionPath == nil {
6608		i.mu.Lock()
6609		i.statusErr = "fork: no session is active"
6610		i.mu.Unlock()
6611		i.invalidate()
6612		return
6613	}
6614	src := i.cfg.CurrentSessionPath()
6615	if src == "" {
6616		i.mu.Lock()
6617		i.statusErr = "fork: no session is active"
6618		i.mu.Unlock()
6619		i.invalidate()
6620		return
6621	}
6622	if i.cfg.FlushSession != nil {
6623		i.cfg.FlushSession()
6624	}
6625	// msgIdx is 0-indexed message position; copy msgIdx+1 rows so
6626	// the selected user message is included.
6627	upTo := msgIdx + 1
6628	newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
6629	if err != nil {
6630		i.mu.Lock()
6631		i.statusErr = "fork: " + err.Error()
6632		i.mu.Unlock()
6633		i.invalidate()
6634		return
6635	}
6636	if i.cfg.LoadSession == nil {
6637		i.mu.Lock()
6638		i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
6639		i.statusErr = ""
6640		i.mu.Unlock()
6641		i.invalidate()
6642		return
6643	}
6644	if err := i.cfg.LoadSession(newPath); err != nil {
6645		i.mu.Lock()
6646		i.statusErr = "fork: switch failed: " + err.Error()
6647		i.mu.Unlock()
6648		i.invalidate()
6649		return
6650	}
6651	i.mu.Lock()
6652	i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
6653	i.statusErr = ""
6654	i.mu.Unlock()
6655	i.invalidate()
6656}
6657
6658// formatInt is a tiny strconv.Itoa shim; keeps the handler above
6659// from needing a strconv import just for one call.
6660func formatInt(n int) string {
6661	return fmt.Sprintf("%d", n)
6662}
6663
6664// assistantText returns the concatenated text of every TextBlock in
6665// m. Used by the streaming-view dedupe guard to tell when a live
6666// streamed reply has already been promoted into the transcript.
6667func assistantText(m provider.Message) string {
6668	var sb strings.Builder
6669	for _, c := range m.Content {
6670		if tb, ok := c.(provider.TextBlock); ok {
6671			if sb.Len() > 0 {
6672				sb.WriteByte('\n')
6673			}
6674			sb.WriteString(tb.Text)
6675		}
6676	}
6677	return sb.String()
6678}
6679
6680// resetStreamingStateLocked clears every piece of streaming state
6681// in one shot. Used by abort paths (turn cancel, compact finish,
6682// queue drain) so the pacer doesn't keep draining stale runes from
6683// a prior turn. Must be called with i.mu held.
6684func (i *Interactive) resetStreamingStateLocked() {
6685	i.streaming.Reset()
6686	i.streamPending = i.streamPending[:0]
6687	i.streamFlushPending = false
6688	i.streamOn = false
6689	i.openAllToolGatesLocked()
6690}
6691
6692// openAllToolGatesLocked drops every pending tool gate so that any
6693// tool registered during this turn renders unconditionally from now
6694// on. Called when streaming finalizes (the paced text has fully
6695// drained and `streaming` is about to reset to length 0): without
6696// this, the gate comparison against a freshly-reset streaming buffer
6697// would wrongly re-hide tools that had already cleared their gate.
6698// Must be called with i.mu held.
6699func (i *Interactive) openAllToolGatesLocked() {
6700	for id := range i.toolGate {
6701		i.toolGate[id] = 0
6702	}
6703}
6704
6705// gateToolLocked records the stream position at which a tool call may
6706// become visible. The gate is the total length the streaming buffer
6707// will reach once the pacer has drained everything currently queued
6708// (already painted + still pending). Holding the tool block back
6709// until the pacer crosses that mark guarantees the prose emitted
6710// before the tool call finishes typing out above it, instead of the
6711// tool block snapping in while the paragraph is still filling in.
6712//
6713// We only gate while text is actively streaming. If no stream is in
6714// flight (gate 0), the tool shows immediately, which is the correct
6715// behaviour for tool-only turns and replayed sessions. First
6716// registration wins so a later EvToolCall can't move an existing
6717// gate. Must be called with i.mu held.
6718func (i *Interactive) gateToolLocked(id string) {
6719	if _, ok := i.toolGate[id]; ok {
6720		return
6721	}
6722	if !i.streamOn {
6723		i.toolGate[id] = 0
6724		return
6725	}
6726	i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
6727}
6728
6729// toolGateOpenLocked reports whether the gated tool block may render
6730// yet, i.e. the pacer has drained enough text to reach the position
6731// recorded when the tool call arrived. Must be called with i.mu held.
6732func (i *Interactive) toolGateOpenLocked(id string) bool {
6733	gate, ok := i.toolGate[id]
6734	if !ok || gate == 0 {
6735		return true
6736	}
6737	return i.streaming.Len() >= gate
6738}
6739
6740// assistantMessageSideEffects runs the non-visual hooks attached to
6741// EvAssistantMessage: the host-provided OnAssistant callback and the
6742// telegram-bridge mirror. Called with i.mu held.
6743//
6744// Factored out of handleEvent because the streaming pacer may defer
6745// visual reset until after the last buffered rune has painted, but
6746// the callbacks themselves must fire on message arrival so
6747// downstream observers (session persistence, telegram, cost panels)
6748// don't wait on a UI animation to catch up.
6749func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
6750	if i.cfg.OnAssistant != nil {
6751		i.cfg.OnAssistant(m)
6752	}
6753	if i.telegramBridge != nil && i.telegramBridge.Active() {
6754		var sb strings.Builder
6755		for _, c := range m.Content {
6756			if tb, ok := c.(provider.TextBlock); ok {
6757				if sb.Len() > 0 {
6758					sb.WriteString("\n")
6759				}
6760				sb.WriteString(tb.Text)
6761			}
6762		}
6763		if text := sb.String(); strings.TrimSpace(text) != "" {
6764			go i.telegramBridge.OnAssistantText(text)
6765		}
6766	}
6767}
6768
6769// paintPaceRate is how many runes the streaming pacer releases per
6770// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
6771// that a 500-rune summary finishes in ~1.3s, slow enough to look
6772// like a human typing. Empirically matches the feel of provider
6773// paths that already drip-stream natively.
6774const paintPaceRate = 6
6775
6776// paintPaceInterval is the tick interval for the streaming pacer.
6777// 16ms lines up with the redraw throttle so we never paint faster
6778// than the terminal can keep up.
6779const paintPaceInterval = 16 * time.Millisecond
6780
6781// runStreamPacer drains buffered deltas from streamPending into
6782// streaming a small batch per tick, invalidating after each move.
6783// It stops when the context cancels (tui shutdown).
6784//
6785// Why a pacer: providers differ wildly in how they chunk their
6786// text_delta events. The API-key path on Anthropic emits ~30 drips
6787// for a 400-token summary; the OAuth path can coalesce the same
6788// summary into 3 fat chunks, visually indistinguishable from "the
6789// whole reply just appeared". The pacer normalizes that so every
6790// path looks the same on screen.
6791func (i *Interactive) runStreamPacer(ctx context.Context) {
6792	t := time.NewTicker(paintPaceInterval)
6793	defer t.Stop()
6794	for {
6795		select {
6796		case <-ctx.Done():
6797			return
6798		case <-t.C:
6799			i.mu.Lock()
6800			if len(i.streamPending) == 0 {
6801				// EvAssistantMessage already fired but the pacer
6802				// was still draining a tick ago. Everything is now
6803				// painted; clear the streaming flags so the next
6804				// redraw shows the finalised transcript message
6805				// and hides the streaming overlay.
6806				if i.streamFlushPending {
6807					i.streamFlushPending = false
6808					i.streaming.Reset()
6809					i.streamOn = false
6810					i.openAllToolGatesLocked()
6811					i.mu.Unlock()
6812					i.invalidate()
6813					continue
6814				}
6815				i.mu.Unlock()
6816				continue
6817			}
6818			n := paintPaceRate
6819			if n > len(i.streamPending) {
6820				n = len(i.streamPending)
6821			}
6822			i.streaming.WriteString(string(i.streamPending[:n]))
6823			i.streamPending = i.streamPending[n:]
6824			i.mu.Unlock()
6825			i.invalidate()
6826		}
6827	}
6828}

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