v0.2.4 dfd2501

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close