v0.2.53 c7f7c01

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

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