v0.1.88 14ffdb6

interactive.go RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close