v0.1.61 0a85be3

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

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close