v0.1.50 471fdea

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

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