v0.1.86 d46f1d5

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", "openai", "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 len(items) == 0 {
2924		i.mu.Lock()
2925		i.statusOK = "no credentials stored; already logged out"
2926		i.statusErr = ""
2927		i.mu.Unlock()
2928		i.invalidate()
2929		return
2930	}
2931	if len(items) > 1 {
2932		items = append(items, logoutItem{label: "all", target: "all"})
2933	}
2934
2935	i.logoutDialog.Open(items)
2936	i.invalidate()
2937}
2938
2939// doLogout clears credentials for the given provider (or all providers)
2940// from auth.json. If the active agent was using those credentials, it
2941// is torn down so the user is forced through /login before their next
2942// prompt.
2943//
2944// target: "anthropic" | "openai" | "kimi" | "all"
2945func (i *Interactive) doLogout(target string) {
2946	if i.cfg.AuthManager == nil {
2947		i.mu.Lock()
2948		i.statusErr = "no auth manager configured"
2949		i.mu.Unlock()
2950		return
2951	}
2952	store := i.cfg.AuthManager.Store()
2953	if store == nil {
2954		i.mu.Lock()
2955		i.statusErr = "auth store is not available"
2956		i.mu.Unlock()
2957		return
2958	}
2959
2960	var providers []string
2961	switch target {
2962	case "", "all":
2963		providers = []string{"anthropic", "openai", "kimi", "google"}
2964	case "anthropic", "openai", "kimi", "google":
2965		providers = []string{target}
2966	default:
2967		i.mu.Lock()
2968		i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, google, or all)"
2969		i.mu.Unlock()
2970		return
2971	}
2972
2973	var errs []string
2974	clearedCurrent := false
2975	for _, p := range providers {
2976		if err := store.Clear(p); err != nil {
2977			errs = append(errs, p+": "+err.Error())
2978			continue
2979		}
2980		if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
2981			if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
2982				errs = append(errs, p+": "+err.Error())
2983				continue
2984			}
2985		}
2986		if p == i.cfg.Provider {
2987			clearedCurrent = true
2988		}
2989	}
2990
2991	i.mu.Lock()
2992	defer i.mu.Unlock()
2993	if len(errs) > 0 {
2994		i.statusErr = "logout errors: " + strings.Join(errs, "; ")
2995		return
2996	}
2997	i.statusErr = ""
2998	if clearedCurrent {
2999		// The running agent was using a credential we just wiped. Drop
3000		// it so prompts can't go out with the stale client, and hint at
3001		// /login.
3002		i.agent = nil
3003		i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
3004	} else {
3005		i.statusOK = "logged out of " + strings.Join(providers, ", ")
3006	}
3007}
3008
3009func (i *Interactive) startAPIKeyFlow(provider string) {
3010	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
3011		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
3012	}
3013	url, err := i.cfg.AuthManager.StartAPIKey(provider)
3014	if err != nil {
3015		i.dialog.ShowResult(false, err.Error())
3016		return
3017	}
3018	i.dialog.ShowWaiting(url)
3019}
3020
3021func (i *Interactive) startOAuthFlow(provider string) {
3022	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
3023		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
3024	}
3025	// Always run the manual/copy-code flow in parallel with the local
3026	// callback server so headless environments (docker, SSH) can paste
3027	// the authorization code directly without first pressing 'p'.
3028	_, err := i.cfg.AuthManager.StartOAuth(provider)
3029	if err != nil {
3030		i.dialog.ShowResult(false, err.Error())
3031		return
3032	}
3033	manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
3034	if mErr == nil {
3035		i.dialog.ShowWaiting(manualURL)
3036	} else {
3037		i.dialog.ShowResult(false, mErr.Error())
3038	}
3039}
3040
3041func (i *Interactive) startManualOAuthFlow(provider string) {
3042	if i.cfg.AuthManager == nil {
3043		return
3044	}
3045	i.cfg.AuthManager.CancelOAuth()
3046	url, err := i.cfg.AuthManager.StartManualOAuth(provider)
3047	if err != nil {
3048		i.dialog.ShowResult(false, err.Error())
3049		return
3050	}
3051	i.dialog.url = url
3052	i.invalidate()
3053}
3054
3055func (i *Interactive) submitManualOAuthCode(code string) {
3056	if i.cfg.AuthManager == nil {
3057		return
3058	}
3059	go func() {
3060		if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
3061			i.dialog.ShowResult(false, err.Error())
3062			i.invalidate()
3063		}
3064	}()
3065}
3066
3067// applyModelSelection switches the active model (and provider, if the
3068// new model belongs to a different one). It rebuilds the underlying
3069// client when needed so the provider wire-protocol matches.
3070// cancelAndWaitForIdle cancels the active turn (if any) and blocks
3071// briefly until the turn goroutine has updated i.busy = false. Used
3072// before destructive slash commands so transcript-mutating work
3073// (/clear, /compact, /logout, /login completion, cross-provider
3074// /model swap) doesn't race with the still-running stream.
3075//
3076// The wait is bounded; if the turn doesn't release within the timeout
3077// we proceed anyway. Worst case is a brief overlap that the agent's
3078// own mutex protects against.
3079func (i *Interactive) cancelAndWaitForIdle() {
3080	i.mu.Lock()
3081	busy := i.busy
3082	cancel := i.cancelTurn
3083	i.mu.Unlock()
3084	if !busy {
3085		return
3086	}
3087	if cancel != nil {
3088		cancel()
3089	}
3090	deadline := time.Now().Add(2 * time.Second)
3091	for time.Now().Before(deadline) {
3092		i.mu.Lock()
3093		done := !i.busy
3094		i.mu.Unlock()
3095		if done {
3096			return
3097		}
3098		time.Sleep(10 * time.Millisecond)
3099	}
3100}
3101
3102// openBtwDialog opens the side-chat overlay with a frozen snapshot
3103// of the current main session. The optional argument is auto-
3104// submitted as the first question, so '/btw does X work?' fires the
3105// model call immediately instead of just opening an empty dialog.
3106func (i *Interactive) openBtwDialog(args []string) {
3107	if i.agent == nil {
3108		i.mu.Lock()
3109		i.statusErr = "not logged in. type /login first."
3110		i.mu.Unlock()
3111		return
3112	}
3113	seed := strings.TrimSpace(strings.Join(args, " "))
3114	i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.invalidate)
3115	i.invalidate()
3116}
3117
3118// openSkillsDialog opens the skill inspector. The picker reflects
3119// whatever SkillSnapshot returns at call time, so edits to a
3120// SKILL.md made during a session show up on the next /skills.
3121func (i *Interactive) openSkillsDialog() {
3122	var list []*skills.Skill
3123	if i.cfg.SkillSnapshot != nil {
3124		list = i.cfg.SkillSnapshot()
3125	}
3126	i.skillsDialog.Open(list)
3127	i.invalidate()
3128}
3129
3130// openJumpDialog builds a /jump picker from the current transcript.
3131// If the user typed "/jump foo" with a filter and it matches exactly
3132// one turn, jump there directly without showing the dialog.
3133func (i *Interactive) openJumpDialog(args []string) {
3134	if i.view == nil || len(i.view.Messages) == 0 {
3135		i.mu.Lock()
3136		i.statusErr = "nothing to jump to \u2014 the session is empty"
3137		i.mu.Unlock()
3138		return
3139	}
3140	filter := strings.TrimSpace(strings.Join(args, " "))
3141	i.jumpDialog.Open(i.view.Messages, filter)
3142	// Shortcut: with a filter argument that matches exactly one turn,
3143	// jump immediately and skip the picker.
3144	if filter != "" {
3145		if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
3146			t := tgts[0]
3147			i.jumpDialog.Close()
3148			i.applyJumpSelection(t.MessageIdx, t.TurnNo)
3149		}
3150	}
3151}
3152
3153// applyJumpSelection scrolls the chat viewport so the user message at
3154// msgIdx is visible at (or near) the top of the chat area. Uses the
3155// anchor slice returned by view.BuildWithAnchors so the mapping from
3156// message index to row is exact, regardless of variable-height tool
3157// blocks above the target.
3158func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
3159	cols := i.lastCols()
3160	chat, anchors := i.view.BuildWithAnchors(cols)
3161	var row int
3162	found := false
3163	for _, a := range anchors {
3164		if a.MessageIdx == msgIdx {
3165			row = a.Row
3166			found = true
3167			break
3168		}
3169	}
3170	if !found {
3171		i.mu.Lock()
3172		i.statusErr = "could not resolve jump target"
3173		i.mu.Unlock()
3174		return
3175	}
3176
3177	chatLen := len(chat)
3178	page := i.chatPage()
3179	if page < 1 {
3180		page = 1
3181	}
3182	// scrollOffset is measured from the bottom of the chat slice, so
3183	// to place `row` at the top of the viewport we want:
3184	//     chatLen - scrollOffset - page == row
3185	// Solve for scrollOffset and clamp to [0, chatLen-page].
3186	offset := chatLen - (row + page)
3187	if offset < 0 {
3188		offset = 0
3189	}
3190	maxOffset := chatLen - page
3191	if maxOffset < 0 {
3192		maxOffset = 0
3193	}
3194	if offset > maxOffset {
3195		offset = maxOffset
3196	}
3197
3198	i.mu.Lock()
3199	i.scrollOffset = offset
3200	i.parkedTurn = turnNo
3201	i.parkedTotal = totalTurnsLocked(i.view.Messages)
3202	i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
3203	i.statusErr = ""
3204	i.mu.Unlock()
3205}
3206
3207// totalTurnsLocked counts user messages in the transcript. Caller is
3208// assumed to hold i.mu (the name is a mild reminder; this function
3209// itself doesn't touch shared state beyond the slice it's handed).
3210func totalTurnsLocked(msgs []provider.Message) int {
3211	n := 0
3212	for _, m := range msgs {
3213		if m.Role == provider.RoleUser {
3214			n++
3215		}
3216	}
3217	return n
3218}
3219
3220// applySessionSelection loads the given session via the cli-provided
3221// callback and snaps the viewport to the bottom (the latest message)
3222// so the user lands at the live tail of the resumed conversation.
3223func (i *Interactive) applySessionSelection(path string) {
3224	if i.cfg.LoadSession == nil {
3225		i.mu.Lock()
3226		i.statusErr = "session loading is not wired in this build"
3227		i.mu.Unlock()
3228		return
3229	}
3230	i.mu.Lock()
3231	if i.sessionLoading {
3232		i.statusErr = "already resuming a session"
3233		i.mu.Unlock()
3234		i.invalidate()
3235		return
3236	}
3237	i.sessionLoading = true
3238	i.statusOK = "resuming session: " + path
3239	i.statusErr = ""
3240	i.mu.Unlock()
3241	i.invalidate()
3242
3243	go func() {
3244		err := i.cfg.LoadSession(path)
3245		i.mu.Lock()
3246		defer i.mu.Unlock()
3247		i.sessionLoading = false
3248		if err != nil {
3249			i.statusErr = err.Error()
3250			i.statusOK = ""
3251			i.invalidate()
3252			return
3253		}
3254		i.statusOK = "resumed session: " + path
3255		i.statusErr = ""
3256		i.parkedTurn = 0
3257		i.parkedTotal = 0
3258		i.scrollOffset = 0
3259		i.extNotes = nil
3260		i.view.InvalidateRenderCache()
3261		if i.agent != nil {
3262			i.view.Messages = i.agent.Messages()
3263			i.cumUsage = i.agent.Cost()
3264		}
3265		i.invalidate()
3266	}()
3267}
3268
3269// scrollToLastTurn parks the viewport at the most recent user turn,
3270// or at the top if the transcript has no user messages. Used after
3271// resume so the user lands looking at where they left off.
3272func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
3273	if len(msgs) == 0 {
3274		i.mu.Lock()
3275		i.scrollOffset = 0
3276		i.mu.Unlock()
3277		return
3278	}
3279	// Find the last user message index.
3280	lastUser := -1
3281	turnNo, totalTurns := 0, 0
3282	for idx, m := range msgs {
3283		if m.Role == provider.RoleUser {
3284			totalTurns++
3285			lastUser = idx
3286		}
3287	}
3288	if lastUser < 0 {
3289		i.mu.Lock()
3290		i.scrollOffset = 0
3291		i.mu.Unlock()
3292		return
3293	}
3294	turnNo = totalTurns
3295
3296	cols := i.lastCols()
3297	chat, anchors := i.view.BuildWithAnchors(cols)
3298	var row int
3299	found := false
3300	for _, a := range anchors {
3301		if a.MessageIdx == lastUser {
3302			row = a.Row
3303			found = true
3304			break
3305		}
3306	}
3307	if !found {
3308		i.mu.Lock()
3309		i.scrollOffset = 0
3310		i.mu.Unlock()
3311		return
3312	}
3313
3314	chatLen := len(chat)
3315	page := i.chatPage()
3316	if page < 1 {
3317		page = 1
3318	}
3319	offset := chatLen - (row + page)
3320	if offset < 0 {
3321		offset = 0
3322	}
3323	maxOffset := chatLen - page
3324	if maxOffset < 0 {
3325		maxOffset = 0
3326	}
3327	if offset > maxOffset {
3328		offset = maxOffset
3329	}
3330
3331	i.mu.Lock()
3332	i.scrollOffset = offset
3333	// Mark the parked-turn footer so the user sees "viewing turn N of
3334	// M - pgdn to catch up" — same affordance as /jump. Tells them at
3335	// a glance that they're looking at history, not the live tail.
3336	if offset > 0 {
3337		i.parkedTurn = turnNo
3338		i.parkedTotal = totalTurns
3339	}
3340	i.mu.Unlock()
3341	i.invalidate()
3342}
3343
3344func (i *Interactive) applyModelSelection(prov, model string) {
3345	i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
3346}
3347
3348// applyRescueModelSelection is like applyModelSelection but routes
3349// through BuildAgentForRescue so launch-time --api-key / --base-url
3350// overrides are dropped before the new agent is built. Falls back to
3351// the regular builder when the host doesn't wire a rescue builder.
3352func (i *Interactive) applyRescueModelSelection(prov, model string) {
3353	builder := i.cfg.BuildAgentForRescue
3354	if builder == nil {
3355		builder = i.cfg.BuildAgentFor
3356	}
3357	i.swapModel(prov, model, builder, true)
3358}
3359
3360// swapModel applies a /model selection (or a rescue selection) using
3361// the supplied builder. rescue=true tags the success message so the
3362// user can see that launch-time overrides were ignored.
3363func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
3364	if model == "" {
3365		return
3366	}
3367	m, err := provider.FindModel(prov, model)
3368	if err != nil {
3369		i.mu.Lock()
3370		i.statusErr = err.Error()
3371		i.mu.Unlock()
3372		return
3373	}
3374	// Same provider AND not a rescue retry: just swap the model on
3375	// the existing agent — no rebuild needed because the underlying
3376	// client is reusable. Rescue retries always rebuild so a stale
3377	// auth header / base URL can't carry over.
3378	if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
3379		i.mu.Lock()
3380		i.cfg.Model = m.ID
3381		i.agent.Model = m.ID
3382		i.statusOK = "model: " + m.ID
3383		i.statusErr = ""
3384		i.mu.Unlock()
3385		if i.cfg.PersistModel != nil {
3386			i.cfg.PersistModel(i.cfg.Provider, m.ID)
3387		}
3388		return
3389	}
3390	if builder == nil {
3391		i.mu.Lock()
3392		i.statusErr = "cannot switch provider: no builder configured"
3393		i.mu.Unlock()
3394		return
3395	}
3396	// Snapshot the current transcript and cumulative usage BEFORE we
3397	// build the replacement agent so we can hand them off. Without
3398	// this the user perceives the entire session as wiped on a
3399	// cross-provider /model swap.
3400	var carryMsgs []provider.Message
3401	var carryCost provider.Usage
3402	if i.agent != nil {
3403		carryMsgs = i.agent.Messages()
3404		carryCost = i.agent.Cost()
3405	}
3406
3407	ag, p, md, err := builder(m.Provider, m.ID)
3408	if err != nil {
3409		i.mu.Lock()
3410		i.statusErr = err.Error()
3411		i.mu.Unlock()
3412		return
3413	}
3414
3415	// Replay the transcript and seed the cost on the freshly-built
3416	// agent. Messages travel cleanly between providers because they
3417	// use the same provider.Message shape; tool-call ids are local
3418	// to a turn so cross-provider continuation never confuses the
3419	// new model (it just sees the assistant's reply, no orphan
3420	// tool_use blocks because /model swaps are gated to idle state).
3421	if len(carryMsgs) > 0 {
3422		ag.SetMessages(carryMsgs)
3423	}
3424	ag.SeedCost(carryCost)
3425
3426	i.mu.Lock()
3427	i.agent = ag
3428	i.cfg.Provider = p
3429	i.cfg.Model = md
3430	if rescue {
3431		i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
3432	} else {
3433		i.statusOK = "switched to " + p + " / " + md
3434	}
3435	i.statusErr = ""
3436	// Render cache keys are width+content based, so the new agent's
3437	// identical messages will reuse the existing entries. Nothing
3438	// to invalidate.
3439	i.mu.Unlock()
3440	// The new agent was built off the base tool registry, so any
3441	// dynamically-registered tools (telegram_send_*) need to be
3442	// reattached. applyTelegramTools is a no-op when the bridge is
3443	// idle so the cross-provider path still works on a vanilla setup.
3444	i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
3445	if i.cfg.PersistModel != nil {
3446		i.cfg.PersistModel(p, md)
3447	}
3448}
3449
3450func (i *Interactive) handleAuthEvent(ev auth.Event) {
3451	switch ev.Kind {
3452	case "started":
3453		i.dialog.ShowWaiting(ev.URL)
3454	case "browser_open":
3455		// no-op
3456	case "error":
3457		i.dialog.ShowResult(false, ev.Message)
3458	case "success":
3459		// Rebuild the agent with the fresh credential.
3460		ag, prov, model, err := i.cfg.BuildAgent()
3461		if err != nil {
3462			i.dialog.ShowResult(false, err.Error())
3463			return
3464		}
3465		i.mu.Lock()
3466		i.agent = ag
3467		i.cfg.Provider = prov
3468		i.cfg.Model = model
3469		i.statusErr = ""
3470		i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
3471		i.mu.Unlock()
3472		i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
3473		i.dialog.ShowResult(true, "")
3474	}
3475}
3476
3477// runCompact invokes core.Agent.Compact and reflects the progress in
3478// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
3479// cancel via the same cancelTurn channel used for normal turns.
3480//
3481// When auto is true the spinner message is pinned to "condensing
3482// history" and the status bar surfaces "(auto)" next to the context
3483// percentage so it's obvious the system triggered this, not the user.
3484func (i *Interactive) runCompact(parent context.Context, auto bool) {
3485	if i.agent == nil {
3486		i.mu.Lock()
3487		i.statusErr = "not logged in. type /login first."
3488		i.mu.Unlock()
3489		return
3490	}
3491	ctx, cancel := context.WithCancel(parent)
3492	i.mu.Lock()
3493	i.busy = true
3494	if auto {
3495		i.spin.StartFixed("condensing history")
3496		i.autoCompacting = true
3497	} else {
3498		i.spin.StartFixed("compacting")
3499	}
3500	i.cancelTurn = cancel
3501	i.statusErr = ""
3502	i.statusOK = ""
3503	// Do NOT set streamOn: the summary text should not be visible
3504	// in the chat while compacting. The user just sees the spinner
3505	// and can keep typing / queue prompts.
3506	i.scrollOffset = 0
3507	i.helpBlock = nil
3508	i.mu.Unlock()
3509	i.invalidate()
3510
3511	go func() {
3512		// Sink discards deltas — we don't stream the summary to the UI.
3513		sink := func(delta string) {}
3514		summary, err := i.agent.Compact(ctx, 4, sink)
3515		_ = summary
3516		i.mu.Lock()
3517		i.busy = false
3518		i.resetStreamingStateLocked()
3519		i.cancelTurn = nil
3520		i.autoCompacting = false
3521
3522		// Drain the queue: if the user typed a prompt while compacting,
3523		// fire it now that the transcript is clean.
3524		var next string
3525		var hasNext bool
3526
3527		switch {
3528		case err != nil && ctx.Err() != nil:
3529			i.statusErr = ""
3530			if auto {
3531				i.statusOK = "auto-condense cancelled"
3532			} else {
3533				i.statusOK = "compaction cancelled"
3534			}
3535			i.queued = nil // drop queue on cancel
3536			if i.agent != nil {
3537				i.agent.DrainQueuedMessages()
3538			}
3539		case err != nil:
3540			i.statusErr = "compaction failed: " + err.Error()
3541			i.statusOK = ""
3542			i.queued = nil // drop queue on error
3543			if i.agent != nil {
3544				i.agent.DrainQueuedMessages()
3545			}
3546		default:
3547			i.statusErr = ""
3548			// Read token count from the compaction message meta.
3549			tokens := ""
3550			msgs := i.agent.Messages()
3551			if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
3552				tokens = msgs[0].Meta["tokens_before"]
3553			}
3554			switch {
3555			case i.pendingPostCompactNote != "":
3556				i.statusOK = i.pendingPostCompactNote
3557			case tokens != "":
3558				i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
3559			default:
3560				i.statusOK = "compacted (ctrl+o to expand)"
3561			}
3562			i.pendingPostCompactNote = ""
3563			i.extNotes = stripAutoCompactNotes(i.extNotes)
3564			i.lastCtxInput = 0
3565			i.toolCalls = map[string]*tui.ToolCallView{}
3566			i.toolOrder = nil
3567			i.view.InvalidateRenderCache()
3568			// Pop queued prompt if any.
3569			if len(i.queued) > 0 {
3570				next, i.queued = i.queued[0], i.queued[1:]
3571				hasNext = true
3572			}
3573		}
3574		i.mu.Unlock()
3575		i.invalidate()
3576
3577		if hasNext {
3578			p := i.runCtx
3579			if p == nil {
3580				p = context.Background()
3581			}
3582			i.startTurn(p, next)
3583		}
3584	}()
3585}
3586
3587func (i *Interactive) startTurn(parent context.Context, prompt string) {
3588	i.startTurnWithImages(parent, prompt, nil)
3589}
3590
3591func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
3592	if i.agent == nil {
3593		return
3594	}
3595	// Force a full repaint when a new turn begins so any stray dialog,
3596	// popup, or stale tool-progress rows don't leak into the visible
3597	// chat area before the assistant starts streaming. Equivalent to
3598	// the user pressing ctrl+l right before submit.
3599	if i.rend != nil {
3600		i.rend.Clear()
3601	}
3602	// Pre-turn safety: if the most recent context measurement is
3603	// already past the auto-compact threshold, condense before
3604	// sending so the next outbound request stays under the limit.
3605	// The condense flow re-fires the user's queued prompt for us, so
3606	// we just hand it off and exit.
3607	i.mu.Lock()
3608	needsPreCompact := !i.autoCompacting && i.shouldAutoCompactLocked()
3609	if needsPreCompact {
3610		if prompt != "" {
3611			i.queued = append([]string{prompt}, i.queued...)
3612		}
3613		i.statusErr = ""
3614		i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending…"))
3615		i.pendingPostCompactNote = "context auto-compacted; sending your last message"
3616		i.mu.Unlock()
3617		i.invalidate()
3618		i.runCompact(parent, true)
3619		return
3620	}
3621	i.mu.Unlock()
3622
3623	ctx, cancel := context.WithCancel(parent)
3624	i.mu.Lock()
3625	i.busy = true
3626	i.spin.Start()
3627	i.cancelTurn = cancel
3628	i.statusErr = ""
3629	i.statusOK = ""
3630	i.streaming.Reset()
3631	i.streamOn = true
3632	i.toolCalls = map[string]*tui.ToolCallView{}
3633	i.toolOrder = nil
3634	i.scrollOffset = 0 // jump back to the bottom on new turn
3635	// Reset the auto-follow baseline so the very next render at
3636	// interactive.go:1053 doesn't see a synthetic shrink between
3637	// "last frame had the previous turn's tool overlay" and
3638	// "this frame had it cleared above". Without this, the guard
3639	// reads delta = -(rows in cleared overlay) and decrements
3640	// scrollOffset, which on terminals that mirror zot's pane
3641	// scroll into the host scrollbar visibly yanks the viewport.
3642	// See autofollow_shrink_test.go for the exact arithmetic.
3643	i.prevChatLen = 0
3644	i.prevChatCols = 0
3645	i.parkedTurn = 0 // starting a turn clears the /jump parked state
3646	i.parkedTotal = 0
3647	i.helpBlock = nil // hide the help block once the user asks something
3648	i.mu.Unlock()
3649	i.invalidate()
3650
3651	sink := func(ev core.AgentEvent) {
3652		i.handleEvent(ev)
3653		i.invalidate()
3654	}
3655
3656	go func() {
3657		err := i.agent.Prompt(ctx, prompt, images, sink)
3658		i.mu.Lock()
3659		i.busy = false
3660		// Don't touch streamPending / streamFlushPending here — the
3661		// pacer may still be draining the final deltas and needs to
3662		// paint them even though Prompt has returned. It will reset
3663		// streamOn on its own once the buffer empties.
3664		if len(i.streamPending) == 0 {
3665			i.streamOn = false
3666		}
3667		i.cancelTurn = nil
3668		if err != nil && ctx.Err() == nil {
3669			i.statusErr = err.Error()
3670		}
3671		// Decide whether to offer a model rescue picker for recoverable
3672		// provider failures (auth/rate/temporary). The picker opens after
3673		// the mutex is released so it can take its own locks freely.
3674		var (
3675			offer       bool
3676			rescueWhy   string
3677			rescueImgs  []provider.ImageBlock
3678			rescueModel string
3679			rescueProv  string
3680			rescueFprov string
3681		)
3682		if err != nil && ctx.Err() == nil {
3683			if ok, reason := classifyRescueError(err); ok {
3684				offer = true
3685				rescueWhy = reason
3686				rescueImgs = images
3687				rescueModel = i.cfg.Model
3688				rescueProv = i.cfg.Provider
3689				rescueFprov = extractFailedProvider(err)
3690				if rescueFprov == "" {
3691					rescueFprov = i.cfg.Provider
3692				}
3693				// Suppress the red banner — the rescue dialog already
3694				// surfaces the failure.
3695				i.statusErr = ""
3696			}
3697		}
3698		// Detect HTTP 413 "payload too large" responses. The provider
3699		// rejected the request because the request body exceeded its
3700		// per-request limit. Token-based auto-compact can miss this
3701		// because the limit is on raw bytes, not tokens. Re-queue the
3702		// prompt so it survives the condense pass and trigger one.
3703		payloadTooLarge := err != nil && ctx.Err() == nil && isPayloadTooLargeError(err)
3704		if payloadTooLarge {
3705			i.statusErr = ""
3706			i.queued = append([]string{prompt}, i.queued...)
3707			i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
3708			i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
3709		}
3710		// Persist the assistant's reply (and every tool row before
3711		// it) to the session file while the turn memory is hot.
3712		// Without this, WriteNewTranscript only fires at zot exit,
3713		// meaning a crash or ungraceful kill drops the whole
3714		// conversation. FlushSession is idempotent (it advances the
3715		// baseline so subsequent flushes only write new rows).
3716		flush := i.cfg.FlushSession
3717		i.mu.Unlock()
3718		if flush != nil {
3719			flush()
3720		}
3721		i.mu.Lock()
3722		// Pop the next queued message, if any, and relaunch.
3723		var next string
3724		var hasNext bool
3725		if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
3726			next, i.queued = i.queued[0], i.queued[1:]
3727			hasNext = true
3728		}
3729		// If the turn was cancelled or errored, drop the queue so the
3730		// user isn't bombarded with stale messages after an interrupt.
3731		if ctx.Err() != nil || err != nil {
3732			i.queued = nil
3733			if i.agent != nil {
3734				i.agent.DrainQueuedMessages()
3735			}
3736		}
3737		// Decide whether the next thing to do is an auto-compaction.
3738		// Only fires when the turn completed cleanly AND no host-side
3739		// or agent-side queued messages are waiting (otherwise a queued
3740		// message would race the condense).
3741		agentQueued := 0
3742		if i.agent != nil {
3743			agentQueued = i.agent.QueuedMessageCount()
3744		}
3745		shouldAutoCompact := !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
3746		i.mu.Unlock()
3747		i.invalidate()
3748		parent := i.runCtx
3749		if parent == nil {
3750			parent = context.Background()
3751		}
3752		switch {
3753		case hasNext:
3754			i.startTurn(parent, next)
3755		case offer:
3756			i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
3757		case payloadTooLarge:
3758			i.runCompact(parent, true)
3759		case shouldAutoCompact:
3760			i.runCompact(parent, true)
3761		}
3762	}()
3763}
3764
3765// openRescueDialog surfaces the rescue model picker after a recoverable
3766// provider failure. The pending prompt + images are stashed on the
3767// Interactive so a later applyRescueSelection can re-run the same turn
3768// against the freshly-picked model. activeProvider/failedProvider are
3769// usually the same, but some clients embed different prefixes in their
3770// errors than the configured provider id, so we accept both.
3771func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
3772	if i.rescueDialog == nil {
3773		return
3774	}
3775	loggedIn := []string{}
3776	if i.cfg.LoggedInProviders != nil {
3777		loggedIn = i.cfg.LoggedInProviders()
3778	}
3779	fprov := failedProvider
3780	if fprov == "" {
3781		fprov = activeProvider
3782	}
3783	i.mu.Lock()
3784	i.pendingRescuePrompt = prompt
3785	i.pendingRescueImages = images
3786	i.mu.Unlock()
3787	i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
3788	i.invalidate()
3789}
3790
3791// applyRescueSelection switches model (cross-provider if needed) and
3792// re-runs the same prompt+images that just failed. Mirrors
3793// applyModelSelection's transcript-carry logic so the user keeps full
3794// session continuity across the swap.
3795func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
3796	if model == "" {
3797		return
3798	}
3799	i.applyRescueModelSelection(prov, model)
3800	i.mu.Lock()
3801	images := i.pendingRescueImages
3802	if prompt == "" {
3803		prompt = i.pendingRescuePrompt
3804	}
3805	i.pendingRescuePrompt = ""
3806	i.pendingRescueImages = nil
3807	i.mu.Unlock()
3808	parent := i.runCtx
3809	if parent == nil {
3810		parent = context.Background()
3811	}
3812	i.startTurnWithImages(parent, prompt, images)
3813}
3814
3815func stripAutoCompactNotes(notes []string) []string {
3816	if len(notes) == 0 {
3817		return notes
3818	}
3819	out := notes[:0]
3820	for _, n := range notes {
3821		if strings.Contains(n, "condensing history") {
3822			continue
3823		}
3824		out = append(out, n)
3825	}
3826	return out
3827}
3828
3829// autoCompactNoteLine returns a styled chat-area note for the
3830// inline auto-compact heads-up. Lives in extNotes so it survives
3831// the busy-spinner overwrite of the status row.
3832func autoCompactNoteLine(th tui.Theme, msg string) string {
3833	return "  " + th.FG256(th.Warning, "⚠ "+msg)
3834}
3835
3836// isPayloadTooLargeError matches HTTP 413 responses surfaced by the
3837// provider clients. The error formatting differs slightly between
3838// providers (anthropic and openai both prepend the status code), so
3839// we look for the canonical 413 marker as well as the conventional
3840// 'payload too large' phrase.
3841func isPayloadTooLargeError(err error) bool {
3842	if err == nil {
3843		return false
3844	}
3845	msg := strings.ToLower(err.Error())
3846	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")
3847}
3848
3849// autoCompactThreshold is the context-window fraction at which the
3850// agent will auto-compact after a turn ends. 0.85 leaves enough
3851// headroom for one more user prompt + response before we bump the
3852// hard limit.
3853const autoCompactThreshold = 0.85
3854
3855// shouldAutoCompactLocked reports whether the last turn pushed context
3856// usage past the auto-compact threshold. Must be called with i.mu
3857// held; it reads lastCtxInput and the current model's context window.
3858func (i *Interactive) shouldAutoCompactLocked() bool {
3859	if i.agent == nil {
3860		return false
3861	}
3862	if i.autoCompacting {
3863		return false
3864	}
3865	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
3866	if err != nil || m.ContextWindow <= 0 {
3867		return false
3868	}
3869	if i.lastCtxInput <= 0 {
3870		return false
3871	}
3872	return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
3873}
3874
3875func (i *Interactive) handleEvent(ev core.AgentEvent) {
3876	i.mu.Lock()
3877	defer i.mu.Unlock()
3878	switch e := ev.(type) {
3879	case core.EvAssistantStart:
3880		// Fires at the top of every oneTurn, including follow-up
3881		// turns after tool use. Without this, the streaming buffer
3882		// is still marked off from the previous assistant message
3883		// and the final summary text pops in all at once instead
3884		// of typewriter-streaming delta by delta.
3885		i.streaming.Reset()
3886		i.streamPending = i.streamPending[:0]
3887		i.streamFlushPending = false
3888		i.streamOn = true
3889		// Clear the live tool-call overlay. Any tools from the
3890		// previous round are now fully folded into the transcript
3891		// (assistant tool_use block + tool role message with the
3892		// result), so keeping them in the overlay would duplicate
3893		// them in the view — once inside the finalised transcript
3894		// and once below the streaming block, with the streaming
3895		// summary sandwiched in between. The next EvToolUseStart
3896		// will populate fresh entries for this turn's tools.
3897		i.toolCalls = map[string]*tui.ToolCallView{}
3898		i.toolOrder = nil
3899	case core.EvTextDelta:
3900		// Buffer into streamPending; the paintPace ticker drains
3901		// it into i.streaming a few runes at a time for a smooth
3902		// typewriter effect independent of upstream chunk size.
3903		i.streamPending = append(i.streamPending, []rune(e.Delta)...)
3904		i.streamOn = true
3905	case core.EvAssistantMessage:
3906		// OnAssistant + telegram mirroring always fire on message
3907		// arrival — they read the FINAL message content, which is
3908		// complete regardless of what's still in the pacer.
3909		i.assistantMessageSideEffects(e.Message)
3910		// If the pacer still has characters to drain, keep streamOn
3911		// true and mark flush pending; the paintPace ticker will
3912		// drain the remainder and reset streaming state when done.
3913		// Otherwise (rare: full-replay sessions, abort paths) clear
3914		// synchronously so a later render doesn't show stale text.
3915		if len(i.streamPending) > 0 {
3916			i.streamFlushPending = true
3917			return
3918		}
3919		i.resetStreamingStateLocked()
3920	case core.EvToolUseStart:
3921		// Live streaming: pre-create the view so the user sees the
3922		// tool call being composed in real time. Any subsequent
3923		// EvToolCall for the same ID updates the same struct (the
3924		// final parsed args + name are already known here).
3925		if _, exists := i.toolCalls[e.ID]; !exists {
3926			i.toolCalls[e.ID] = &tui.ToolCallView{
3927				ID:        e.ID,
3928				Name:      e.Name,
3929				Streaming: true,
3930			}
3931			i.toolOrder = append(i.toolOrder, e.ID)
3932		}
3933	case core.EvToolUseArgs:
3934		if tc, ok := i.toolCalls[e.ID]; ok {
3935			tc.RawJSONBuf += e.Delta
3936			// Refresh the live path as soon as it parses; used in
3937			// the header (write /Users/pat/Desktop/demo.ts)
3938			// while the content is still streaming.
3939			if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
3940				tc.LivePath = p
3941			} else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
3942				tc.LivePath = p
3943			}
3944		}
3945	case core.EvToolUseEnd:
3946		if tc, ok := i.toolCalls[e.ID]; ok {
3947			tc.Streaming = false
3948		}
3949	case core.EvToolCall:
3950		// If we already pre-created the view during streaming, just
3951		// refresh the final Args summary. Otherwise create a new one
3952		// (non-streaming providers or legacy paths).
3953		if tc, ok := i.toolCalls[e.ID]; ok {
3954			tc.Args = tui.ShortArgs(e.Name, e.Args)
3955			tc.Streaming = false
3956		} else {
3957			i.toolCalls[e.ID] = &tui.ToolCallView{
3958				ID:   e.ID,
3959				Name: e.Name,
3960				Args: tui.ShortArgs(e.Name, e.Args),
3961			}
3962			i.toolOrder = append(i.toolOrder, e.ID)
3963		}
3964	case core.EvToolResult:
3965		if tc, ok := i.toolCalls[e.ID]; ok {
3966			tc.Done = true
3967			tc.Error = e.Result.IsError
3968			var text strings.Builder
3969			for _, c := range e.Result.Content {
3970				if tb, ok := c.(provider.TextBlock); ok {
3971					if text.Len() > 0 {
3972						text.WriteString("\n")
3973					}
3974					text.WriteString(tb.Text)
3975				}
3976			}
3977			tc.Result = text.String()
3978		}
3979		if i.cfg.OnToolResult != nil {
3980			i.cfg.OnToolResult(e.ID, e.Result)
3981		}
3982	case core.EvUsage:
3983		i.cumUsage = e.Cumulative
3984		if e.Usage.InputTokens > 0 {
3985			i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
3986		}
3987	case core.EvTurnEnd:
3988		if e.Stop == provider.StopAborted {
3989			i.resetStreamingStateLocked()
3990			i.statusErr = ""
3991			i.statusOK = "cancelled"
3992			return
3993		}
3994		// Don't surface mid-loop stream errors as a red banner here.
3995		// EvTurnEnd fires after every step in a multi-step tool loop,
3996		// so a transient 503 / network blip would briefly paint a red
3997		// banner over the still-streaming chat before the agent loop
3998		// either retries or exits. The final error (if any) is set by
3999		// startTurnWithImages once Prompt() returns, and recoverable
4000		// failures are routed to the rescue picker instead — which
4001		// keeps the chat clean while the agent is working.
4002		_ = e.Err
4003	}
4004}
4005
4006// Agent returns the current agent, if any. Used by cli.go to flush the
4007// final transcript to the session file.
4008func (i *Interactive) Agent() *core.Agent {
4009	i.mu.Lock()
4010	defer i.mu.Unlock()
4011	return i.agent
4012}
4013
4014// silence unused import in some build configs
4015var _ = fmt.Sprintf
4016
4017// runReloadExt triggers a live reload of every extension (discovered
4018// + explicit). Runs on a goroutine so the TUI stays responsive; the
4019// Manager.Reload takes a couple of hundred ms to shut down subprocs
4020// and respawn them. Shows a status line throughout.
4021func (i *Interactive) runReloadExt(ctx context.Context) {
4022	if i.cfg.Extensions == nil {
4023		i.mu.Lock()
4024		i.statusErr = "no extension manager in this build"
4025		i.mu.Unlock()
4026		i.invalidate()
4027		return
4028	}
4029	i.mu.Lock()
4030	i.statusOK = "reloading extensions…"
4031	i.statusErr = ""
4032	i.mu.Unlock()
4033	i.invalidate()
4034
4035	go func() {
4036		stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
4037		msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
4038		if len(stats.Errors) > 0 {
4039			msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
4040		}
4041		i.mu.Lock()
4042		i.statusOK = msg
4043		i.statusErr = ""
4044		i.mu.Unlock()
4045		i.invalidate()
4046	}()
4047}
4048
4049// Confirm implements core.Confirmer. The agent goroutine calls
4050// this synchronously before every tool invocation when --no-yolo is
4051// active. We push the request onto the confirmDialog queue, trigger
4052// a redraw, and block the caller until the user answers.
4053//
4054// If the session is cancelled or the TUI exits mid-prompt, any
4055// pending request is refused via CancelAll so the agent doesn't
4056// deadlock.
4057func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
4058	resp := make(chan core.ConfirmDecision, 1)
4059	i.confirmDialog.Enqueue(&confirmRequest{
4060		toolName: toolName,
4061		preview:  preview,
4062		resp:     resp,
4063	})
4064	i.invalidate()
4065	return <-resp
4066}
4067
4068// openTelegramDialog shows the picker for `/telegram` with no arg.
4069// Items depend on current state: disconnect + status when running,
4070// connect + status when stopped.
4071func (i *Interactive) openTelegramDialog() {
4072	items := i.telegramMenuItems()
4073	if len(items) == 0 {
4074		i.mu.Lock()
4075		i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
4076		i.mu.Unlock()
4077		i.invalidate()
4078		return
4079	}
4080	i.telegramDialog.Open(items)
4081	i.invalidate()
4082}
4083
4084// telegramMenuItems builds the dialog entries for the current
4085// bridge state. Returns empty when no bot.json exists so the
4086// caller can show a helpful status line instead of an empty menu.
4087func (i *Interactive) telegramMenuItems() []telegramItem {
4088	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
4089	if err != nil || cfg.BotToken == "" {
4090		return nil
4091	}
4092	var items []telegramItem
4093	if i.telegramBridge != nil && i.telegramBridge.Active() {
4094		items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
4095		st := i.telegramBridge.State()
4096		hint := "active"
4097		if st.Username != "" {
4098			hint += " as @" + st.Username
4099		}
4100		items = append(items, telegramItem{label: "status", action: "status", hint: hint})
4101	} else {
4102		label := "connect"
4103		hint := "start mirroring dms into this session"
4104		if cfg.AllowedUserID == 0 {
4105			hint = "awaiting pairing (send /start to the bot once connected)"
4106		}
4107		items = append(items, telegramItem{label: label, action: "connect", hint: hint})
4108		items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
4109	}
4110	return items
4111}
4112
4113// doTelegram dispatches one of the three explicit actions. Called
4114// from /telegram <action> or after the picker selects a row.
4115func (i *Interactive) doTelegram(action string) {
4116	switch action {
4117	case "connect":
4118		i.telegramConnect()
4119	case "disconnect":
4120		i.telegramDisconnect()
4121	case "status":
4122		i.telegramStatus()
4123	default:
4124		i.mu.Lock()
4125		i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
4126		i.mu.Unlock()
4127		i.invalidate()
4128	}
4129}
4130
4131// telegramConnect starts the bridge. Refuses if it's already
4132// running or if the on-disk bot.json is missing a token.
4133func (i *Interactive) telegramConnect() {
4134	if i.telegramBridge != nil && i.telegramBridge.Active() {
4135		i.mu.Lock()
4136		i.statusOK = "telegram already connected"
4137		i.statusErr = ""
4138		i.mu.Unlock()
4139		i.invalidate()
4140		return
4141	}
4142	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
4143	if err != nil {
4144		i.mu.Lock()
4145		i.statusErr = "telegram: " + err.Error()
4146		i.mu.Unlock()
4147		i.invalidate()
4148		return
4149	}
4150	if cfg.BotToken == "" {
4151		i.mu.Lock()
4152		i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
4153		i.mu.Unlock()
4154		i.invalidate()
4155		return
4156	}
4157	// Refuse to start when a background daemon is already polling
4158	// the same bot. Two concurrent long-poll consumers race each
4159	// update and one always loses, so DMs get half-delivered. The
4160	// user can `zot telegram-bot stop` first, then /telegram connect.
4161	if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
4162		i.mu.Lock()
4163		i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
4164		i.mu.Unlock()
4165		i.invalidate()
4166		return
4167	}
4168	i.telegramBridge = &telegram.Bridge{
4169		Client: telegram.NewClient(cfg.BotToken),
4170		Config: cfg,
4171		Save: func(next telegram.Config) error {
4172			return telegram.SaveConfig(i.cfg.ZotHome, next)
4173		},
4174		Host: &telegramHost{iv: i},
4175	}
4176	if err := i.telegramBridge.Start(i.runCtx); err != nil {
4177		i.telegramBridge = nil
4178		i.mu.Lock()
4179		i.statusErr = "telegram connect failed: " + err.Error()
4180		i.mu.Unlock()
4181		i.invalidate()
4182		return
4183	}
4184	i.applyTelegramTools(true)
4185	state := i.telegramBridge.State()
4186	label := "telegram connected"
4187	if state.Username != "" {
4188		label += " as @" + state.Username
4189	}
4190	if state.PairedID == 0 {
4191		label += " — send /start to the bot from your phone to claim it"
4192	}
4193	i.mu.Lock()
4194	i.statusOK = label
4195	i.statusErr = ""
4196	i.mu.Unlock()
4197	i.invalidate()
4198}
4199
4200// telegramDisconnect stops the bridge. No-op when already stopped.
4201func (i *Interactive) telegramDisconnect() {
4202	if i.telegramBridge == nil || !i.telegramBridge.Active() {
4203		i.mu.Lock()
4204		i.statusOK = "telegram already disconnected"
4205		i.statusErr = ""
4206		i.mu.Unlock()
4207		i.invalidate()
4208		return
4209	}
4210	i.telegramBridge.Stop()
4211	i.applyTelegramTools(false)
4212	i.mu.Lock()
4213	i.statusOK = "telegram disconnected"
4214	i.statusErr = ""
4215	i.mu.Unlock()
4216	i.invalidate()
4217}
4218
4219// telegramSenderAdapter wraps the bridge so the tools package can
4220// drive it without importing telegram directly. The Active() check
4221// is forwarded to the bridge so the tool can fail clearly with a
4222// model-readable error when the user disconnected mid-turn.
4223type telegramSenderAdapter struct {
4224	bridge *telegram.Bridge
4225}
4226
4227func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
4228	if a.bridge == nil {
4229		return fmt.Errorf("telegram bridge is not connected")
4230	}
4231	return a.bridge.SendImage(ctx, path, caption)
4232}
4233
4234func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
4235	if a.bridge == nil {
4236		return fmt.Errorf("telegram bridge is not connected")
4237	}
4238	return a.bridge.SendDocument(ctx, path, caption)
4239}
4240
4241func (a telegramSenderAdapter) Active() bool {
4242	return a.bridge != nil && a.bridge.Active()
4243}
4244
4245// applyTelegramTools registers (active=true) or removes (active=false)
4246// the telegram_send_image and telegram_send_file tools on the running
4247// agent so the model only sees them while the bridge is connected.
4248// Snapshots and mutates the live tool registry so any extension or
4249// /reload-ext additions made while Telegram is connected survive a
4250// later /telegram disconnect (we only add or strip the two telegram
4251// entries, never the rest).
4252func (i *Interactive) applyTelegramTools(active bool) {
4253	if i.agent == nil {
4254		return
4255	}
4256	current := i.agent.Tools
4257	next := core.Registry{}
4258	for name, t := range current {
4259		if name == "telegram_send_image" || name == "telegram_send_file" {
4260			continue
4261		}
4262		next[name] = t
4263	}
4264	if active {
4265		sender := telegramSenderAdapter{bridge: i.telegramBridge}
4266		next["telegram_send_image"] = &tools.TelegramSendImageTool{
4267			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
4268		}
4269		next["telegram_send_file"] = &tools.TelegramSendFileTool{
4270			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
4271		}
4272	}
4273	i.agent.SetTools(next)
4274}
4275
4276// telegramStatus writes a one-liner describing the bridge state.
4277// Reports on both the in-tui bridge and the background daemon so
4278// the user isn't confused when the daemon owns the poll loop.
4279func (i *Interactive) telegramStatus() {
4280	var msg string
4281	if i.telegramBridge != nil && i.telegramBridge.Active() {
4282		s := i.telegramBridge.State()
4283		msg = "telegram: connected (tui bridge)"
4284		if s.Username != "" {
4285			msg += " as @" + s.Username
4286		}
4287		if s.PairedID != 0 {
4288			msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
4289		} else {
4290			msg += " - awaiting pairing"
4291		}
4292	} else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
4293		msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
4294	} else {
4295		cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
4296		if cfg.BotToken == "" {
4297			msg = "telegram: not configured. run `zot telegram-bot setup` first."
4298		} else {
4299			msg = "telegram: disconnected"
4300			if cfg.BotUsername != "" {
4301				msg += " (@" + cfg.BotUsername + " ready to connect)"
4302			}
4303		}
4304	}
4305	i.mu.Lock()
4306	i.statusOK = msg
4307	i.statusErr = ""
4308	i.mu.Unlock()
4309	i.invalidate()
4310}
4311
4312// telegramHost adapts *Interactive to telegram.Host so the bridge
4313// can call back into the TUI without importing modes directly.
4314type telegramHost struct{ iv *Interactive }
4315
4316func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
4317	h.iv.SubmitOrQueue(prompt, images)
4318}
4319
4320func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
4321
4322func (h *telegramHost) Status() string {
4323	h.iv.mu.Lock()
4324	providerName := h.iv.cfg.Provider
4325	model := h.iv.cfg.Model
4326	cwd := h.iv.cfg.CWD
4327	usage := h.iv.cumUsage
4328	subscription := h.iv.cfg.AuthMethod == "oauth"
4329	ctxUsed := h.iv.lastCtxInput
4330	busy := h.iv.busy
4331	queued := len(h.iv.queued)
4332	h.iv.mu.Unlock()
4333
4334	ctxMax := 0
4335	if m, err := provider.FindModel(providerName, model); err == nil {
4336		ctxMax = m.ContextWindow
4337	}
4338	return telegram.FormatStatus(telegram.StatusSnapshot{
4339		Provider:     providerName,
4340		Model:        model,
4341		CWD:          cwd,
4342		Usage:        usage,
4343		Subscription: subscription,
4344		ContextUsed:  ctxUsed,
4345		ContextMax:   ctxMax,
4346		Busy:         busy,
4347		Queued:       queued,
4348	})
4349}
4350
4351func (h *telegramHost) Notify(level, message string) {
4352	h.iv.mu.Lock()
4353	switch level {
4354	case "error", "warn":
4355		h.iv.statusErr = message
4356		h.iv.statusOK = ""
4357	default:
4358		h.iv.statusOK = message
4359		h.iv.statusErr = ""
4360	}
4361	h.iv.mu.Unlock()
4362	h.iv.invalidate()
4363}
4364
4365// openSessionOpsDialog shows the picker for `/session` with no arg.
4366// Always offers export, import, fork, tree; the handlers bail with
4367// a clear status message when the precondition isn't met (empty
4368// transcript on fork; no parent/siblings on tree).
4369func (i *Interactive) openSessionOpsDialog() {
4370	items := []sessionOpsItem{
4371		{label: "export", action: "export", hint: "write the current session to a .zotsession file"},
4372		{label: "import", action: "import", hint: "load a .zotsession file into this directory"},
4373		{label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
4374		{label: "tree", action: "tree", hint: "switch between branches in this directory"},
4375	}
4376	i.sessionOpsDialog.Open(items)
4377	i.invalidate()
4378}
4379
4380// doSessionOp dispatches export, import, fork, or tree. arg is the
4381// optional positional argument from e.g. /session export <path>
4382// or /session import <path>; fork and tree ignore it.
4383func (i *Interactive) doSessionOp(action, arg string) {
4384	switch action {
4385	case "export":
4386		i.doSessionExport(arg)
4387	case "import":
4388		i.doSessionImport(arg)
4389	case "fork":
4390		i.doSessionFork()
4391	case "tree":
4392		i.doSessionTree()
4393	default:
4394		i.mu.Lock()
4395		i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
4396		i.mu.Unlock()
4397		i.invalidate()
4398	}
4399}
4400
4401// doSessionExport writes the live session file to destination path
4402// dst. When dst is empty we default to ~/Downloads (falling back to
4403// the user's home directory if it doesn't exist). The helper
4404// expands a leading `~` and creates any missing parent directories.
4405func (i *Interactive) doSessionExport(dst string) {
4406	if i.cfg.CurrentSessionPath == nil {
4407		i.mu.Lock()
4408		i.statusErr = "export: no session is active (running with --no-session?)"
4409		i.mu.Unlock()
4410		i.invalidate()
4411		return
4412	}
4413	src := i.cfg.CurrentSessionPath()
4414	if src == "" {
4415		i.mu.Lock()
4416		i.statusErr = "export: no session is active (running with --no-session?)"
4417		i.mu.Unlock()
4418		i.invalidate()
4419		return
4420	}
4421	// Persist any in-memory agent messages to the session file so
4422	// the export carries the full conversation. Without this, the
4423	// default lazy-flush-at-exit strategy leaves most of a running
4424	// session unwritten and the export ends up with only the meta.
4425	if i.cfg.FlushSession != nil {
4426		i.cfg.FlushSession()
4427	}
4428	dst = unquotePath(dst)
4429	if dst == "" {
4430		dst = defaultExportDir()
4431	} else {
4432		dst = expandTilde(dst)
4433	}
4434	out, err := core.ExportSession(src, dst)
4435	if err != nil {
4436		i.mu.Lock()
4437		i.statusErr = "export: " + err.Error()
4438		i.mu.Unlock()
4439		i.invalidate()
4440		return
4441	}
4442	i.mu.Lock()
4443	i.statusOK = "exported session to " + friendlyPath(out)
4444	i.statusErr = ""
4445	i.mu.Unlock()
4446	i.invalidate()
4447}
4448
4449// doSessionImport copies the .zotsession file at src into the
4450// running cwd's sessions directory and loads it as the active
4451// session, same as `/sessions` -> pick. When src is empty we ask
4452// the user to pass a path (no usable default here).
4453func (i *Interactive) doSessionImport(src string) {
4454	src = unquotePath(src)
4455	if src == "" {
4456		i.mu.Lock()
4457		i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
4458		i.mu.Unlock()
4459		i.invalidate()
4460		return
4461	}
4462	src = expandTilde(src)
4463	if _, err := os.Stat(src); err != nil {
4464		i.mu.Lock()
4465		i.statusErr = "import: " + err.Error()
4466		i.mu.Unlock()
4467		i.invalidate()
4468		return
4469	}
4470	newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version)
4471	if err != nil {
4472		i.mu.Lock()
4473		i.statusErr = "import: " + err.Error()
4474		i.mu.Unlock()
4475		i.invalidate()
4476		return
4477	}
4478	if i.cfg.LoadSession == nil {
4479		i.mu.Lock()
4480		i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
4481		i.statusErr = ""
4482		i.mu.Unlock()
4483		i.invalidate()
4484		return
4485	}
4486	if err := i.cfg.LoadSession(newPath); err != nil {
4487		i.mu.Lock()
4488		i.statusErr = "import: load failed: " + err.Error()
4489		i.mu.Unlock()
4490		i.invalidate()
4491		return
4492	}
4493	i.mu.Lock()
4494	i.statusOK = "imported and switched to session " + friendlyPath(newPath)
4495	i.statusErr = ""
4496	i.mu.Unlock()
4497	i.invalidate()
4498}
4499
4500// defaultExportDir returns ~/Downloads when it exists, or ~ as a
4501// fallback, or /tmp on exotic machines with no home dir.
4502func defaultExportDir() string {
4503	home, err := os.UserHomeDir()
4504	if err != nil || home == "" {
4505		return os.TempDir()
4506	}
4507	downloads := filepath.Join(home, "Downloads")
4508	if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
4509		return downloads
4510	}
4511	return home
4512}
4513
4514// expandTilde turns a leading ~ into the user's home directory.
4515// Returns the input unchanged when there's no tilde or no home.
4516func expandTilde(p string) string {
4517	if p == "" || p[0] != '~' {
4518		return p
4519	}
4520	home, err := os.UserHomeDir()
4521	if err != nil || home == "" {
4522		return p
4523	}
4524	if len(p) == 1 {
4525		return home
4526	}
4527	if p[1] == '/' || p[1] == filepath.Separator {
4528		return filepath.Join(home, p[2:])
4529	}
4530	return p
4531}
4532
4533// unquotePath strips a matching pair of surrounding single or
4534// double quotes. Drag-drop paste in the tui auto-quotes dropped
4535// file paths so the shell-like `/session import 'foo bar.zs'`
4536// stays well-formed; when the TUI's own slash handler consumes
4537// the arg, we want the raw path back.
4538func unquotePath(p string) string {
4539	p = strings.TrimSpace(p)
4540	if len(p) >= 2 {
4541		first := p[0]
4542		last := p[len(p)-1]
4543		if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
4544			p = p[1 : len(p)-1]
4545		}
4546	}
4547	return p
4548}
4549
4550// friendlyPath collapses the user's home directory to a leading ~
4551// so status messages read cleanly. Falls back to the raw path when
4552// the home dir is unknown.
4553func friendlyPath(p string) string {
4554	home, err := os.UserHomeDir()
4555	if err != nil || home == "" {
4556		return p
4557	}
4558	if strings.HasPrefix(p, home+string(filepath.Separator)) {
4559		return "~" + p[len(home):]
4560	}
4561	return p
4562}
4563
4564// doSessionFork opens the /jump turn picker in "fork mode". The
4565// next selection branches the current session at that user turn
4566// instead of scrolling the viewport to it.
4567func (i *Interactive) doSessionFork() {
4568	if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
4569		i.mu.Lock()
4570		i.statusErr = "fork: no session is active (running with --no-session?)"
4571		i.mu.Unlock()
4572		i.invalidate()
4573		return
4574	}
4575	msgs := []provider.Message{}
4576	if i.agent != nil {
4577		msgs = i.agent.Messages()
4578	}
4579	if len(msgs) == 0 {
4580		i.mu.Lock()
4581		i.statusErr = "fork: transcript is empty; nothing to fork from"
4582		i.mu.Unlock()
4583		i.invalidate()
4584		return
4585	}
4586	i.pendingFork = true
4587	i.jumpDialog.Open(msgs, "")
4588	i.invalidate()
4589}
4590
4591// doSessionTree shows the branch topology picker for this cwd.
4592// Pick an entry to switch into it (same semantics as /sessions
4593// picking a past session, but with the parent/child indentation).
4594func (i *Interactive) doSessionTree() {
4595	if i.cfg.ZotHome == "" || i.cfg.CWD == "" {
4596		i.mu.Lock()
4597		i.statusErr = "tree: session storage not configured"
4598		i.mu.Unlock()
4599		i.invalidate()
4600		return
4601	}
4602	// Flush the running agent's transcript first so its message
4603	// count + latest preview are accurate in the tree.
4604	if i.cfg.FlushSession != nil {
4605		i.cfg.FlushSession()
4606	}
4607	roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD)
4608	if len(roots) == 0 {
4609		i.mu.Lock()
4610		i.statusErr = "tree: no sessions in this directory yet"
4611		i.mu.Unlock()
4612		i.invalidate()
4613		return
4614	}
4615	current := ""
4616	if i.cfg.CurrentSessionPath != nil {
4617		current = i.cfg.CurrentSessionPath()
4618	}
4619	if !i.sessionTreeDialog.Open(roots, current) {
4620		i.mu.Lock()
4621		i.statusErr = "tree: no branches to show"
4622		i.mu.Unlock()
4623	}
4624	i.invalidate()
4625}
4626
4627// applySessionTreeSelection switches the running agent to the
4628// session file at path. Thin wrapper around LoadSession that also
4629// writes a status line.
4630func (i *Interactive) applySessionTreeSelection(path string) {
4631	if i.cfg.LoadSession == nil {
4632		i.mu.Lock()
4633		i.statusErr = "tree: session swap not available in this build"
4634		i.mu.Unlock()
4635		i.invalidate()
4636		return
4637	}
4638	if err := i.cfg.LoadSession(path); err != nil {
4639		i.mu.Lock()
4640		i.statusErr = "tree: load failed: " + err.Error()
4641		i.mu.Unlock()
4642		i.invalidate()
4643		return
4644	}
4645	i.mu.Lock()
4646	i.statusOK = "switched to branch " + friendlyPath(path)
4647	i.statusErr = ""
4648	i.mu.Unlock()
4649	i.invalidate()
4650}
4651
4652// applyForkSelection branches the current session at msgIdx+1 (so
4653// the selected user message and everything before it is included
4654// in the new branch), then switches the running agent to the new
4655// file. Called from the jump-dialog handler when pendingFork=true.
4656func (i *Interactive) applyForkSelection(msgIdx int) {
4657	i.pendingFork = false
4658	if i.cfg.CurrentSessionPath == nil {
4659		i.mu.Lock()
4660		i.statusErr = "fork: no session is active"
4661		i.mu.Unlock()
4662		i.invalidate()
4663		return
4664	}
4665	src := i.cfg.CurrentSessionPath()
4666	if src == "" {
4667		i.mu.Lock()
4668		i.statusErr = "fork: no session is active"
4669		i.mu.Unlock()
4670		i.invalidate()
4671		return
4672	}
4673	if i.cfg.FlushSession != nil {
4674		i.cfg.FlushSession()
4675	}
4676	// msgIdx is 0-indexed message position; copy msgIdx+1 rows so
4677	// the selected user message is included.
4678	upTo := msgIdx + 1
4679	newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
4680	if err != nil {
4681		i.mu.Lock()
4682		i.statusErr = "fork: " + err.Error()
4683		i.mu.Unlock()
4684		i.invalidate()
4685		return
4686	}
4687	if i.cfg.LoadSession == nil {
4688		i.mu.Lock()
4689		i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
4690		i.statusErr = ""
4691		i.mu.Unlock()
4692		i.invalidate()
4693		return
4694	}
4695	if err := i.cfg.LoadSession(newPath); err != nil {
4696		i.mu.Lock()
4697		i.statusErr = "fork: switch failed: " + err.Error()
4698		i.mu.Unlock()
4699		i.invalidate()
4700		return
4701	}
4702	i.mu.Lock()
4703	i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
4704	i.statusErr = ""
4705	i.mu.Unlock()
4706	i.invalidate()
4707}
4708
4709// formatInt is a tiny strconv.Itoa shim; keeps the handler above
4710// from needing a strconv import just for one call.
4711func formatInt(n int) string {
4712	return fmt.Sprintf("%d", n)
4713}
4714
4715// assistantText returns the concatenated text of every TextBlock in
4716// m. Used by the streaming-view dedupe guard to tell when a live
4717// streamed reply has already been promoted into the transcript.
4718func assistantText(m provider.Message) string {
4719	var sb strings.Builder
4720	for _, c := range m.Content {
4721		if tb, ok := c.(provider.TextBlock); ok {
4722			if sb.Len() > 0 {
4723				sb.WriteByte('\n')
4724			}
4725			sb.WriteString(tb.Text)
4726		}
4727	}
4728	return sb.String()
4729}
4730
4731// resetStreamingStateLocked clears every piece of streaming state
4732// in one shot. Used by abort paths (turn cancel, compact finish,
4733// queue drain) so the pacer doesn't keep draining stale runes from
4734// a prior turn. Must be called with i.mu held.
4735func (i *Interactive) resetStreamingStateLocked() {
4736	i.streaming.Reset()
4737	i.streamPending = i.streamPending[:0]
4738	i.streamFlushPending = false
4739	i.streamOn = false
4740}
4741
4742// assistantMessageSideEffects runs the non-visual hooks attached to
4743// EvAssistantMessage: the host-provided OnAssistant callback and the
4744// telegram-bridge mirror. Called with i.mu held.
4745//
4746// Factored out of handleEvent because the streaming pacer may defer
4747// visual reset until after the last buffered rune has painted, but
4748// the callbacks themselves must fire on message arrival so
4749// downstream observers (session persistence, telegram, cost panels)
4750// don't wait on a UI animation to catch up.
4751func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
4752	if i.cfg.OnAssistant != nil {
4753		i.cfg.OnAssistant(m)
4754	}
4755	if i.telegramBridge != nil && i.telegramBridge.Active() {
4756		var sb strings.Builder
4757		for _, c := range m.Content {
4758			if tb, ok := c.(provider.TextBlock); ok {
4759				if sb.Len() > 0 {
4760					sb.WriteString("\n")
4761				}
4762				sb.WriteString(tb.Text)
4763			}
4764		}
4765		if text := sb.String(); strings.TrimSpace(text) != "" {
4766			go i.telegramBridge.OnAssistantText(text)
4767		}
4768	}
4769}
4770
4771// paintPaceRate is how many runes the streaming pacer releases per
4772// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
4773// that a 500-rune summary finishes in ~1.3s, slow enough to look
4774// like a human typing. Empirically matches the feel of provider
4775// paths that already drip-stream natively.
4776const paintPaceRate = 6
4777
4778// paintPaceInterval is the tick interval for the streaming pacer.
4779// 16ms lines up with the redraw throttle so we never paint faster
4780// than the terminal can keep up.
4781const paintPaceInterval = 16 * time.Millisecond
4782
4783// runStreamPacer drains buffered deltas from streamPending into
4784// streaming a small batch per tick, invalidating after each move.
4785// It stops when the context cancels (tui shutdown).
4786//
4787// Why a pacer: providers differ wildly in how they chunk their
4788// text_delta events. The API-key path on Anthropic emits ~30 drips
4789// for a 400-token summary; the OAuth path can coalesce the same
4790// summary into 3 fat chunks, visually indistinguishable from "the
4791// whole reply just appeared". The pacer normalizes that so every
4792// path looks the same on screen.
4793func (i *Interactive) runStreamPacer(ctx context.Context) {
4794	t := time.NewTicker(paintPaceInterval)
4795	defer t.Stop()
4796	for {
4797		select {
4798		case <-ctx.Done():
4799			return
4800		case <-t.C:
4801			i.mu.Lock()
4802			if len(i.streamPending) == 0 {
4803				// EvAssistantMessage already fired but the pacer
4804				// was still draining a tick ago. Everything is now
4805				// painted; clear the streaming flags so the next
4806				// redraw shows the finalised transcript message
4807				// and hides the streaming overlay.
4808				if i.streamFlushPending {
4809					i.streamFlushPending = false
4810					i.streaming.Reset()
4811					i.streamOn = false
4812					i.mu.Unlock()
4813					i.invalidate()
4814					continue
4815				}
4816				i.mu.Unlock()
4817				continue
4818			}
4819			n := paintPaceRate
4820			if n > len(i.streamPending) {
4821				n = len(i.streamPending)
4822			}
4823			i.streaming.WriteString(string(i.streamPending[:n]))
4824			i.streamPending = i.streamPending[n:]
4825			i.mu.Unlock()
4826			i.invalidate()
4827		}
4828	}
4829}

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close