v0.1.70 25cb7d5

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

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close