v0.2.44 4bec50a

interactive.go RawHistoryFolder

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

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