v0.1.93 757e158

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

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