v0.1.51 ff1af01

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

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close