v0.3.3 2d8ec25

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

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