v0.2.8 ec5eb20

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

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