v0.1.91 91c696d

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

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