v0.2.52 4694534

interactive.go RawHistoryFolder

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

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