v0.2.59 902f459

interactive.go RawHistoryFolder

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

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 476

↑↓ moveenter openesc close