v0.1.64 caac491

interactive.go RawHistoryFolder

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

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