v0.1.58 25a4caf

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

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