v0.2.38 6d28881

interactive.go RawHistoryFolder

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

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