v0.2.48 599d866

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

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