v0.1.52 07c0730

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

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