v0.1.99 fb3e1af

interactive.go RawHistoryFolder

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

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