v0.1.84 ad93054

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

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