v0.1.79 7970412

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

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