v0.1.82 25e7da2

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	if i.handleInputHistoryKey(k) {
1932		return false
1933	}
1934	if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {
1935		i.inputHistoryIndex = -1
1936	}
1937
1938	if submit := i.ed.HandleKey(k); submit {
1939		// SubmitValue() expands any [pasted text #N +L lines]
1940		// placeholders back into their bodies; the raw Value()
1941		// is only what the user sees on screen.
1942		text := strings.TrimRight(i.ed.SubmitValue(), "\n")
1943		// Expand [file:name] and [dir:name/] chips to full paths.
1944		text = expandFileChips(text, i.cfg.CWD)
1945		if text == "" {
1946			return false
1947		}
1948		i.ed.Clear()
1949		i.inputHistoryIndex = -1
1950		i.suggest.Reset()
1951		i.fileSuggest.Reset()
1952
1953		if looksLikeSlashCommand(text) {
1954			head := text
1955			rest := ""
1956			if idx := strings.IndexAny(text, " \t"); idx >= 0 {
1957				head = text[:idx]
1958				rest = strings.TrimSpace(text[idx:])
1959			}
1960			if !isKnownSlashCommand(text) {
1961				// Try extensions before giving up. Extensions register
1962				// commands by bare name (no leading slash); strip it here.
1963				extName := strings.TrimPrefix(head, "/")
1964				if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1965					go i.invokeExtensionCommand(ctx, extName, rest)
1966					return false
1967				}
1968				i.mu.Lock()
1969				i.statusErr = "unknown command " + head + " — type /help to see the list"
1970				i.statusOK = ""
1971				i.mu.Unlock()
1972				return false
1973			}
1974			// Slash commands run regardless of busy state. Commands that
1975			// would mutate the transcript or replace the agent (/clear,
1976			// /compact, /logout, /login, /model) cancel the active turn
1977			// first and wait for the goroutine to wind down so they don't
1978			// race with a streaming response. Safe commands (/help,
1979			// /jump, /sessions, /jail, /unjail, /exit) run immediately
1980			// without disturbing the active turn.
1981			if slashCancelsTurn(head) {
1982				i.cancelAndWaitForIdle()
1983			}
1984			return i.runSlash(ctx, text)
1985		}
1986
1987		if i.agent == nil {
1988			i.mu.Lock()
1989			i.statusErr = "not logged in. type /login first."
1990			i.mu.Unlock()
1991			return false
1992		}
1993		// Mirror the user's typed prompt into the paired Telegram
1994		// chat (when the bridge is active) so the Telegram thread
1995		// stays a complete record of the session, not just the half
1996		// that originated on the phone. On a goroutine so the
1997		// network write doesn't delay the local turn.
1998		if i.telegramBridge != nil && i.telegramBridge.Active() {
1999			go i.telegramBridge.OnUserTyped(text)
2000		}
2001		// If a turn is already in flight, queue this prompt inside the
2002		// agent loop so it is delivered at the next safe model-call
2003		// boundary instead of waiting for the whole run to finish.
2004		i.mu.Lock()
2005		busy := i.busy
2006		ag := i.agent
2007		i.mu.Unlock()
2008		if busy {
2009			if ag != nil {
2010				ag.QueueMessage(text)
2011			} else {
2012				i.mu.Lock()
2013				i.queued = append(i.queued, text)
2014				i.mu.Unlock()
2015			}
2016			i.invalidate()
2017			return false
2018		}
2019		i.startTurn(ctx, text)
2020	}
2021	return false
2022}
2023
2024func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
2025	if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {
2026		return false
2027	}
2028	// Do not steal normal cursor movement. History browsing can only
2029	// start from an empty editor; once active, Left/Right keep walking
2030	// the ring so repeated presses work even though the editor now
2031	// contains the selected historical prompt.
2032	if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
2033		return false
2034	}
2035	hist := i.inputHistory()
2036	if len(hist) == 0 {
2037		return false
2038	}
2039
2040	if i.inputHistoryIndex < 0 {
2041		// Start just after the newest item so Left lands on the most
2042		// recent user prompt and Right keeps the editor empty.
2043		i.inputHistoryIndex = len(hist)
2044	}
2045
2046	switch k.Kind {
2047	case tui.KeyLeft:
2048		if i.inputHistoryIndex > 0 {
2049			i.inputHistoryIndex--
2050		}
2051	case tui.KeyRight:
2052		if i.inputHistoryIndex < len(hist) {
2053			i.inputHistoryIndex++
2054		}
2055	}
2056
2057	if i.inputHistoryIndex >= len(hist) {
2058		i.ed.Clear()
2059	} else {
2060		i.ed.SetValue(hist[i.inputHistoryIndex])
2061	}
2062	return true
2063}
2064
2065func (i *Interactive) inputHistory() []string {
2066	if i.agent == nil {
2067		return nil
2068	}
2069	msgs := i.agent.Messages()
2070	hist := make([]string, 0, len(msgs))
2071	for _, m := range msgs {
2072		if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) {
2073			continue
2074		}
2075		text := userMessageText(m)
2076		if strings.TrimSpace(text) == "" {
2077			continue
2078		}
2079		hist = append(hist, text)
2080	}
2081	return hist
2082}
2083
2084func userMessageText(m provider.Message) string {
2085	var sb strings.Builder
2086	for _, c := range m.Content {
2087		if tb, ok := c.(provider.TextBlock); ok {
2088			if sb.Len() > 0 {
2089				sb.WriteByte('\n')
2090			}
2091			sb.WriteString(tb.Text)
2092		}
2093	}
2094	return sb.String()
2095}
2096
2097// invokeExtensionCommand fires an extension-registered slash command
2098// in a background goroutine, awaits the response, and applies the
2099// requested action (prompt / insert / display / noop). Errors and
2100// timeouts surface as a status_err line.
2101
2102func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
2103	resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
2104	if err != nil {
2105		i.mu.Lock()
2106		i.statusErr = "extension /" + name + ": " + err.Error()
2107		i.statusOK = ""
2108		i.mu.Unlock()
2109		i.invalidate()
2110		return
2111	}
2112	if resp.Error != "" {
2113		i.mu.Lock()
2114		i.statusErr = "extension /" + name + ": " + resp.Error
2115		i.statusOK = ""
2116		i.mu.Unlock()
2117		i.invalidate()
2118		return
2119	}
2120	switch resp.Action {
2121	case "open_panel":
2122		if resp.OpenPanel != nil {
2123			extName := name
2124			if i.cfg.Extensions != nil {
2125				if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
2126					extName = owner
2127				}
2128			}
2129			i.OpenPanel(extName, *resp.OpenPanel)
2130		}
2131	case "prompt":
2132		if strings.TrimSpace(resp.Prompt) == "" {
2133			return
2134		}
2135		i.startTurn(i.runCtx, resp.Prompt)
2136	case "insert":
2137		i.ed.Insert(resp.Insert)
2138		i.invalidate()
2139	case "display":
2140		i.appendExtensionNote(name, resp.Display, "info")
2141	case "noop", "":
2142		// nothing
2143	default:
2144		i.mu.Lock()
2145		i.statusErr = "extension /" + name + ": unknown action " + resp.Action
2146		i.mu.Unlock()
2147		i.invalidate()
2148	}
2149}
2150
2151// appendExtensionNote renders an extension-originated note in the
2152// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
2153// "success" (tool/ok green).
2154func (i *Interactive) appendExtensionNote(extName, msg, level string) {
2155	if msg == "" {
2156		return
2157	}
2158	i.mu.Lock()
2159	defer i.mu.Unlock()
2160	color := i.cfg.Theme.Muted
2161	switch level {
2162	case "warn":
2163		color = i.cfg.Theme.Warning
2164	case "error":
2165		color = i.cfg.Theme.Error
2166	case "success":
2167		color = i.cfg.Theme.Tool
2168	}
2169	prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
2170	for _, line := range strings.Split(msg, "\n") {
2171		i.statusOK = "" // clear any stale ok
2172		i.statusErr = ""
2173		i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
2174	}
2175}
2176
2177// HostHooks implementation for the extension manager. The manager
2178// holds an interface, not a concrete *Interactive, so these methods
2179// are the only thing the manager sees.
2180
2181// Notify is the manager's NotifyFromExt entry point.
2182func (i *Interactive) Notify(extName, level, message string) {
2183	i.appendExtensionNote(extName, message, level)
2184	i.invalidate()
2185}
2186
2187// Submit feeds text through the agent loop as if the user had typed it.
2188func (i *Interactive) Submit(text string) {
2189	i.startTurn(i.runCtx, text)
2190}
2191
2192// SubmitOrQueue runs text immediately if the agent is idle, or
2193// appends it to the pending queue if a turn is already in flight.
2194// Used by the telegram bridge (and by the editor submit path) so
2195// both input sources share the same "queue behind an active turn"
2196// semantics. Images are ignored for now — only the text prompt is
2197// forwarded — because the queued-prompt path is text-only; a
2198// follow-up can expand the queue entry to carry images.
2199func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
2200	i.mu.Lock()
2201	if i.agent == nil {
2202		i.statusErr = "not logged in. type /login first."
2203		i.mu.Unlock()
2204		i.invalidate()
2205		return
2206	}
2207	if i.busy {
2208		// Queue text only; images are dropped for queued prompts.
2209		ag := i.agent
2210		i.mu.Unlock()
2211		if ag != nil {
2212			ag.QueueMessage(text)
2213		} else {
2214			i.mu.Lock()
2215			i.queued = append(i.queued, text)
2216			i.mu.Unlock()
2217		}
2218		i.invalidate()
2219		return
2220	}
2221	i.mu.Unlock()
2222	i.startTurnWithImages(i.runCtx, text, images)
2223}
2224
2225// CancelTurn aborts the active turn if one is running. Used by the
2226// telegram bridge when the paired user sends /stop.
2227// ChangelogVersion returns the version string of the changelog
2228// currently shown (or last shown). Used by the dismiss callback
2229// to store the correct version for dev builds.
2230func (i *Interactive) ChangelogVersion() string {
2231	if i.changelogDialog != nil {
2232		return i.changelogDialog.version
2233	}
2234	return ""
2235}
2236
2237func (i *Interactive) CancelTurn() {
2238	i.mu.Lock()
2239	cancel := i.cancelTurn
2240	i.mu.Unlock()
2241	if cancel != nil {
2242		cancel()
2243		i.confirmDialog.CancelAll("turn cancelled")
2244	}
2245}
2246
2247// Insert places text at the cursor in the editor.
2248func (i *Interactive) Insert(text string) {
2249	i.ed.Insert(text)
2250	i.invalidate()
2251}
2252
2253// Display appends a styled note from extName to the chat without a
2254// model call.
2255func (i *Interactive) Display(extName, text string) {
2256	i.appendExtensionNote(extName, text, "info")
2257	i.invalidate()
2258}
2259
2260func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
2261	i.mu.Lock()
2262	defer i.mu.Unlock()
2263	i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
2264	if i.cfg.Extensions != nil {
2265		cols, rows := i.cfg.Terminal.Size()
2266		_ = cols
2267		_ = rows
2268	}
2269	i.invalidate()
2270}
2271
2272func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
2273	i.mu.Lock()
2274	defer i.mu.Unlock()
2275	if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
2276		i.extPanel.Update(title, lines, footer)
2277		i.invalidate()
2278	}
2279}
2280
2281func (i *Interactive) ClosePanel(extName, panelID string) {
2282	i.mu.Lock()
2283	defer i.mu.Unlock()
2284	if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
2285		i.extPanel.Close()
2286		i.invalidate()
2287	}
2288}
2289
2290func effectiveImageProtocol(override *bool) tui.ImageProtocol {
2291	detected := tui.DetectImageProtocol()
2292	if override == nil {
2293		return detected
2294	}
2295	if !*override {
2296		return tui.ImageProtocolNone
2297	}
2298	return detected
2299}
2300
2301func imageProtocolName(p tui.ImageProtocol) string {
2302	switch p {
2303	case tui.ImageProtocolKitty:
2304		return "kitty/ghostty"
2305	case tui.ImageProtocolITerm2:
2306		return "iTerm2"
2307	default:
2308		return "none"
2309	}
2310}
2311
2312func onOff(v bool) string {
2313	if v {
2314		return "enabled"
2315	}
2316	return "disabled"
2317}
2318
2319func (i *Interactive) openSettingsDialog() {
2320	detected := tui.DetectImageProtocol()
2321	imgEnabled := detected != tui.ImageProtocolNone
2322	if i.cfg.InlineImagesEnabled != nil {
2323		imgEnabled = *i.cfg.InlineImagesEnabled
2324	}
2325	imgDisabled := detected == tui.ImageProtocolNone
2326	imgHint := ""
2327	if imgDisabled {
2328		imgEnabled = false
2329		imgHint = "this terminal does not support inline images"
2330	} else {
2331		imgHint = "terminal supports " + imageProtocolName(detected)
2332	}
2333	i.settingsDialog.Open([]settingsItem{{
2334		key:      "inline_images_enabled",
2335		label:    "render images when supported",
2336		desc:     "draw screenshots inline instead of showing a text placeholder",
2337		value:    imgEnabled,
2338		disabled: imgDisabled,
2339		hint:     imgHint,
2340	}})
2341}
2342
2343func (i *Interactive) applySettingToggle(key string, value bool) {
2344	switch key {
2345	case "inline_images_enabled":
2346		val := value
2347		i.cfg.InlineImagesEnabled = &val
2348		if i.cfg.SettingsStore != nil {
2349			if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
2350				i.mu.Lock()
2351				i.statusErr = "settings: " + err.Error()
2352				i.mu.Unlock()
2353				return
2354			}
2355		}
2356		i.mu.Lock()
2357		i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
2358		i.view.InvalidateRenderCache()
2359		i.statusOK = "inline image rendering " + onOff(value)
2360		i.statusErr = ""
2361		i.mu.Unlock()
2362	}
2363}
2364
2365func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
2366	parts := strings.Fields(cmd)
2367	switch parts[0] {
2368	case "/exit":
2369		return true
2370	case "/clear":
2371		if i.agent != nil {
2372			i.agent.SetMessages(nil)
2373		}
2374		i.mu.Lock()
2375		i.toolCalls = map[string]*tui.ToolCallView{}
2376		i.toolOrder = nil
2377		i.statusErr = ""
2378		i.statusOK = ""
2379		i.helpBlock = nil
2380		i.parkedTurn = 0
2381		i.parkedTotal = 0
2382		i.scrollOffset = 0
2383		i.extNotes = nil
2384		i.view.InvalidateRenderCache()
2385		i.mu.Unlock()
2386	case "/help":
2387		i.mu.Lock()
2388		i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
2389		i.statusErr = ""
2390		i.statusOK = ""
2391		// Pin the viewport to the newest content so the help block,
2392		// which we just appended to the end of the transcript, is
2393		// what the user actually sees.
2394		i.scrollOffset = 0
2395		i.mu.Unlock()
2396	case "/login":
2397		i.dialog.Open(i.cfg.ZotHome)
2398	case "/logout":
2399		if len(parts) >= 2 {
2400			// Explicit target: /logout anthropic | openai | all
2401			i.doLogout(parts[1])
2402			break
2403		}
2404		// No arg: open the picker over whichever providers are
2405		// currently logged in. If nothing's logged in, bail with a
2406		// status line.
2407		i.openLogoutDialog()
2408	case "/model":
2409		if len(parts) >= 2 {
2410			i.applyModelSelection("", parts[1])
2411		} else {
2412			var loggedIn []string
2413			if i.cfg.LoggedInProviders != nil {
2414				loggedIn = i.cfg.LoggedInProviders()
2415			}
2416			i.modelDialog.Open(i.cfg.Model, loggedIn)
2417		}
2418	case "/settings":
2419		i.mu.Lock()
2420		i.statusErr = "/settings is temporarily disabled"
2421		i.statusOK = ""
2422		i.mu.Unlock()
2423	case "/sessions":
2424		i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
2425	case "/jump":
2426		i.openJumpDialog(parts[1:])
2427	case "/btw":
2428		i.openBtwDialog(parts[1:])
2429	case "/skills":
2430		i.openSkillsDialog()
2431	case "/compact":
2432		i.runCompact(ctx, false)
2433	case "/study":
2434		// Canned prompt that tells the agent to read every file
2435		// in the current directory so its later turns have the
2436		// whole project in context. Dispatched through the normal
2437		// queue-or-start path so it behaves identically to
2438		// typing the prompt by hand.
2439		const studyPrompt = "Read and understand everything in the current directory."
2440		i.mu.Lock()
2441		busy := i.busy
2442		ag := i.agent
2443		i.mu.Unlock()
2444		if busy {
2445			if ag != nil {
2446				ag.QueueMessage(studyPrompt)
2447			} else {
2448				i.mu.Lock()
2449				i.queued = append(i.queued, studyPrompt)
2450				i.mu.Unlock()
2451			}
2452			i.invalidate()
2453			break
2454		}
2455		i.startTurn(ctx, studyPrompt)
2456	case "/jail":
2457		if i.cfg.Sandbox == nil {
2458			i.mu.Lock()
2459			i.statusErr = "sandbox not available in this build"
2460			i.mu.Unlock()
2461			break
2462		}
2463		i.cfg.Sandbox.Lock()
2464		i.mu.Lock()
2465		i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
2466		i.statusErr = ""
2467		i.mu.Unlock()
2468	case "/unjail":
2469		if i.cfg.Sandbox == nil {
2470			i.mu.Lock()
2471			i.statusErr = "sandbox not available in this build"
2472			i.mu.Unlock()
2473			break
2474		}
2475		i.cfg.Sandbox.Unlock()
2476		i.mu.Lock()
2477		i.statusOK = "unjailed"
2478		i.statusErr = ""
2479		i.mu.Unlock()
2480	case "/reload-ext":
2481		i.runReloadExt(ctx)
2482	case "/telegram", "/tg":
2483		if len(parts) >= 2 {
2484			i.doTelegram(parts[1])
2485			break
2486		}
2487		i.openTelegramDialog()
2488	case "/session":
2489		if len(parts) >= 2 {
2490			action := parts[1]
2491			arg := ""
2492			if len(parts) >= 3 {
2493				arg = strings.Join(parts[2:], " ")
2494			}
2495			i.doSessionOp(action, arg)
2496			break
2497		}
2498		i.openSessionOpsDialog()
2499	case "/swarm":
2500		i.runSwarm(ctx, parts[1:])
2501	default:
2502		// Last-resort fallback: try the extension manager. Built-in
2503		// cases above always win; this branch only fires for slash
2504		// commands the extension manager registered. Same routing as
2505		// the editor's submit-handler dispatch path so the autocomplete
2506		// "enter on highlighted suggestion" flow also works.
2507		extName := strings.TrimPrefix(parts[0], "/")
2508		if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
2509			rest := ""
2510			if len(parts) > 1 {
2511				rest = strings.Join(parts[1:], " ")
2512			}
2513			go i.invokeExtensionCommand(ctx, extName, rest)
2514			return false
2515		}
2516		i.mu.Lock()
2517		i.statusErr = "unknown command: " + parts[0]
2518		i.mu.Unlock()
2519	}
2520	return false
2521}
2522
2523// openLogoutDialog shows the provider picker for `/logout` with no
2524// argument. Only providers the user is currently logged into are
2525// listed, plus an "all" entry when more than one is present. If
2526// nothing's logged in, writes a status line instead of opening an
2527// empty dialog.
2528func (i *Interactive) openLogoutDialog() {
2529	if i.cfg.AuthManager == nil {
2530		i.mu.Lock()
2531		i.statusErr = "no auth manager configured"
2532		i.mu.Unlock()
2533		i.invalidate()
2534		return
2535	}
2536	store := i.cfg.AuthManager.Store()
2537	if store == nil {
2538		i.mu.Lock()
2539		i.statusErr = "auth store is not available"
2540		i.mu.Unlock()
2541		i.invalidate()
2542		return
2543	}
2544	creds, err := store.Load()
2545	if err != nil {
2546		i.mu.Lock()
2547		i.statusErr = "read auth store: " + err.Error()
2548		i.mu.Unlock()
2549		i.invalidate()
2550		return
2551	}
2552
2553	var items []logoutItem
2554	for _, p := range []string{"anthropic", "openai", "kimi", "google"} {
2555		if creds.Has(p) {
2556			method := creds.Method(p)
2557			if method == "oauth" {
2558				method = "subscription"
2559			}
2560			items = append(items, logoutItem{
2561				label:  providerLabel(p),
2562				target: p,
2563				method: method,
2564			})
2565		}
2566	}
2567	if len(items) == 0 {
2568		i.mu.Lock()
2569		i.statusOK = "no credentials stored; already logged out"
2570		i.statusErr = ""
2571		i.mu.Unlock()
2572		i.invalidate()
2573		return
2574	}
2575	if len(items) > 1 {
2576		items = append(items, logoutItem{label: "all", target: "all"})
2577	}
2578
2579	i.logoutDialog.Open(items)
2580	i.invalidate()
2581}
2582
2583// doLogout clears credentials for the given provider (or all providers)
2584// from auth.json. If the active agent was using those credentials, it
2585// is torn down so the user is forced through /login before their next
2586// prompt.
2587//
2588// target: "anthropic" | "openai" | "kimi" | "all"
2589func (i *Interactive) doLogout(target string) {
2590	if i.cfg.AuthManager == nil {
2591		i.mu.Lock()
2592		i.statusErr = "no auth manager configured"
2593		i.mu.Unlock()
2594		return
2595	}
2596	store := i.cfg.AuthManager.Store()
2597	if store == nil {
2598		i.mu.Lock()
2599		i.statusErr = "auth store is not available"
2600		i.mu.Unlock()
2601		return
2602	}
2603
2604	var providers []string
2605	switch target {
2606	case "", "all":
2607		providers = []string{"anthropic", "openai", "kimi", "google"}
2608	case "anthropic", "openai", "kimi", "google":
2609		providers = []string{target}
2610	default:
2611		i.mu.Lock()
2612		i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, google, or all)"
2613		i.mu.Unlock()
2614		return
2615	}
2616
2617	var errs []string
2618	clearedCurrent := false
2619	for _, p := range providers {
2620		if err := store.Clear(p); err != nil {
2621			errs = append(errs, p+": "+err.Error())
2622			continue
2623		}
2624		if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
2625			if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
2626				errs = append(errs, p+": "+err.Error())
2627				continue
2628			}
2629		}
2630		if p == i.cfg.Provider {
2631			clearedCurrent = true
2632		}
2633	}
2634
2635	i.mu.Lock()
2636	defer i.mu.Unlock()
2637	if len(errs) > 0 {
2638		i.statusErr = "logout errors: " + strings.Join(errs, "; ")
2639		return
2640	}
2641	i.statusErr = ""
2642	if clearedCurrent {
2643		// The running agent was using a credential we just wiped. Drop
2644		// it so prompts can't go out with the stale client, and hint at
2645		// /login.
2646		i.agent = nil
2647		i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
2648	} else {
2649		i.statusOK = "logged out of " + strings.Join(providers, ", ")
2650	}
2651}
2652
2653func (i *Interactive) startAPIKeyFlow(provider string) {
2654	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
2655		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
2656	}
2657	url, err := i.cfg.AuthManager.StartAPIKey(provider)
2658	if err != nil {
2659		i.dialog.ShowResult(false, err.Error())
2660		return
2661	}
2662	i.dialog.ShowWaiting(url)
2663}
2664
2665func (i *Interactive) startOAuthFlow(provider string) {
2666	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
2667		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
2668	}
2669	// Always run the manual/copy-code flow in parallel with the local
2670	// callback server so headless environments (docker, SSH) can paste
2671	// the authorization code directly without first pressing 'p'.
2672	_, err := i.cfg.AuthManager.StartOAuth(provider)
2673	if err != nil {
2674		i.dialog.ShowResult(false, err.Error())
2675		return
2676	}
2677	manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
2678	if mErr == nil {
2679		i.dialog.ShowWaiting(manualURL)
2680	} else {
2681		i.dialog.ShowResult(false, mErr.Error())
2682	}
2683}
2684
2685func (i *Interactive) startManualOAuthFlow(provider string) {
2686	if i.cfg.AuthManager == nil {
2687		return
2688	}
2689	i.cfg.AuthManager.CancelOAuth()
2690	url, err := i.cfg.AuthManager.StartManualOAuth(provider)
2691	if err != nil {
2692		i.dialog.ShowResult(false, err.Error())
2693		return
2694	}
2695	i.dialog.url = url
2696	i.invalidate()
2697}
2698
2699func (i *Interactive) submitManualOAuthCode(code string) {
2700	if i.cfg.AuthManager == nil {
2701		return
2702	}
2703	go func() {
2704		if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
2705			i.dialog.ShowResult(false, err.Error())
2706			i.invalidate()
2707		}
2708	}()
2709}
2710
2711// applyModelSelection switches the active model (and provider, if the
2712// new model belongs to a different one). It rebuilds the underlying
2713// client when needed so the provider wire-protocol matches.
2714// cancelAndWaitForIdle cancels the active turn (if any) and blocks
2715// briefly until the turn goroutine has updated i.busy = false. Used
2716// before destructive slash commands so transcript-mutating work
2717// (/clear, /compact, /logout, /login completion, cross-provider
2718// /model swap) doesn't race with the still-running stream.
2719//
2720// The wait is bounded; if the turn doesn't release within the timeout
2721// we proceed anyway. Worst case is a brief overlap that the agent's
2722// own mutex protects against.
2723func (i *Interactive) cancelAndWaitForIdle() {
2724	i.mu.Lock()
2725	busy := i.busy
2726	cancel := i.cancelTurn
2727	i.mu.Unlock()
2728	if !busy {
2729		return
2730	}
2731	if cancel != nil {
2732		cancel()
2733	}
2734	deadline := time.Now().Add(2 * time.Second)
2735	for time.Now().Before(deadline) {
2736		i.mu.Lock()
2737		done := !i.busy
2738		i.mu.Unlock()
2739		if done {
2740			return
2741		}
2742		time.Sleep(10 * time.Millisecond)
2743	}
2744}
2745
2746// openBtwDialog opens the side-chat overlay with a frozen snapshot
2747// of the current main session. The optional argument is auto-
2748// submitted as the first question, so '/btw does X work?' fires the
2749// model call immediately instead of just opening an empty dialog.
2750func (i *Interactive) openBtwDialog(args []string) {
2751	if i.agent == nil {
2752		i.mu.Lock()
2753		i.statusErr = "not logged in. type /login first."
2754		i.mu.Unlock()
2755		return
2756	}
2757	seed := strings.TrimSpace(strings.Join(args, " "))
2758	i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed, i.invalidate)
2759	i.invalidate()
2760}
2761
2762// openSkillsDialog opens the skill inspector. The picker reflects
2763// whatever SkillSnapshot returns at call time, so edits to a
2764// SKILL.md made during a session show up on the next /skills.
2765func (i *Interactive) openSkillsDialog() {
2766	var list []*skills.Skill
2767	if i.cfg.SkillSnapshot != nil {
2768		list = i.cfg.SkillSnapshot()
2769	}
2770	i.skillsDialog.Open(list)
2771	i.invalidate()
2772}
2773
2774// openJumpDialog builds a /jump picker from the current transcript.
2775// If the user typed "/jump foo" with a filter and it matches exactly
2776// one turn, jump there directly without showing the dialog.
2777func (i *Interactive) openJumpDialog(args []string) {
2778	if i.view == nil || len(i.view.Messages) == 0 {
2779		i.mu.Lock()
2780		i.statusErr = "nothing to jump to \u2014 the session is empty"
2781		i.mu.Unlock()
2782		return
2783	}
2784	filter := strings.TrimSpace(strings.Join(args, " "))
2785	i.jumpDialog.Open(i.view.Messages, filter)
2786	// Shortcut: with a filter argument that matches exactly one turn,
2787	// jump immediately and skip the picker.
2788	if filter != "" {
2789		if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
2790			t := tgts[0]
2791			i.jumpDialog.Close()
2792			i.applyJumpSelection(t.MessageIdx, t.TurnNo)
2793		}
2794	}
2795}
2796
2797// applyJumpSelection scrolls the chat viewport so the user message at
2798// msgIdx is visible at (or near) the top of the chat area. Uses the
2799// anchor slice returned by view.BuildWithAnchors so the mapping from
2800// message index to row is exact, regardless of variable-height tool
2801// blocks above the target.
2802func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
2803	cols := i.lastCols()
2804	chat, anchors := i.view.BuildWithAnchors(cols)
2805	var row int
2806	found := false
2807	for _, a := range anchors {
2808		if a.MessageIdx == msgIdx {
2809			row = a.Row
2810			found = true
2811			break
2812		}
2813	}
2814	if !found {
2815		i.mu.Lock()
2816		i.statusErr = "could not resolve jump target"
2817		i.mu.Unlock()
2818		return
2819	}
2820
2821	chatLen := len(chat)
2822	page := i.chatPage()
2823	if page < 1 {
2824		page = 1
2825	}
2826	// scrollOffset is measured from the bottom of the chat slice, so
2827	// to place `row` at the top of the viewport we want:
2828	//     chatLen - scrollOffset - page == row
2829	// Solve for scrollOffset and clamp to [0, chatLen-page].
2830	offset := chatLen - (row + page)
2831	if offset < 0 {
2832		offset = 0
2833	}
2834	maxOffset := chatLen - page
2835	if maxOffset < 0 {
2836		maxOffset = 0
2837	}
2838	if offset > maxOffset {
2839		offset = maxOffset
2840	}
2841
2842	i.mu.Lock()
2843	i.scrollOffset = offset
2844	i.parkedTurn = turnNo
2845	i.parkedTotal = totalTurnsLocked(i.view.Messages)
2846	i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
2847	i.statusErr = ""
2848	i.mu.Unlock()
2849}
2850
2851// totalTurnsLocked counts user messages in the transcript. Caller is
2852// assumed to hold i.mu (the name is a mild reminder; this function
2853// itself doesn't touch shared state beyond the slice it's handed).
2854func totalTurnsLocked(msgs []provider.Message) int {
2855	n := 0
2856	for _, m := range msgs {
2857		if m.Role == provider.RoleUser {
2858			n++
2859		}
2860	}
2861	return n
2862}
2863
2864// applySessionSelection loads the given session via the cli-provided
2865// callback and snaps the viewport to the bottom (the latest message)
2866// so the user lands at the live tail of the resumed conversation.
2867func (i *Interactive) applySessionSelection(path string) {
2868	if i.cfg.LoadSession == nil {
2869		i.mu.Lock()
2870		i.statusErr = "session loading is not wired in this build"
2871		i.mu.Unlock()
2872		return
2873	}
2874	i.mu.Lock()
2875	if i.sessionLoading {
2876		i.statusErr = "already resuming a session"
2877		i.mu.Unlock()
2878		i.invalidate()
2879		return
2880	}
2881	i.sessionLoading = true
2882	i.statusOK = "resuming session: " + path
2883	i.statusErr = ""
2884	i.mu.Unlock()
2885	i.invalidate()
2886
2887	go func() {
2888		err := i.cfg.LoadSession(path)
2889		i.mu.Lock()
2890		defer i.mu.Unlock()
2891		i.sessionLoading = false
2892		if err != nil {
2893			i.statusErr = err.Error()
2894			i.statusOK = ""
2895			i.invalidate()
2896			return
2897		}
2898		i.statusOK = "resumed session: " + path
2899		i.statusErr = ""
2900		i.parkedTurn = 0
2901		i.parkedTotal = 0
2902		i.scrollOffset = 0
2903		i.extNotes = nil
2904		i.view.InvalidateRenderCache()
2905		if i.agent != nil {
2906			i.view.Messages = i.agent.Messages()
2907			i.cumUsage = i.agent.Cost()
2908		}
2909		i.invalidate()
2910	}()
2911}
2912
2913// scrollToLastTurn parks the viewport at the most recent user turn,
2914// or at the top if the transcript has no user messages. Used after
2915// resume so the user lands looking at where they left off.
2916func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
2917	if len(msgs) == 0 {
2918		i.mu.Lock()
2919		i.scrollOffset = 0
2920		i.mu.Unlock()
2921		return
2922	}
2923	// Find the last user message index.
2924	lastUser := -1
2925	turnNo, totalTurns := 0, 0
2926	for idx, m := range msgs {
2927		if m.Role == provider.RoleUser {
2928			totalTurns++
2929			lastUser = idx
2930		}
2931	}
2932	if lastUser < 0 {
2933		i.mu.Lock()
2934		i.scrollOffset = 0
2935		i.mu.Unlock()
2936		return
2937	}
2938	turnNo = totalTurns
2939
2940	cols := i.lastCols()
2941	chat, anchors := i.view.BuildWithAnchors(cols)
2942	var row int
2943	found := false
2944	for _, a := range anchors {
2945		if a.MessageIdx == lastUser {
2946			row = a.Row
2947			found = true
2948			break
2949		}
2950	}
2951	if !found {
2952		i.mu.Lock()
2953		i.scrollOffset = 0
2954		i.mu.Unlock()
2955		return
2956	}
2957
2958	chatLen := len(chat)
2959	page := i.chatPage()
2960	if page < 1 {
2961		page = 1
2962	}
2963	offset := chatLen - (row + page)
2964	if offset < 0 {
2965		offset = 0
2966	}
2967	maxOffset := chatLen - page
2968	if maxOffset < 0 {
2969		maxOffset = 0
2970	}
2971	if offset > maxOffset {
2972		offset = maxOffset
2973	}
2974
2975	i.mu.Lock()
2976	i.scrollOffset = offset
2977	// Mark the parked-turn footer so the user sees "viewing turn N of
2978	// M - pgdn to catch up" — same affordance as /jump. Tells them at
2979	// a glance that they're looking at history, not the live tail.
2980	if offset > 0 {
2981		i.parkedTurn = turnNo
2982		i.parkedTotal = totalTurns
2983	}
2984	i.mu.Unlock()
2985	i.invalidate()
2986}
2987
2988func (i *Interactive) applyModelSelection(prov, model string) {
2989	i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
2990}
2991
2992// applyRescueModelSelection is like applyModelSelection but routes
2993// through BuildAgentForRescue so launch-time --api-key / --base-url
2994// overrides are dropped before the new agent is built. Falls back to
2995// the regular builder when the host doesn't wire a rescue builder.
2996func (i *Interactive) applyRescueModelSelection(prov, model string) {
2997	builder := i.cfg.BuildAgentForRescue
2998	if builder == nil {
2999		builder = i.cfg.BuildAgentFor
3000	}
3001	i.swapModel(prov, model, builder, true)
3002}
3003
3004// swapModel applies a /model selection (or a rescue selection) using
3005// the supplied builder. rescue=true tags the success message so the
3006// user can see that launch-time overrides were ignored.
3007func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
3008	if model == "" {
3009		return
3010	}
3011	m, err := provider.FindModel(prov, model)
3012	if err != nil {
3013		i.mu.Lock()
3014		i.statusErr = err.Error()
3015		i.mu.Unlock()
3016		return
3017	}
3018	// Same provider AND not a rescue retry: just swap the model on
3019	// the existing agent — no rebuild needed because the underlying
3020	// client is reusable. Rescue retries always rebuild so a stale
3021	// auth header / base URL can't carry over.
3022	if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
3023		i.mu.Lock()
3024		i.cfg.Model = m.ID
3025		i.agent.Model = m.ID
3026		i.statusOK = "model: " + m.ID
3027		i.statusErr = ""
3028		i.mu.Unlock()
3029		if i.cfg.PersistModel != nil {
3030			i.cfg.PersistModel(i.cfg.Provider, m.ID)
3031		}
3032		return
3033	}
3034	if builder == nil {
3035		i.mu.Lock()
3036		i.statusErr = "cannot switch provider: no builder configured"
3037		i.mu.Unlock()
3038		return
3039	}
3040	// Snapshot the current transcript and cumulative usage BEFORE we
3041	// build the replacement agent so we can hand them off. Without
3042	// this the user perceives the entire session as wiped on a
3043	// cross-provider /model swap.
3044	var carryMsgs []provider.Message
3045	var carryCost provider.Usage
3046	if i.agent != nil {
3047		carryMsgs = i.agent.Messages()
3048		carryCost = i.agent.Cost()
3049	}
3050
3051	ag, p, md, err := builder(m.Provider, m.ID)
3052	if err != nil {
3053		i.mu.Lock()
3054		i.statusErr = err.Error()
3055		i.mu.Unlock()
3056		return
3057	}
3058
3059	// Replay the transcript and seed the cost on the freshly-built
3060	// agent. Messages travel cleanly between providers because they
3061	// use the same provider.Message shape; tool-call ids are local
3062	// to a turn so cross-provider continuation never confuses the
3063	// new model (it just sees the assistant's reply, no orphan
3064	// tool_use blocks because /model swaps are gated to idle state).
3065	if len(carryMsgs) > 0 {
3066		ag.SetMessages(carryMsgs)
3067	}
3068	ag.SeedCost(carryCost)
3069
3070	i.mu.Lock()
3071	i.agent = ag
3072	i.cfg.Provider = p
3073	i.cfg.Model = md
3074	if rescue {
3075		i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
3076	} else {
3077		i.statusOK = "switched to " + p + " / " + md
3078	}
3079	i.statusErr = ""
3080	// Render cache keys are width+content based, so the new agent's
3081	// identical messages will reuse the existing entries. Nothing
3082	// to invalidate.
3083	i.mu.Unlock()
3084	// The new agent was built off the base tool registry, so any
3085	// dynamically-registered tools (telegram_send_*) need to be
3086	// reattached. applyTelegramTools is a no-op when the bridge is
3087	// idle so the cross-provider path still works on a vanilla setup.
3088	i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
3089	if i.cfg.PersistModel != nil {
3090		i.cfg.PersistModel(p, md)
3091	}
3092}
3093
3094func (i *Interactive) handleAuthEvent(ev auth.Event) {
3095	switch ev.Kind {
3096	case "started":
3097		i.dialog.ShowWaiting(ev.URL)
3098	case "browser_open":
3099		// no-op
3100	case "error":
3101		i.dialog.ShowResult(false, ev.Message)
3102	case "success":
3103		// Rebuild the agent with the fresh credential.
3104		ag, prov, model, err := i.cfg.BuildAgent()
3105		if err != nil {
3106			i.dialog.ShowResult(false, err.Error())
3107			return
3108		}
3109		i.mu.Lock()
3110		i.agent = ag
3111		i.cfg.Provider = prov
3112		i.cfg.Model = model
3113		i.statusErr = ""
3114		i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
3115		i.mu.Unlock()
3116		i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
3117		i.dialog.ShowResult(true, "")
3118	}
3119}
3120
3121// runCompact invokes core.Agent.Compact and reflects the progress in
3122// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
3123// cancel via the same cancelTurn channel used for normal turns.
3124//
3125// When auto is true the spinner message is pinned to "condensing
3126// history" and the status bar surfaces "(auto)" next to the context
3127// percentage so it's obvious the system triggered this, not the user.
3128func (i *Interactive) runCompact(parent context.Context, auto bool) {
3129	if i.agent == nil {
3130		i.mu.Lock()
3131		i.statusErr = "not logged in. type /login first."
3132		i.mu.Unlock()
3133		return
3134	}
3135	ctx, cancel := context.WithCancel(parent)
3136	i.mu.Lock()
3137	i.busy = true
3138	if auto {
3139		i.spin.StartFixed("condensing history")
3140		i.autoCompacting = true
3141	} else {
3142		i.spin.StartFixed("compacting")
3143	}
3144	i.cancelTurn = cancel
3145	i.statusErr = ""
3146	i.statusOK = ""
3147	// Do NOT set streamOn: the summary text should not be visible
3148	// in the chat while compacting. The user just sees the spinner
3149	// and can keep typing / queue prompts.
3150	i.scrollOffset = 0
3151	i.helpBlock = nil
3152	i.mu.Unlock()
3153	i.invalidate()
3154
3155	go func() {
3156		// Sink discards deltas — we don't stream the summary to the UI.
3157		sink := func(delta string) {}
3158		summary, err := i.agent.Compact(ctx, 4, sink)
3159		_ = summary
3160		i.mu.Lock()
3161		i.busy = false
3162		i.resetStreamingStateLocked()
3163		i.cancelTurn = nil
3164		i.autoCompacting = false
3165
3166		// Drain the queue: if the user typed a prompt while compacting,
3167		// fire it now that the transcript is clean.
3168		var next string
3169		var hasNext bool
3170
3171		switch {
3172		case err != nil && ctx.Err() != nil:
3173			i.statusErr = ""
3174			if auto {
3175				i.statusOK = "auto-condense cancelled"
3176			} else {
3177				i.statusOK = "compaction cancelled"
3178			}
3179			i.queued = nil // drop queue on cancel
3180			if i.agent != nil {
3181				i.agent.DrainQueuedMessages()
3182			}
3183		case err != nil:
3184			i.statusErr = "compaction failed: " + err.Error()
3185			i.statusOK = ""
3186			i.queued = nil // drop queue on error
3187			if i.agent != nil {
3188				i.agent.DrainQueuedMessages()
3189			}
3190		default:
3191			i.statusErr = ""
3192			// Read token count from the compaction message meta.
3193			tokens := ""
3194			msgs := i.agent.Messages()
3195			if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
3196				tokens = msgs[0].Meta["tokens_before"]
3197			}
3198			switch {
3199			case i.pendingPostCompactNote != "":
3200				i.statusOK = i.pendingPostCompactNote
3201			case tokens != "":
3202				i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
3203			default:
3204				i.statusOK = "compacted (ctrl+o to expand)"
3205			}
3206			i.pendingPostCompactNote = ""
3207			i.extNotes = stripAutoCompactNotes(i.extNotes)
3208			i.lastCtxInput = 0
3209			i.toolCalls = map[string]*tui.ToolCallView{}
3210			i.toolOrder = nil
3211			i.view.InvalidateRenderCache()
3212			// Pop queued prompt if any.
3213			if len(i.queued) > 0 {
3214				next, i.queued = i.queued[0], i.queued[1:]
3215				hasNext = true
3216			}
3217		}
3218		i.mu.Unlock()
3219		i.invalidate()
3220
3221		if hasNext {
3222			p := i.runCtx
3223			if p == nil {
3224				p = context.Background()
3225			}
3226			i.startTurn(p, next)
3227		}
3228	}()
3229}
3230
3231func (i *Interactive) startTurn(parent context.Context, prompt string) {
3232	i.startTurnWithImages(parent, prompt, nil)
3233}
3234
3235func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
3236	if i.agent == nil {
3237		return
3238	}
3239	// Force a full repaint when a new turn begins so any stray dialog,
3240	// popup, or stale tool-progress rows don't leak into the visible
3241	// chat area before the assistant starts streaming. Equivalent to
3242	// the user pressing ctrl+l right before submit.
3243	if i.rend != nil {
3244		i.rend.Clear()
3245	}
3246	// Pre-turn safety: if the most recent context measurement is
3247	// already past the auto-compact threshold, condense before
3248	// sending so the next outbound request stays under the limit.
3249	// The condense flow re-fires the user's queued prompt for us, so
3250	// we just hand it off and exit.
3251	i.mu.Lock()
3252	needsPreCompact := !i.autoCompacting && i.shouldAutoCompactLocked()
3253	if needsPreCompact {
3254		if prompt != "" {
3255			i.queued = append([]string{prompt}, i.queued...)
3256		}
3257		i.statusErr = ""
3258		i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending…"))
3259		i.pendingPostCompactNote = "context auto-compacted; sending your last message"
3260		i.mu.Unlock()
3261		i.invalidate()
3262		i.runCompact(parent, true)
3263		return
3264	}
3265	i.mu.Unlock()
3266
3267	ctx, cancel := context.WithCancel(parent)
3268	i.mu.Lock()
3269	i.busy = true
3270	i.spin.Start()
3271	i.cancelTurn = cancel
3272	i.statusErr = ""
3273	i.statusOK = ""
3274	i.streaming.Reset()
3275	i.streamOn = true
3276	i.toolCalls = map[string]*tui.ToolCallView{}
3277	i.toolOrder = nil
3278	i.scrollOffset = 0 // jump back to the bottom on new turn
3279	// Reset the auto-follow baseline so the very next render at
3280	// interactive.go:1053 doesn't see a synthetic shrink between
3281	// "last frame had the previous turn's tool overlay" and
3282	// "this frame had it cleared above". Without this, the guard
3283	// reads delta = -(rows in cleared overlay) and decrements
3284	// scrollOffset, which on terminals that mirror zot's pane
3285	// scroll into the host scrollbar visibly yanks the viewport.
3286	// See autofollow_shrink_test.go for the exact arithmetic.
3287	i.prevChatLen = 0
3288	i.prevChatCols = 0
3289	i.parkedTurn = 0 // starting a turn clears the /jump parked state
3290	i.parkedTotal = 0
3291	i.helpBlock = nil // hide the help block once the user asks something
3292	i.mu.Unlock()
3293	i.invalidate()
3294
3295	sink := func(ev core.AgentEvent) {
3296		i.handleEvent(ev)
3297		i.invalidate()
3298	}
3299
3300	go func() {
3301		err := i.agent.Prompt(ctx, prompt, images, sink)
3302		i.mu.Lock()
3303		i.busy = false
3304		// Don't touch streamPending / streamFlushPending here — the
3305		// pacer may still be draining the final deltas and needs to
3306		// paint them even though Prompt has returned. It will reset
3307		// streamOn on its own once the buffer empties.
3308		if len(i.streamPending) == 0 {
3309			i.streamOn = false
3310		}
3311		i.cancelTurn = nil
3312		if err != nil && ctx.Err() == nil {
3313			i.statusErr = err.Error()
3314		}
3315		// Decide whether to offer a model rescue picker for recoverable
3316		// provider failures (auth/rate/temporary). The picker opens after
3317		// the mutex is released so it can take its own locks freely.
3318		var (
3319			offer       bool
3320			rescueWhy   string
3321			rescueImgs  []provider.ImageBlock
3322			rescueModel string
3323			rescueProv  string
3324			rescueFprov string
3325		)
3326		if err != nil && ctx.Err() == nil {
3327			if ok, reason := classifyRescueError(err); ok {
3328				offer = true
3329				rescueWhy = reason
3330				rescueImgs = images
3331				rescueModel = i.cfg.Model
3332				rescueProv = i.cfg.Provider
3333				rescueFprov = extractFailedProvider(err)
3334				if rescueFprov == "" {
3335					rescueFprov = i.cfg.Provider
3336				}
3337				// Suppress the red banner — the rescue dialog already
3338				// surfaces the failure.
3339				i.statusErr = ""
3340			}
3341		}
3342		// Detect HTTP 413 "payload too large" responses. The provider
3343		// rejected the request because the request body exceeded its
3344		// per-request limit. Token-based auto-compact can miss this
3345		// because the limit is on raw bytes, not tokens. Re-queue the
3346		// prompt so it survives the condense pass and trigger one.
3347		payloadTooLarge := err != nil && ctx.Err() == nil && isPayloadTooLargeError(err)
3348		if payloadTooLarge {
3349			i.statusErr = ""
3350			i.queued = append([]string{prompt}, i.queued...)
3351			i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
3352			i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
3353		}
3354		// Persist the assistant's reply (and every tool row before
3355		// it) to the session file while the turn memory is hot.
3356		// Without this, WriteNewTranscript only fires at zot exit,
3357		// meaning a crash or ungraceful kill drops the whole
3358		// conversation. FlushSession is idempotent (it advances the
3359		// baseline so subsequent flushes only write new rows).
3360		flush := i.cfg.FlushSession
3361		i.mu.Unlock()
3362		if flush != nil {
3363			flush()
3364		}
3365		i.mu.Lock()
3366		// Pop the next queued message, if any, and relaunch.
3367		var next string
3368		var hasNext bool
3369		if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
3370			next, i.queued = i.queued[0], i.queued[1:]
3371			hasNext = true
3372		}
3373		// If the turn was cancelled or errored, drop the queue so the
3374		// user isn't bombarded with stale messages after an interrupt.
3375		if ctx.Err() != nil || err != nil {
3376			i.queued = nil
3377			if i.agent != nil {
3378				i.agent.DrainQueuedMessages()
3379			}
3380		}
3381		// Decide whether the next thing to do is an auto-compaction.
3382		// Only fires when the turn completed cleanly AND no host-side
3383		// or agent-side queued messages are waiting (otherwise a queued
3384		// message would race the condense).
3385		agentQueued := 0
3386		if i.agent != nil {
3387			agentQueued = i.agent.QueuedMessageCount()
3388		}
3389		shouldAutoCompact := !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
3390		i.mu.Unlock()
3391		i.invalidate()
3392		parent := i.runCtx
3393		if parent == nil {
3394			parent = context.Background()
3395		}
3396		switch {
3397		case hasNext:
3398			i.startTurn(parent, next)
3399		case offer:
3400			i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
3401		case payloadTooLarge:
3402			i.runCompact(parent, true)
3403		case shouldAutoCompact:
3404			i.runCompact(parent, true)
3405		}
3406	}()
3407}
3408
3409// openRescueDialog surfaces the rescue model picker after a recoverable
3410// provider failure. The pending prompt + images are stashed on the
3411// Interactive so a later applyRescueSelection can re-run the same turn
3412// against the freshly-picked model. activeProvider/failedProvider are
3413// usually the same, but some clients embed different prefixes in their
3414// errors than the configured provider id, so we accept both.
3415func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
3416	if i.rescueDialog == nil {
3417		return
3418	}
3419	loggedIn := []string{}
3420	if i.cfg.LoggedInProviders != nil {
3421		loggedIn = i.cfg.LoggedInProviders()
3422	}
3423	fprov := failedProvider
3424	if fprov == "" {
3425		fprov = activeProvider
3426	}
3427	i.mu.Lock()
3428	i.pendingRescuePrompt = prompt
3429	i.pendingRescueImages = images
3430	i.mu.Unlock()
3431	i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
3432	i.invalidate()
3433}
3434
3435// applyRescueSelection switches model (cross-provider if needed) and
3436// re-runs the same prompt+images that just failed. Mirrors
3437// applyModelSelection's transcript-carry logic so the user keeps full
3438// session continuity across the swap.
3439func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
3440	if model == "" {
3441		return
3442	}
3443	i.applyRescueModelSelection(prov, model)
3444	i.mu.Lock()
3445	images := i.pendingRescueImages
3446	if prompt == "" {
3447		prompt = i.pendingRescuePrompt
3448	}
3449	i.pendingRescuePrompt = ""
3450	i.pendingRescueImages = nil
3451	i.mu.Unlock()
3452	parent := i.runCtx
3453	if parent == nil {
3454		parent = context.Background()
3455	}
3456	i.startTurnWithImages(parent, prompt, images)
3457}
3458
3459func stripAutoCompactNotes(notes []string) []string {
3460	if len(notes) == 0 {
3461		return notes
3462	}
3463	out := notes[:0]
3464	for _, n := range notes {
3465		if strings.Contains(n, "condensing history") {
3466			continue
3467		}
3468		out = append(out, n)
3469	}
3470	return out
3471}
3472
3473// autoCompactNoteLine returns a styled chat-area note for the
3474// inline auto-compact heads-up. Lives in extNotes so it survives
3475// the busy-spinner overwrite of the status row.
3476func autoCompactNoteLine(th tui.Theme, msg string) string {
3477	return "  " + th.FG256(th.Warning, "⚠ "+msg)
3478}
3479
3480// isPayloadTooLargeError matches HTTP 413 responses surfaced by the
3481// provider clients. The error formatting differs slightly between
3482// providers (anthropic and openai both prepend the status code), so
3483// we look for the canonical 413 marker as well as the conventional
3484// 'payload too large' phrase.
3485func isPayloadTooLargeError(err error) bool {
3486	if err == nil {
3487		return false
3488	}
3489	msg := strings.ToLower(err.Error())
3490	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")
3491}
3492
3493// autoCompactThreshold is the context-window fraction at which the
3494// agent will auto-compact after a turn ends. 0.85 leaves enough
3495// headroom for one more user prompt + response before we bump the
3496// hard limit.
3497const autoCompactThreshold = 0.85
3498
3499// shouldAutoCompactLocked reports whether the last turn pushed context
3500// usage past the auto-compact threshold. Must be called with i.mu
3501// held; it reads lastCtxInput and the current model's context window.
3502func (i *Interactive) shouldAutoCompactLocked() bool {
3503	if i.agent == nil {
3504		return false
3505	}
3506	if i.autoCompacting {
3507		return false
3508	}
3509	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
3510	if err != nil || m.ContextWindow <= 0 {
3511		return false
3512	}
3513	if i.lastCtxInput <= 0 {
3514		return false
3515	}
3516	return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
3517}
3518
3519func (i *Interactive) handleEvent(ev core.AgentEvent) {
3520	i.mu.Lock()
3521	defer i.mu.Unlock()
3522	switch e := ev.(type) {
3523	case core.EvAssistantStart:
3524		// Fires at the top of every oneTurn, including follow-up
3525		// turns after tool use. Without this, the streaming buffer
3526		// is still marked off from the previous assistant message
3527		// and the final summary text pops in all at once instead
3528		// of typewriter-streaming delta by delta.
3529		i.streaming.Reset()
3530		i.streamPending = i.streamPending[:0]
3531		i.streamFlushPending = false
3532		i.streamOn = true
3533		// Clear the live tool-call overlay. Any tools from the
3534		// previous round are now fully folded into the transcript
3535		// (assistant tool_use block + tool role message with the
3536		// result), so keeping them in the overlay would duplicate
3537		// them in the view — once inside the finalised transcript
3538		// and once below the streaming block, with the streaming
3539		// summary sandwiched in between. The next EvToolUseStart
3540		// will populate fresh entries for this turn's tools.
3541		i.toolCalls = map[string]*tui.ToolCallView{}
3542		i.toolOrder = nil
3543	case core.EvTextDelta:
3544		// Buffer into streamPending; the paintPace ticker drains
3545		// it into i.streaming a few runes at a time for a smooth
3546		// typewriter effect independent of upstream chunk size.
3547		i.streamPending = append(i.streamPending, []rune(e.Delta)...)
3548		i.streamOn = true
3549	case core.EvAssistantMessage:
3550		// OnAssistant + telegram mirroring always fire on message
3551		// arrival — they read the FINAL message content, which is
3552		// complete regardless of what's still in the pacer.
3553		i.assistantMessageSideEffects(e.Message)
3554		// If the pacer still has characters to drain, keep streamOn
3555		// true and mark flush pending; the paintPace ticker will
3556		// drain the remainder and reset streaming state when done.
3557		// Otherwise (rare: full-replay sessions, abort paths) clear
3558		// synchronously so a later render doesn't show stale text.
3559		if len(i.streamPending) > 0 {
3560			i.streamFlushPending = true
3561			return
3562		}
3563		i.resetStreamingStateLocked()
3564	case core.EvToolUseStart:
3565		// Live streaming: pre-create the view so the user sees the
3566		// tool call being composed in real time. Any subsequent
3567		// EvToolCall for the same ID updates the same struct (the
3568		// final parsed args + name are already known here).
3569		if _, exists := i.toolCalls[e.ID]; !exists {
3570			i.toolCalls[e.ID] = &tui.ToolCallView{
3571				ID:        e.ID,
3572				Name:      e.Name,
3573				Streaming: true,
3574			}
3575			i.toolOrder = append(i.toolOrder, e.ID)
3576		}
3577	case core.EvToolUseArgs:
3578		if tc, ok := i.toolCalls[e.ID]; ok {
3579			tc.RawJSONBuf += e.Delta
3580			// Refresh the live path as soon as it parses; used in
3581			// the header (write /Users/pat/Desktop/demo.ts)
3582			// while the content is still streaming.
3583			if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
3584				tc.LivePath = p
3585			} else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
3586				tc.LivePath = p
3587			}
3588		}
3589	case core.EvToolUseEnd:
3590		if tc, ok := i.toolCalls[e.ID]; ok {
3591			tc.Streaming = false
3592		}
3593	case core.EvToolCall:
3594		// If we already pre-created the view during streaming, just
3595		// refresh the final Args summary. Otherwise create a new one
3596		// (non-streaming providers or legacy paths).
3597		if tc, ok := i.toolCalls[e.ID]; ok {
3598			tc.Args = tui.ShortArgs(e.Name, e.Args)
3599			tc.Streaming = false
3600		} else {
3601			i.toolCalls[e.ID] = &tui.ToolCallView{
3602				ID:   e.ID,
3603				Name: e.Name,
3604				Args: tui.ShortArgs(e.Name, e.Args),
3605			}
3606			i.toolOrder = append(i.toolOrder, e.ID)
3607		}
3608	case core.EvToolResult:
3609		if tc, ok := i.toolCalls[e.ID]; ok {
3610			tc.Done = true
3611			tc.Error = e.Result.IsError
3612			var text strings.Builder
3613			for _, c := range e.Result.Content {
3614				if tb, ok := c.(provider.TextBlock); ok {
3615					if text.Len() > 0 {
3616						text.WriteString("\n")
3617					}
3618					text.WriteString(tb.Text)
3619				}
3620			}
3621			tc.Result = text.String()
3622		}
3623		if i.cfg.OnToolResult != nil {
3624			i.cfg.OnToolResult(e.ID, e.Result)
3625		}
3626	case core.EvUsage:
3627		i.cumUsage = e.Cumulative
3628		if e.Usage.InputTokens > 0 {
3629			i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
3630		}
3631	case core.EvTurnEnd:
3632		if e.Stop == provider.StopAborted {
3633			i.resetStreamingStateLocked()
3634			i.statusErr = ""
3635			i.statusOK = "cancelled"
3636			return
3637		}
3638		// Don't surface mid-loop stream errors as a red banner here.
3639		// EvTurnEnd fires after every step in a multi-step tool loop,
3640		// so a transient 503 / network blip would briefly paint a red
3641		// banner over the still-streaming chat before the agent loop
3642		// either retries or exits. The final error (if any) is set by
3643		// startTurnWithImages once Prompt() returns, and recoverable
3644		// failures are routed to the rescue picker instead — which
3645		// keeps the chat clean while the agent is working.
3646		_ = e.Err
3647	}
3648}
3649
3650// Agent returns the current agent, if any. Used by cli.go to flush the
3651// final transcript to the session file.
3652func (i *Interactive) Agent() *core.Agent {
3653	i.mu.Lock()
3654	defer i.mu.Unlock()
3655	return i.agent
3656}
3657
3658// silence unused import in some build configs
3659var _ = fmt.Sprintf
3660
3661// runReloadExt triggers a live reload of every extension (discovered
3662// + explicit). Runs on a goroutine so the TUI stays responsive; the
3663// Manager.Reload takes a couple of hundred ms to shut down subprocs
3664// and respawn them. Shows a status line throughout.
3665func (i *Interactive) runReloadExt(ctx context.Context) {
3666	if i.cfg.Extensions == nil {
3667		i.mu.Lock()
3668		i.statusErr = "no extension manager in this build"
3669		i.mu.Unlock()
3670		i.invalidate()
3671		return
3672	}
3673	i.mu.Lock()
3674	i.statusOK = "reloading extensions…"
3675	i.statusErr = ""
3676	i.mu.Unlock()
3677	i.invalidate()
3678
3679	go func() {
3680		stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
3681		msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
3682		if len(stats.Errors) > 0 {
3683			msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
3684		}
3685		i.mu.Lock()
3686		i.statusOK = msg
3687		i.statusErr = ""
3688		i.mu.Unlock()
3689		i.invalidate()
3690	}()
3691}
3692
3693// Confirm implements core.Confirmer. The agent goroutine calls
3694// this synchronously before every tool invocation when --no-yolo is
3695// active. We push the request onto the confirmDialog queue, trigger
3696// a redraw, and block the caller until the user answers.
3697//
3698// If the session is cancelled or the TUI exits mid-prompt, any
3699// pending request is refused via CancelAll so the agent doesn't
3700// deadlock.
3701func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
3702	resp := make(chan core.ConfirmDecision, 1)
3703	i.confirmDialog.Enqueue(&confirmRequest{
3704		toolName: toolName,
3705		preview:  preview,
3706		resp:     resp,
3707	})
3708	i.invalidate()
3709	return <-resp
3710}
3711
3712// openTelegramDialog shows the picker for `/telegram` with no arg.
3713// Items depend on current state: disconnect + status when running,
3714// connect + status when stopped.
3715func (i *Interactive) openTelegramDialog() {
3716	items := i.telegramMenuItems()
3717	if len(items) == 0 {
3718		i.mu.Lock()
3719		i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
3720		i.mu.Unlock()
3721		i.invalidate()
3722		return
3723	}
3724	i.telegramDialog.Open(items)
3725	i.invalidate()
3726}
3727
3728// telegramMenuItems builds the dialog entries for the current
3729// bridge state. Returns empty when no bot.json exists so the
3730// caller can show a helpful status line instead of an empty menu.
3731func (i *Interactive) telegramMenuItems() []telegramItem {
3732	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
3733	if err != nil || cfg.BotToken == "" {
3734		return nil
3735	}
3736	var items []telegramItem
3737	if i.telegramBridge != nil && i.telegramBridge.Active() {
3738		items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
3739		st := i.telegramBridge.State()
3740		hint := "active"
3741		if st.Username != "" {
3742			hint += " as @" + st.Username
3743		}
3744		items = append(items, telegramItem{label: "status", action: "status", hint: hint})
3745	} else {
3746		label := "connect"
3747		hint := "start mirroring dms into this session"
3748		if cfg.AllowedUserID == 0 {
3749			hint = "awaiting pairing (send /start to the bot once connected)"
3750		}
3751		items = append(items, telegramItem{label: label, action: "connect", hint: hint})
3752		items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
3753	}
3754	return items
3755}
3756
3757// doTelegram dispatches one of the three explicit actions. Called
3758// from /telegram <action> or after the picker selects a row.
3759func (i *Interactive) doTelegram(action string) {
3760	switch action {
3761	case "connect":
3762		i.telegramConnect()
3763	case "disconnect":
3764		i.telegramDisconnect()
3765	case "status":
3766		i.telegramStatus()
3767	default:
3768		i.mu.Lock()
3769		i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
3770		i.mu.Unlock()
3771		i.invalidate()
3772	}
3773}
3774
3775// telegramConnect starts the bridge. Refuses if it's already
3776// running or if the on-disk bot.json is missing a token.
3777func (i *Interactive) telegramConnect() {
3778	if i.telegramBridge != nil && i.telegramBridge.Active() {
3779		i.mu.Lock()
3780		i.statusOK = "telegram already connected"
3781		i.statusErr = ""
3782		i.mu.Unlock()
3783		i.invalidate()
3784		return
3785	}
3786	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
3787	if err != nil {
3788		i.mu.Lock()
3789		i.statusErr = "telegram: " + err.Error()
3790		i.mu.Unlock()
3791		i.invalidate()
3792		return
3793	}
3794	if cfg.BotToken == "" {
3795		i.mu.Lock()
3796		i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
3797		i.mu.Unlock()
3798		i.invalidate()
3799		return
3800	}
3801	// Refuse to start when a background daemon is already polling
3802	// the same bot. Two concurrent long-poll consumers race each
3803	// update and one always loses, so DMs get half-delivered. The
3804	// user can `zot telegram-bot stop` first, then /telegram connect.
3805	if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
3806		i.mu.Lock()
3807		i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
3808		i.mu.Unlock()
3809		i.invalidate()
3810		return
3811	}
3812	i.telegramBridge = &telegram.Bridge{
3813		Client: telegram.NewClient(cfg.BotToken),
3814		Config: cfg,
3815		Save: func(next telegram.Config) error {
3816			return telegram.SaveConfig(i.cfg.ZotHome, next)
3817		},
3818		Host: &telegramHost{iv: i},
3819	}
3820	if err := i.telegramBridge.Start(i.runCtx); err != nil {
3821		i.telegramBridge = nil
3822		i.mu.Lock()
3823		i.statusErr = "telegram connect failed: " + err.Error()
3824		i.mu.Unlock()
3825		i.invalidate()
3826		return
3827	}
3828	i.applyTelegramTools(true)
3829	state := i.telegramBridge.State()
3830	label := "telegram connected"
3831	if state.Username != "" {
3832		label += " as @" + state.Username
3833	}
3834	if state.PairedID == 0 {
3835		label += " — send /start to the bot from your phone to claim it"
3836	}
3837	i.mu.Lock()
3838	i.statusOK = label
3839	i.statusErr = ""
3840	i.mu.Unlock()
3841	i.invalidate()
3842}
3843
3844// telegramDisconnect stops the bridge. No-op when already stopped.
3845func (i *Interactive) telegramDisconnect() {
3846	if i.telegramBridge == nil || !i.telegramBridge.Active() {
3847		i.mu.Lock()
3848		i.statusOK = "telegram already disconnected"
3849		i.statusErr = ""
3850		i.mu.Unlock()
3851		i.invalidate()
3852		return
3853	}
3854	i.telegramBridge.Stop()
3855	i.applyTelegramTools(false)
3856	i.mu.Lock()
3857	i.statusOK = "telegram disconnected"
3858	i.statusErr = ""
3859	i.mu.Unlock()
3860	i.invalidate()
3861}
3862
3863// telegramSenderAdapter wraps the bridge so the tools package can
3864// drive it without importing telegram directly. The Active() check
3865// is forwarded to the bridge so the tool can fail clearly with a
3866// model-readable error when the user disconnected mid-turn.
3867type telegramSenderAdapter struct {
3868	bridge *telegram.Bridge
3869}
3870
3871func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
3872	if a.bridge == nil {
3873		return fmt.Errorf("telegram bridge is not connected")
3874	}
3875	return a.bridge.SendImage(ctx, path, caption)
3876}
3877
3878func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
3879	if a.bridge == nil {
3880		return fmt.Errorf("telegram bridge is not connected")
3881	}
3882	return a.bridge.SendDocument(ctx, path, caption)
3883}
3884
3885func (a telegramSenderAdapter) Active() bool {
3886	return a.bridge != nil && a.bridge.Active()
3887}
3888
3889// applyTelegramTools registers (active=true) or removes (active=false)
3890// the telegram_send_image and telegram_send_file tools on the running
3891// agent so the model only sees them while the bridge is connected.
3892// Snapshots and mutates the live tool registry so any extension or
3893// /reload-ext additions made while Telegram is connected survive a
3894// later /telegram disconnect (we only add or strip the two telegram
3895// entries, never the rest).
3896func (i *Interactive) applyTelegramTools(active bool) {
3897	if i.agent == nil {
3898		return
3899	}
3900	current := i.agent.Tools
3901	next := core.Registry{}
3902	for name, t := range current {
3903		if name == "telegram_send_image" || name == "telegram_send_file" {
3904			continue
3905		}
3906		next[name] = t
3907	}
3908	if active {
3909		sender := telegramSenderAdapter{bridge: i.telegramBridge}
3910		next["telegram_send_image"] = &tools.TelegramSendImageTool{
3911			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
3912		}
3913		next["telegram_send_file"] = &tools.TelegramSendFileTool{
3914			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
3915		}
3916	}
3917	i.agent.SetTools(next)
3918}
3919
3920// telegramStatus writes a one-liner describing the bridge state.
3921// Reports on both the in-tui bridge and the background daemon so
3922// the user isn't confused when the daemon owns the poll loop.
3923func (i *Interactive) telegramStatus() {
3924	var msg string
3925	if i.telegramBridge != nil && i.telegramBridge.Active() {
3926		s := i.telegramBridge.State()
3927		msg = "telegram: connected (tui bridge)"
3928		if s.Username != "" {
3929			msg += " as @" + s.Username
3930		}
3931		if s.PairedID != 0 {
3932			msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
3933		} else {
3934			msg += " - awaiting pairing"
3935		}
3936	} else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
3937		msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
3938	} else {
3939		cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
3940		if cfg.BotToken == "" {
3941			msg = "telegram: not configured. run `zot telegram-bot setup` first."
3942		} else {
3943			msg = "telegram: disconnected"
3944			if cfg.BotUsername != "" {
3945				msg += " (@" + cfg.BotUsername + " ready to connect)"
3946			}
3947		}
3948	}
3949	i.mu.Lock()
3950	i.statusOK = msg
3951	i.statusErr = ""
3952	i.mu.Unlock()
3953	i.invalidate()
3954}
3955
3956// telegramHost adapts *Interactive to telegram.Host so the bridge
3957// can call back into the TUI without importing modes directly.
3958type telegramHost struct{ iv *Interactive }
3959
3960func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
3961	h.iv.SubmitOrQueue(prompt, images)
3962}
3963
3964func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
3965
3966func (h *telegramHost) Status() string {
3967	h.iv.mu.Lock()
3968	providerName := h.iv.cfg.Provider
3969	model := h.iv.cfg.Model
3970	cwd := h.iv.cfg.CWD
3971	usage := h.iv.cumUsage
3972	subscription := h.iv.cfg.AuthMethod == "oauth"
3973	ctxUsed := h.iv.lastCtxInput
3974	busy := h.iv.busy
3975	queued := len(h.iv.queued)
3976	h.iv.mu.Unlock()
3977
3978	ctxMax := 0
3979	if m, err := provider.FindModel(providerName, model); err == nil {
3980		ctxMax = m.ContextWindow
3981	}
3982	return telegram.FormatStatus(telegram.StatusSnapshot{
3983		Provider:     providerName,
3984		Model:        model,
3985		CWD:          cwd,
3986		Usage:        usage,
3987		Subscription: subscription,
3988		ContextUsed:  ctxUsed,
3989		ContextMax:   ctxMax,
3990		Busy:         busy,
3991		Queued:       queued,
3992	})
3993}
3994
3995func (h *telegramHost) Notify(level, message string) {
3996	h.iv.mu.Lock()
3997	switch level {
3998	case "error", "warn":
3999		h.iv.statusErr = message
4000		h.iv.statusOK = ""
4001	default:
4002		h.iv.statusOK = message
4003		h.iv.statusErr = ""
4004	}
4005	h.iv.mu.Unlock()
4006	h.iv.invalidate()
4007}
4008
4009// openSessionOpsDialog shows the picker for `/session` with no arg.
4010// Always offers export, import, fork, tree; the handlers bail with
4011// a clear status message when the precondition isn't met (empty
4012// transcript on fork; no parent/siblings on tree).
4013func (i *Interactive) openSessionOpsDialog() {
4014	items := []sessionOpsItem{
4015		{label: "export", action: "export", hint: "write the current session to a .zotsession file"},
4016		{label: "import", action: "import", hint: "load a .zotsession file into this directory"},
4017		{label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
4018		{label: "tree", action: "tree", hint: "switch between branches in this directory"},
4019	}
4020	i.sessionOpsDialog.Open(items)
4021	i.invalidate()
4022}
4023
4024// doSessionOp dispatches export, import, fork, or tree. arg is the
4025// optional positional argument from e.g. /session export <path>
4026// or /session import <path>; fork and tree ignore it.
4027func (i *Interactive) doSessionOp(action, arg string) {
4028	switch action {
4029	case "export":
4030		i.doSessionExport(arg)
4031	case "import":
4032		i.doSessionImport(arg)
4033	case "fork":
4034		i.doSessionFork()
4035	case "tree":
4036		i.doSessionTree()
4037	default:
4038		i.mu.Lock()
4039		i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
4040		i.mu.Unlock()
4041		i.invalidate()
4042	}
4043}
4044
4045// doSessionExport writes the live session file to destination path
4046// dst. When dst is empty we default to ~/Downloads (falling back to
4047// the user's home directory if it doesn't exist). The helper
4048// expands a leading `~` and creates any missing parent directories.
4049func (i *Interactive) doSessionExport(dst string) {
4050	if i.cfg.CurrentSessionPath == nil {
4051		i.mu.Lock()
4052		i.statusErr = "export: no session is active (running with --no-session?)"
4053		i.mu.Unlock()
4054		i.invalidate()
4055		return
4056	}
4057	src := i.cfg.CurrentSessionPath()
4058	if src == "" {
4059		i.mu.Lock()
4060		i.statusErr = "export: no session is active (running with --no-session?)"
4061		i.mu.Unlock()
4062		i.invalidate()
4063		return
4064	}
4065	// Persist any in-memory agent messages to the session file so
4066	// the export carries the full conversation. Without this, the
4067	// default lazy-flush-at-exit strategy leaves most of a running
4068	// session unwritten and the export ends up with only the meta.
4069	if i.cfg.FlushSession != nil {
4070		i.cfg.FlushSession()
4071	}
4072	dst = unquotePath(dst)
4073	if dst == "" {
4074		dst = defaultExportDir()
4075	} else {
4076		dst = expandTilde(dst)
4077	}
4078	out, err := core.ExportSession(src, dst)
4079	if err != nil {
4080		i.mu.Lock()
4081		i.statusErr = "export: " + err.Error()
4082		i.mu.Unlock()
4083		i.invalidate()
4084		return
4085	}
4086	i.mu.Lock()
4087	i.statusOK = "exported session to " + friendlyPath(out)
4088	i.statusErr = ""
4089	i.mu.Unlock()
4090	i.invalidate()
4091}
4092
4093// doSessionImport copies the .zotsession file at src into the
4094// running cwd's sessions directory and loads it as the active
4095// session, same as `/sessions` -> pick. When src is empty we ask
4096// the user to pass a path (no usable default here).
4097func (i *Interactive) doSessionImport(src string) {
4098	src = unquotePath(src)
4099	if src == "" {
4100		i.mu.Lock()
4101		i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
4102		i.mu.Unlock()
4103		i.invalidate()
4104		return
4105	}
4106	src = expandTilde(src)
4107	if _, err := os.Stat(src); err != nil {
4108		i.mu.Lock()
4109		i.statusErr = "import: " + err.Error()
4110		i.mu.Unlock()
4111		i.invalidate()
4112		return
4113	}
4114	newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version)
4115	if err != nil {
4116		i.mu.Lock()
4117		i.statusErr = "import: " + err.Error()
4118		i.mu.Unlock()
4119		i.invalidate()
4120		return
4121	}
4122	if i.cfg.LoadSession == nil {
4123		i.mu.Lock()
4124		i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
4125		i.statusErr = ""
4126		i.mu.Unlock()
4127		i.invalidate()
4128		return
4129	}
4130	if err := i.cfg.LoadSession(newPath); err != nil {
4131		i.mu.Lock()
4132		i.statusErr = "import: load failed: " + err.Error()
4133		i.mu.Unlock()
4134		i.invalidate()
4135		return
4136	}
4137	i.mu.Lock()
4138	i.statusOK = "imported and switched to session " + friendlyPath(newPath)
4139	i.statusErr = ""
4140	i.mu.Unlock()
4141	i.invalidate()
4142}
4143
4144// defaultExportDir returns ~/Downloads when it exists, or ~ as a
4145// fallback, or /tmp on exotic machines with no home dir.
4146func defaultExportDir() string {
4147	home, err := os.UserHomeDir()
4148	if err != nil || home == "" {
4149		return os.TempDir()
4150	}
4151	downloads := filepath.Join(home, "Downloads")
4152	if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
4153		return downloads
4154	}
4155	return home
4156}
4157
4158// expandTilde turns a leading ~ into the user's home directory.
4159// Returns the input unchanged when there's no tilde or no home.
4160func expandTilde(p string) string {
4161	if p == "" || p[0] != '~' {
4162		return p
4163	}
4164	home, err := os.UserHomeDir()
4165	if err != nil || home == "" {
4166		return p
4167	}
4168	if len(p) == 1 {
4169		return home
4170	}
4171	if p[1] == '/' || p[1] == filepath.Separator {
4172		return filepath.Join(home, p[2:])
4173	}
4174	return p
4175}
4176
4177// unquotePath strips a matching pair of surrounding single or
4178// double quotes. Drag-drop paste in the tui auto-quotes dropped
4179// file paths so the shell-like `/session import 'foo bar.zs'`
4180// stays well-formed; when the TUI's own slash handler consumes
4181// the arg, we want the raw path back.
4182func unquotePath(p string) string {
4183	p = strings.TrimSpace(p)
4184	if len(p) >= 2 {
4185		first := p[0]
4186		last := p[len(p)-1]
4187		if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
4188			p = p[1 : len(p)-1]
4189		}
4190	}
4191	return p
4192}
4193
4194// friendlyPath collapses the user's home directory to a leading ~
4195// so status messages read cleanly. Falls back to the raw path when
4196// the home dir is unknown.
4197func friendlyPath(p string) string {
4198	home, err := os.UserHomeDir()
4199	if err != nil || home == "" {
4200		return p
4201	}
4202	if strings.HasPrefix(p, home+string(filepath.Separator)) {
4203		return "~" + p[len(home):]
4204	}
4205	return p
4206}
4207
4208// doSessionFork opens the /jump turn picker in "fork mode". The
4209// next selection branches the current session at that user turn
4210// instead of scrolling the viewport to it.
4211func (i *Interactive) doSessionFork() {
4212	if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
4213		i.mu.Lock()
4214		i.statusErr = "fork: no session is active (running with --no-session?)"
4215		i.mu.Unlock()
4216		i.invalidate()
4217		return
4218	}
4219	msgs := []provider.Message{}
4220	if i.agent != nil {
4221		msgs = i.agent.Messages()
4222	}
4223	if len(msgs) == 0 {
4224		i.mu.Lock()
4225		i.statusErr = "fork: transcript is empty; nothing to fork from"
4226		i.mu.Unlock()
4227		i.invalidate()
4228		return
4229	}
4230	i.pendingFork = true
4231	i.jumpDialog.Open(msgs, "")
4232	i.invalidate()
4233}
4234
4235// doSessionTree shows the branch topology picker for this cwd.
4236// Pick an entry to switch into it (same semantics as /sessions
4237// picking a past session, but with the parent/child indentation).
4238func (i *Interactive) doSessionTree() {
4239	if i.cfg.ZotHome == "" || i.cfg.CWD == "" {
4240		i.mu.Lock()
4241		i.statusErr = "tree: session storage not configured"
4242		i.mu.Unlock()
4243		i.invalidate()
4244		return
4245	}
4246	// Flush the running agent's transcript first so its message
4247	// count + latest preview are accurate in the tree.
4248	if i.cfg.FlushSession != nil {
4249		i.cfg.FlushSession()
4250	}
4251	roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD)
4252	if len(roots) == 0 {
4253		i.mu.Lock()
4254		i.statusErr = "tree: no sessions in this directory yet"
4255		i.mu.Unlock()
4256		i.invalidate()
4257		return
4258	}
4259	current := ""
4260	if i.cfg.CurrentSessionPath != nil {
4261		current = i.cfg.CurrentSessionPath()
4262	}
4263	if !i.sessionTreeDialog.Open(roots, current) {
4264		i.mu.Lock()
4265		i.statusErr = "tree: no branches to show"
4266		i.mu.Unlock()
4267	}
4268	i.invalidate()
4269}
4270
4271// applySessionTreeSelection switches the running agent to the
4272// session file at path. Thin wrapper around LoadSession that also
4273// writes a status line.
4274func (i *Interactive) applySessionTreeSelection(path string) {
4275	if i.cfg.LoadSession == nil {
4276		i.mu.Lock()
4277		i.statusErr = "tree: session swap not available in this build"
4278		i.mu.Unlock()
4279		i.invalidate()
4280		return
4281	}
4282	if err := i.cfg.LoadSession(path); err != nil {
4283		i.mu.Lock()
4284		i.statusErr = "tree: load failed: " + err.Error()
4285		i.mu.Unlock()
4286		i.invalidate()
4287		return
4288	}
4289	i.mu.Lock()
4290	i.statusOK = "switched to branch " + friendlyPath(path)
4291	i.statusErr = ""
4292	i.mu.Unlock()
4293	i.invalidate()
4294}
4295
4296// applyForkSelection branches the current session at msgIdx+1 (so
4297// the selected user message and everything before it is included
4298// in the new branch), then switches the running agent to the new
4299// file. Called from the jump-dialog handler when pendingFork=true.
4300func (i *Interactive) applyForkSelection(msgIdx int) {
4301	i.pendingFork = false
4302	if i.cfg.CurrentSessionPath == nil {
4303		i.mu.Lock()
4304		i.statusErr = "fork: no session is active"
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 = "fork: no session is active"
4313		i.mu.Unlock()
4314		i.invalidate()
4315		return
4316	}
4317	if i.cfg.FlushSession != nil {
4318		i.cfg.FlushSession()
4319	}
4320	// msgIdx is 0-indexed message position; copy msgIdx+1 rows so
4321	// the selected user message is included.
4322	upTo := msgIdx + 1
4323	newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
4324	if err != nil {
4325		i.mu.Lock()
4326		i.statusErr = "fork: " + err.Error()
4327		i.mu.Unlock()
4328		i.invalidate()
4329		return
4330	}
4331	if i.cfg.LoadSession == nil {
4332		i.mu.Lock()
4333		i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
4334		i.statusErr = ""
4335		i.mu.Unlock()
4336		i.invalidate()
4337		return
4338	}
4339	if err := i.cfg.LoadSession(newPath); err != nil {
4340		i.mu.Lock()
4341		i.statusErr = "fork: switch failed: " + err.Error()
4342		i.mu.Unlock()
4343		i.invalidate()
4344		return
4345	}
4346	i.mu.Lock()
4347	i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
4348	i.statusErr = ""
4349	i.mu.Unlock()
4350	i.invalidate()
4351}
4352
4353// formatInt is a tiny strconv.Itoa shim; keeps the handler above
4354// from needing a strconv import just for one call.
4355func formatInt(n int) string {
4356	return fmt.Sprintf("%d", n)
4357}
4358
4359// assistantText returns the concatenated text of every TextBlock in
4360// m. Used by the streaming-view dedupe guard to tell when a live
4361// streamed reply has already been promoted into the transcript.
4362func assistantText(m provider.Message) string {
4363	var sb strings.Builder
4364	for _, c := range m.Content {
4365		if tb, ok := c.(provider.TextBlock); ok {
4366			if sb.Len() > 0 {
4367				sb.WriteByte('\n')
4368			}
4369			sb.WriteString(tb.Text)
4370		}
4371	}
4372	return sb.String()
4373}
4374
4375// resetStreamingStateLocked clears every piece of streaming state
4376// in one shot. Used by abort paths (turn cancel, compact finish,
4377// queue drain) so the pacer doesn't keep draining stale runes from
4378// a prior turn. Must be called with i.mu held.
4379func (i *Interactive) resetStreamingStateLocked() {
4380	i.streaming.Reset()
4381	i.streamPending = i.streamPending[:0]
4382	i.streamFlushPending = false
4383	i.streamOn = false
4384}
4385
4386// assistantMessageSideEffects runs the non-visual hooks attached to
4387// EvAssistantMessage: the host-provided OnAssistant callback and the
4388// telegram-bridge mirror. Called with i.mu held.
4389//
4390// Factored out of handleEvent because the streaming pacer may defer
4391// visual reset until after the last buffered rune has painted, but
4392// the callbacks themselves must fire on message arrival so
4393// downstream observers (session persistence, telegram, cost panels)
4394// don't wait on a UI animation to catch up.
4395func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
4396	if i.cfg.OnAssistant != nil {
4397		i.cfg.OnAssistant(m)
4398	}
4399	if i.telegramBridge != nil && i.telegramBridge.Active() {
4400		var sb strings.Builder
4401		for _, c := range m.Content {
4402			if tb, ok := c.(provider.TextBlock); ok {
4403				if sb.Len() > 0 {
4404					sb.WriteString("\n")
4405				}
4406				sb.WriteString(tb.Text)
4407			}
4408		}
4409		if text := sb.String(); strings.TrimSpace(text) != "" {
4410			go i.telegramBridge.OnAssistantText(text)
4411		}
4412	}
4413}
4414
4415// paintPaceRate is how many runes the streaming pacer releases per
4416// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
4417// that a 500-rune summary finishes in ~1.3s, slow enough to look
4418// like a human typing. Empirically matches the feel of provider
4419// paths that already drip-stream natively.
4420const paintPaceRate = 6
4421
4422// paintPaceInterval is the tick interval for the streaming pacer.
4423// 16ms lines up with the redraw throttle so we never paint faster
4424// than the terminal can keep up.
4425const paintPaceInterval = 16 * time.Millisecond
4426
4427// runStreamPacer drains buffered deltas from streamPending into
4428// streaming a small batch per tick, invalidating after each move.
4429// It stops when the context cancels (tui shutdown).
4430//
4431// Why a pacer: providers differ wildly in how they chunk their
4432// text_delta events. The API-key path on Anthropic emits ~30 drips
4433// for a 400-token summary; the OAuth path can coalesce the same
4434// summary into 3 fat chunks, visually indistinguishable from "the
4435// whole reply just appeared". The pacer normalizes that so every
4436// path looks the same on screen.
4437func (i *Interactive) runStreamPacer(ctx context.Context) {
4438	t := time.NewTicker(paintPaceInterval)
4439	defer t.Stop()
4440	for {
4441		select {
4442		case <-ctx.Done():
4443			return
4444		case <-t.C:
4445			i.mu.Lock()
4446			if len(i.streamPending) == 0 {
4447				// EvAssistantMessage already fired but the pacer
4448				// was still draining a tick ago. Everything is now
4449				// painted; clear the streaming flags so the next
4450				// redraw shows the finalised transcript message
4451				// and hides the streaming overlay.
4452				if i.streamFlushPending {
4453					i.streamFlushPending = false
4454					i.streaming.Reset()
4455					i.streamOn = false
4456					i.mu.Unlock()
4457					i.invalidate()
4458					continue
4459				}
4460				i.mu.Unlock()
4461				continue
4462			}
4463			n := paintPaceRate
4464			if n > len(i.streamPending) {
4465				n = len(i.streamPending)
4466			}
4467			i.streaming.WriteString(string(i.streamPending[:n]))
4468			i.streamPending = i.streamPending[n:]
4469			i.mu.Unlock()
4470			i.invalidate()
4471		}
4472	}
4473}

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