v0.2.85 6b4f716

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

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