v0.2.35 1dc884a

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

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