v0.0.81 fc47398

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/provider"
18	"github.com/patriceckhart/zot/internal/skills"
19	"github.com/patriceckhart/zot/internal/tui"
20)
21
22// InteractiveConfig configures the interactive loop.
23type InteractiveConfig struct {
24	Terminal     tui.Terminal
25	Theme        tui.Theme
26	Model        string
27	Provider     string
28	AuthMethod   string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
29	BaseURL      string
30	Reasoning    string
31	SystemPrompt string
32	Tools        core.Registry
33	MaxSteps     int
34	CWD          string
35
36	// Agent is optional. If nil, zot opens without credentials; the
37	// user must /login before they can prompt.
38	Agent *core.Agent
39
40	InitialInput string
41
42	// Auth is required. When the user runs /login, Interactive talks to
43	// AuthManager to open a browser and wait for the callback.
44	AuthManager *auth.Manager
45	// BuildAgent is called after a successful login to (re)construct the
46	// agent with the fresh credential. It returns the new agent and
47	// the concrete provider/model in use.
48	BuildAgent func() (*core.Agent, string, string, error)
49
50	// BuildAgentFor rebuilds the agent with an explicit provider/model
51	// override (used by the /model picker when switching providers).
52	// If providerOverride is empty, the current provider is kept.
53	BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
54
55	// ZotHome is the root directory for sessions/, used by /sessions
56	// and the update-check cache.
57	ZotHome string
58
59	// Version is the binary's current version (from main.version).
60	// Used only for display; the update check itself is done outside
61	// this package to avoid an import cycle.
62	Version string
63
64	// UpdateInfoChan is an optional channel that delivers the result
65	// of the github-release update check. Interactive reads at most
66	// one value, drops it if the check reported nothing, and otherwise
67	// surfaces a yellow "update available" banner at the top of the
68	// chat. Nil channel = no banner, no startup cost.
69	UpdateInfoChan <-chan UpdateInfo
70
71	// Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.
72	Sandbox *tools.Sandbox
73
74	// LoadSession swaps the current session for the one at path. The
75	// callback returns the new agent message slice so the TUI can invalidate.
76	LoadSession func(path string) error
77
78	// CurrentSessionPath returns the path of the live session file
79	// on disk (the one every AppendMessage writes to). Used by
80	// /session export so the exporter ships the exact bytes on
81	// disk. Returns an empty string when --no-session is set or
82	// no session is open.
83	CurrentSessionPath func() string
84
85	// FlushSession writes any in-memory agent messages to the
86	// session file that haven't been persisted yet. Called by
87	// /session export right before reading the file so the
88	// exported bytes reflect the full current conversation, not
89	// just the rows the agent happened to write synchronously.
90	// The default WriteNewTranscript-at-exit strategy means most
91	// of a running session lives only in memory until the tui
92	// closes; without a flush hook, /session export writes a
93	// file that only has the meta row.
94	FlushSession func()
95
96	// PersistModel is called whenever the user switches model or provider.
97	// It should update config.json and (if there's an active session)
98	// write a new meta row so resume picks up the same model.
99	PersistModel func(providerName, model string)
100
101	OnAssistant  func(m provider.Message)
102	OnToolResult func(id string, r core.ToolResult)
103
104	// Extensions, if non-nil, lets users invoke extension-registered
105	// slash commands. Commands declared by extensions are looked up
106	// AFTER the built-in catalog so a built-in name always wins.
107	Extensions *extensions.Manager
108
109	// SkillSnapshot, if non-nil, returns the current list of
110	// discovered SKILL.md files. Re-invoked each time /skills opens
111	// so the picker reflects edits made during the session.
112	SkillSnapshot func() []*skills.Skill
113
114	// ChangelogChan, if non-nil, delivers release-notes for the
115	// current binary version once at startup. Interactive opens a
116	// dismissible overlay when the channel produces a non-empty
117	// body. Receiver fires at most once.
118	ChangelogChan <-chan ChangelogPayload
119
120	// OnChangelogDismiss, if non-nil, is called once the user
121	// closes the changelog overlay. The cli wires this to a
122	// MarkChangelogShown call so the same version doesn't show
123	// again on the next launch.
124	OnChangelogDismiss func()
125
126	// NoYolo is true when --no-yolo was passed. Interactive opens
127	// a confirmation dialog before every tool call and blocks the
128	// tool until the user picks yes / always-this-tool /
129	// always-all / no. When false (default), tools run freely.
130	NoYolo bool
131
132	// ConfirmGate is the session-scoped gate wrapping this
133	// interactive's Confirmer. When non-nil, /yolo can call
134	// AllowAll() on it to disable confirmation for the rest of the
135	// session. When nil (yolo mode), /yolo reports that there's
136	// nothing to disable.
137	ConfirmGate *core.ConfirmGate
138}
139
140// ChangelogPayload mirrors agent.ChangelogInfo without the import
141// cycle. The cli builds one from the http response, the tui opens
142// the overlay when one arrives.
143type ChangelogPayload struct {
144	Version string
145	Body    string
146	URL     string
147}
148
149// Interactive is the TUI chat loop.
150type Interactive struct {
151	cfg  InteractiveConfig
152	view *tui.View
153	ed   *tui.Editor
154	rend *tui.Renderer
155
156	mu        sync.Mutex
157	agent     *core.Agent
158	streaming strings.Builder // what's currently painted on screen
159	streamOn  bool
160
161	// streamPending is the runes buffered after each EvTextDelta that
162	// haven't yet been promoted into `streaming` for rendering. It
163	// exists because some provider paths (notably Anthropic via the
164	// oauth/subscription channel) coalesce the model's output into a
165	// few fat chunks instead of drip-streaming. Painting those fat
166	// chunks verbatim looks like the summary "just appears". The
167	// paintPace goroutine drains a handful of runes per tick from
168	// this buffer into `streaming`, giving every path the same
169	// typewriter feel regardless of upstream chunk size.
170	streamPending []rune
171	// streamFlushPending is set when EvAssistantMessage fires while
172	// streamPending still has unrendered runes. The ticker flushes
173	// them, then closes out the stream (clearing flags, resetting
174	// buffers) so the final paint matches the on-disk message.
175	streamFlushPending bool
176	toolCalls          map[string]*tui.ToolCallView
177	toolOrder          []string
178	statusErr          string
179	statusOK           string
180	helpBlock          []string // rendered above the chat when /help was typed
181	cumUsage           provider.Usage
182	lastCtxInput       int // input_tokens of the most recent turn — approximates current context size
183	busy               bool
184	dirty              chan struct{}
185	cancelTurn         context.CancelFunc
186	scrollOffset       int // rows from the bottom; 0 = pinned to latest
187
188	// Messages typed while a turn is in flight. Each is delivered as
189	// its own follow-up turn once the current one finishes. Rendered
190	// above the status bar as "sliding in: ..." chips.
191	queued []string
192
193	// runCtx is the top-level context passed to Run(). Follow-up turns
194	// drained from `queued` are started against this context so they
195	// survive past the ctx of the key event that enqueued them.
196	runCtx context.Context
197
198	// autoCompacting is true while a model-triggered compaction is in
199	// flight. Surfaced in the status bar so the user can tell a
200	// condense pass from a regular assistant turn.
201	autoCompacting bool
202
203	// updateInfo is the result of the async update check. Zero value
204	// while the check hasn't completed or nothing is available.
205	updateInfo UpdateInfo
206
207	dialog            *loginDialog
208	modelDialog       *modelDialog
209	sessionDialog     *sessionDialog
210	jumpDialog        *jumpDialog
211	btwDialog         *btwDialog
212	skillsDialog      *skillsDialog
213	changelogDialog   *changelogDialog
214	confirmDialog     *confirmDialog
215	logoutDialog      *logoutDialog
216	telegramDialog    *telegramDialog
217	telegramBridge    *telegram.Bridge
218	sessionOpsDialog  *sessionOpsDialog
219	sessionTreeDialog *sessionTreeDialog
220
221	// pendingFork is true when the user ran /session fork: the next
222	// jump-picker selection should branch off that message instead
223	// of scrolling. Flag resets after the action fires or the dialog
224	// is dismissed, so repeated /jump calls don't turn into forks.
225	pendingFork bool
226	suggest     *slashSuggester
227	spin        *spinner
228
229	// parkedTurn is the 1-based turn number the viewport is currently
230	// scrolled to by /jump. 0 = not parked, showing the tail as usual.
231	// Rendered as a muted footer at the bottom of the chat so users
232	// don't forget they're looking at history.
233	parkedTurn  int
234	parkedTotal int
235
236	// lastCtrlC is when the user last pressed ctrl+c. The first press
237	// clears the editor / cancels a turn / shows a hint; a second press
238	// within ctrlCExitWindow exits. Mirrors the python-repl convention.
239	lastCtrlC time.Time
240
241	// welcomeStart is when the interactive run began. The welcome
242	// banner shows the binary version for welcomeVersionDuration
243	// after this point and reverts to plain text after.
244	welcomeStart time.Time
245
246	// extNotes are one-shot styled lines pushed by extensions via
247	// Notify / Display. They live above the editor (just below the
248	// transcript) until cleared by /clear or another reset.
249	extNotes []string
250}
251
252// welcomeVersionDuration is how long the welcome banner shows the
253// version suffix before reverting to the plain headline. 1.5s is
254// enough to read at a glance and keeps the splash short.
255const welcomeVersionDuration = 1500 * time.Millisecond
256
257// NewInteractive constructs an Interactive from cfg.
258func NewInteractive(cfg InteractiveConfig) *Interactive {
259	i := &Interactive{
260		cfg: cfg,
261		view: &tui.View{
262			Theme:      cfg.Theme,
263			ImageProto: tui.DetectImageProtocol(),
264		},
265		ed:                tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
266		rend:              tui.NewRenderer(cfg.Terminal),
267		toolCalls:         map[string]*tui.ToolCallView{},
268		dirty:             make(chan struct{}, 8),
269		dialog:            newLoginDialog(),
270		modelDialog:       newModelDialog(),
271		sessionDialog:     newSessionDialog(),
272		jumpDialog:        newJumpDialog(),
273		btwDialog:         newBtwDialog(),
274		skillsDialog:      newSkillsDialog(),
275		changelogDialog:   newChangelogDialog(),
276		confirmDialog:     newConfirmDialog(),
277		logoutDialog:      newLogoutDialog(),
278		telegramDialog:    newTelegramDialog(),
279		sessionOpsDialog:  newSessionOpsDialog(),
280		sessionTreeDialog: newSessionTreeDialog(),
281		suggest:           newSlashSuggester(),
282		spin:              newSpinner(),
283	}
284	if cfg.Agent != nil {
285		i.agent = cfg.Agent
286		i.view.Messages = cfg.Agent.Messages()
287	}
288	return i
289}
290
291// Run blocks until the user quits.
292func (i *Interactive) Run(ctx context.Context) error {
293	i.runCtx = ctx
294	term := i.cfg.Terminal
295	restore, err := term.EnterRaw()
296	if err != nil {
297		return err
298	}
299	defer restore()
300	defer func() {
301		if i.telegramBridge != nil {
302			i.telegramBridge.Stop()
303		}
304	}()
305
306	_, _ = term.Write([]byte(tui.SeqBracketedPasteOn))
307	_, _ = term.Write([]byte(tui.SeqAltScreenOn))
308	defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
309
310	// Streaming pacer: drains buffered text deltas at a steady rate
311	// so typewriter feel is identical across providers regardless of
312	// upstream chunk size. Starts here so it lives for the whole
313	// session and exits with ctx.
314	go i.runStreamPacer(ctx)
315
316	cols, rows := term.Size()
317	i.rend.Resize(cols, rows)
318	term.OnResize(func() {
319		c, r := term.Size()
320		i.rend.Resize(c, r)
321		// Force an immediate redraw on resize. The throttled invalidate
322		// path is fine for animation, but a window resize is a discrete
323		// user action where any visible delay (or stale frame) reads as
324		// brokenness. redraw() is mutex-safe; the worst that happens is
325		// a duplicate paint if the throttler is mid-flight, which is
326		// invisible.
327		i.redraw()
328	})
329
330	if i.cfg.InitialInput != "" {
331		i.ed.SetValue(i.cfg.InitialInput)
332	}
333
334	// Stamp the welcome time and schedule a one-shot redraw at the
335	// expiry so the version suffix disappears on its own even if the
336	// user hasn't typed anything yet.
337	i.welcomeStart = time.Now()
338	time.AfterFunc(welcomeVersionDuration, i.invalidate)
339
340	// If the agent was constructed with a pre-loaded transcript
341	// (--continue, --resume, --session) park the viewport on the
342	// most recent turn so the user lands looking at where the
343	// previous session left off rather than at the bottom of an
344	// already-rendered final reply.
345	if i.agent != nil {
346		if msgs := i.agent.Messages(); len(msgs) > 0 {
347			i.scrollToLastTurn(msgs)
348		}
349	}
350
351	// No credential at startup? Auto-open the login dialog, and mark
352	// the status line. The user can Esc out of the dialog if they
353	// want to dismiss it (e.g. to check /help or /exit first).
354	if i.agent == nil {
355		i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
356		i.dialog.Open(i.cfg.ZotHome)
357	}
358
359	// Input goroutine.
360	keys := make(chan tui.Key, 8)
361	go func() {
362		reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
363		for {
364			k, err := reader.Read()
365			if err != nil {
366				return
367			}
368			keys <- k
369		}
370	}()
371
372	// Subscribe to auth events.
373	var authEvents <-chan auth.Event
374	if i.cfg.AuthManager != nil {
375		authEvents = i.cfg.AuthManager.Events()
376	}
377
378	// Animation ticker: drives spinner and dialog-related redraws when
379	// nothing else changed. 120ms is slow enough that highlighting a huge
380	// transcript doesn't spin the cpu.
381	tick := time.NewTicker(120 * time.Millisecond)
382	defer tick.Stop()
383
384	// Redraw throttle: coalesce bursts of invalidate() calls so we paint
385	// at most once every redrawMinInterval. Huge tool-result dumps can
386	// fire hundreds of invalidations while the user is typing; without
387	// this, the input goroutine never gets CPU and keystrokes lag.
388	const redrawMinInterval = 16 * time.Millisecond
389	var lastRedraw time.Time
390	var pendingRedraw bool
391	var pendingTimer *time.Timer
392
393	drainPending := func() {
394		if pendingTimer != nil {
395			pendingTimer.Stop()
396			pendingTimer = nil
397		}
398		if pendingRedraw {
399			pendingRedraw = false
400			lastRedraw = time.Now()
401			i.redraw()
402		}
403	}
404
405	requestRedraw := func() {
406		since := time.Since(lastRedraw)
407		if since >= redrawMinInterval {
408			// Redrawing right now subsumes any pending redraw, so clear
409			// the throttle state. Without this, a pending flag stays
410			// stuck at true and subsequent invalidate() calls within
411			// redrawMinInterval get dropped — which is exactly how the
412			// final "turn finished" frame went missing until the user
413			// nudged the ui by typing or scrolling.
414			if pendingTimer != nil {
415				pendingTimer.Stop()
416			}
417			pendingRedraw = false
418			lastRedraw = time.Now()
419			i.redraw()
420			return
421		}
422		if pendingRedraw {
423			return // already scheduled
424		}
425		pendingRedraw = true
426		wait := redrawMinInterval - since
427		if pendingTimer == nil {
428			pendingTimer = time.AfterFunc(wait, func() {
429				// Poke the dirty channel so the main loop wakes and
430				// drains the pending redraw on its own goroutine. We
431				// can't call drainPending here directly — it touches
432				// closure state shared with the main loop.
433				i.invalidate()
434			})
435		} else {
436			pendingTimer.Reset(wait)
437		}
438	}
439
440	i.invalidate()
441
442	updates := i.cfg.UpdateInfoChan  // nil-safe; nil channel blocks forever in select
443	changelog := i.cfg.ChangelogChan // single-shot, see case below
444
445	for {
446		select {
447		case <-ctx.Done():
448			return ctx.Err()
449		case k := <-keys:
450			if done := i.handleKey(ctx, k); done {
451				return nil
452			}
453			i.invalidate()
454		case ev := <-authEvents:
455			i.handleAuthEvent(ev)
456			i.invalidate()
457		case info, ok := <-updates:
458			if ok && info.Available {
459				i.mu.Lock()
460				i.updateInfo = info
461				i.mu.Unlock()
462				i.invalidate()
463			}
464			updates = nil // single-shot; subsequent iterations skip this case
465		case cl, ok := <-changelog:
466			if ok && cl.Body != "" {
467				i.changelogDialog.Open(cl.Version, cl.URL, cl.Body)
468				i.invalidate()
469			}
470			changelog = nil // single-shot
471		case <-i.dirty:
472			requestRedraw()
473		case <-tick.C:
474			// Always drain a pending redraw on the tick. This is the
475			// safety net that catches the case where the dirty channel
476			// was saturated when the final "turn finished" invalidate
477			// fired, or where the throttle scheduled a deferred redraw
478			// and the AfterFunc-driven invalidate got dropped on a
479			// full channel.
480			drainPending()
481			if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() || i.sessionOpsDialog.Active() || i.sessionTreeDialog.Active() {
482				requestRedraw() // keep the spinner / dialog animation moving
483			}
484		}
485	}
486}
487
488func (i *Interactive) invalidate() {
489	select {
490	case i.dirty <- struct{}{}:
491	default:
492	}
493}
494
495// lastCols returns the current terminal width in columns.
496func (i *Interactive) lastCols() int {
497	cols, _ := i.cfg.Terminal.Size()
498	return cols
499}
500
501// chatPage returns the number of chat rows currently visible, used
502// as the page size for PageUp/PageDown.
503func (i *Interactive) chatPage() int {
504	_, rows := i.cfg.Terminal.Size()
505	p := rows - 6 // rough reservation for status + editor + a dialog line
506	if p < 4 {
507		p = 4
508	}
509	return p
510}
511
512// scrollBy adjusts the scroll offset. Positive = up (into history).
513// Clearing the parked-turn label when we're back at the bottom means
514// the "viewing turn N" footer goes away automatically as soon as you
515// scroll back to the live tail.
516func (i *Interactive) scrollBy(delta int) {
517	i.mu.Lock()
518	i.scrollOffset += delta
519	if i.scrollOffset < 0 {
520		i.scrollOffset = 0
521	}
522	if i.scrollOffset == 0 {
523		i.parkedTurn = 0
524		i.parkedTotal = 0
525	}
526	i.mu.Unlock()
527	i.invalidate()
528}
529
530// scrollToBottom pins the view to the latest content.
531func (i *Interactive) scrollToBottom() {
532	i.mu.Lock()
533	i.scrollOffset = 0
534	i.parkedTurn = 0
535	i.parkedTotal = 0
536	i.mu.Unlock()
537	i.invalidate()
538}
539
540func (i *Interactive) redraw() {
541	i.mu.Lock()
542	defer i.mu.Unlock()
543
544	cols, _ := i.cfg.Terminal.Size()
545	if i.agent != nil {
546		i.view.Messages = i.agent.Messages()
547	} else {
548		i.view.Messages = nil
549	}
550	// Pacer flush: while the streaming pacer is still draining the
551	// buffer (i.e. EvAssistantMessage already fired but more runes
552	// are queued), the final assistant message is already in
553	// i.agent.Messages() in full. Painting it in the transcript
554	// AND the streaming block at the same time shows the user the
555	// complete text immediately — which defeats the whole pacer.
556	// Hide the last message until the pacer catches up; once the
557	// flush-pending latch clears, the message is revealed (the
558	// streaming block disappears the same frame because streamOn
559	// flips off, so the transition is seamless).
560	if i.streamFlushPending && len(i.view.Messages) > 0 {
561		i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
562	}
563	i.view.Streaming = i.streaming.String()
564	i.view.StreamingActive = i.streamOn
565	// Guard against the narrow race where EvAssistantMessage has
566	// just promoted a streaming reply into the transcript but a
567	// render tick hasn't flipped streamOn off yet. Without the
568	// guard, the same text would appear twice (once as the
569	// in-flight streaming block, once as the last transcript
570	// message). We detect the duplicate strictly: the last
571	// assistant message's visible text must equal the streaming
572	// buffer. Just matching on role is too broad — it also hides
573	// the next round's typewriter streaming after a tool turn,
574	// because the last transcript message is always assistant
575	// (the tool-use block) until the follow-up summary lands.
576	if i.streamOn && i.streaming.Len() > 0 {
577		if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
578			if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
579				i.view.StreamingActive = false
580			}
581		}
582	}
583	// Live tool-call view: only shown while a turn is in flight. Once
584	// the agent is idle, every tool call has already been folded into
585	// the transcript (as assistant.ToolCallBlock + a tool-role message),
586	// so showing v.ToolCalls a second time would duplicate them below
587	// the final assistant text — which looks like the summary came
588	// "before" the tools.
589	i.view.ToolCalls = i.view.ToolCalls[:0]
590	if i.busy {
591		for _, id := range i.toolOrder {
592			if tc, ok := i.toolCalls[id]; ok {
593				i.view.ToolCalls = append(i.view.ToolCalls, *tc)
594			}
595		}
596	}
597	i.view.Err = i.statusErr
598
599	chat := i.view.Build(cols)
600
601	// Welcome banner: shown at the top of the chat area when there is
602	// no transcript yet. Disappears after the first message is sent.
603	// The version suffix is shown for welcomeVersionDuration after
604	// startup, then drops off automatically.
605	if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
606		showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
607		chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
608	}
609
610	// Update-available banner: prepended above everything else so it's
611	// the first thing the user sees when opening a new zot session.
612	// Once rendered, it stays until the user updates to a newer
613	// version — we don't persist a "dismissed" flag because this is
614	// cheap and re-showing it is how most users remember to update.
615	if i.updateInfo.Available {
616		banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
617		chat = append(banner, chat...)
618	}
619
620	// /help block: appended to the transcript so it appears at the
621	// bottom of the chat area (right above the status bar / editor).
622	// Prepending it would push long conversations off the top of the
623	// viewport, which users would miss entirely.
624	if len(i.helpBlock) > 0 {
625		chat = append(chat, i.helpBlock...)
626	}
627
628	if i.statusOK != "" {
629		// Hard-truncate the OK line to the visible width so a long
630		// session path ("resumed session: /Users/.../sessions/...")
631		// doesn't overflow past the right edge and look broken on a
632		// narrow terminal.
633		line := "✓ " + i.statusOK
634		if cols > 4 && len(line) > cols {
635			line = line[:cols-1] + "…"
636		}
637		chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
638	}
639
640	// Extension notes (notify / display) live just under the
641	// transcript, above the dialog/editor band. Cleared by /clear.
642	if len(i.extNotes) > 0 {
643		chat = append(chat, i.extNotes...)
644		chat = append(chat, "")
645	}
646
647	// Dialogs (login or model picker) render between chat and the editor.
648	var dialog []string
649	switch {
650	case i.dialog.Active():
651		dialog = i.dialog.Render(i.cfg.Theme, cols)
652	case i.modelDialog.Active():
653		dialog = i.modelDialog.Render(i.cfg.Theme, cols)
654	case i.sessionDialog.Active():
655		// Reserve rows for the editor (~3), status line (1-2),
656		// dialog chrome (header + hint + rule + indicators, ~5),
657		// and leave the remainder for session rows. Minimum of 3
658		// rows so even a very small terminal shows something.
659		_, rows := i.cfg.Terminal.Size()
660		avail := rows - 12
661		if avail < 3 {
662			avail = 3
663		}
664		i.sessionDialog.MaxRows = avail
665		dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
666	case i.jumpDialog.Active():
667		dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
668	case i.btwDialog.Active():
669		dialog = i.btwDialog.Render(i.cfg.Theme, cols)
670	case i.skillsDialog.Active():
671		dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
672	case i.changelogDialog.Active():
673		dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
674	case i.confirmDialog.Active():
675		dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
676	case i.logoutDialog.Active():
677		dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
678	case i.telegramDialog.Active():
679		dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
680	case i.sessionOpsDialog.Active():
681		dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
682	case i.sessionTreeDialog.Active():
683		dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
684	}
685
686	// Slash-command autocomplete: popup above the status line, only
687	// when the editor starts with "/" and no dialog is already open.
688	// Feed extension-registered commands into the suggester first so
689	// they show up in tab-complete + the popup alongside the built-ins.
690	if i.cfg.Extensions != nil {
691		catalog := i.cfg.Extensions.Commands()
692		extra := make([]slashCommand, 0, len(catalog))
693		for _, c := range catalog {
694			// The popup renders extension commands under a dedicated
695			// "── extensions ───" divider, so the description doesn't
696			// need to repeat the source. If the description is empty,
697			// fall back to the extension name so the row isn't blank.
698			desc := c.Description
699			if strings.TrimSpace(desc) == "" {
700				desc = "(" + c.Extension + ")"
701			}
702			extra = append(extra, slashCommand{
703				Name: "/" + c.Name,
704				Desc: desc,
705			})
706		}
707		i.suggest.SetExtra(extra)
708	}
709	var suggest []string
710	currentInput := i.ed.Value()
711	if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy {
712		suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
713	}
714
715	// Busy prefix shown at the far left of the status bar. The
716	// spinner glyph and its funny-line message share the `zot`
717	// label colour (Theme.Assistant) so the whole "who's working"
718	// band reads at a glance. Elapsed time stays muted because it
719	// drifts every second and shouldn't grab focus.
720	var busyPrefix string
721	if i.busy {
722		busyPrefix = fmt.Sprintf("%s %s %s %s",
723			i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()),
724			i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()),
725			i.cfg.Theme.FG256(i.cfg.Theme.Muted, "-"),
726			i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
727		)
728	}
729
730	ctxMax := 0
731	if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
732		ctxMax = m.ContextWindow
733	}
734	statusLines := tui.StatusBar(tui.StatusBarParams{
735		Theme:          i.cfg.Theme,
736		Provider:       i.cfg.Provider,
737		Model:          i.cfg.Model,
738		Busy:           i.busy,
739		BusyPrefix:     busyPrefix,
740		CWD:            i.cfg.CWD,
741		Locked:         i.cfg.Sandbox.Locked(),
742		Usage:          i.cumUsage,
743		Subscription:   i.cfg.AuthMethod == "oauth",
744		ContextUsed:    i.lastCtxInput,
745		ContextMax:     ctxMax,
746		AutoCompacting: i.autoCompacting,
747		Telegram:       i.telegramBridge != nil && i.telegramBridge.Active(),
748		Cols:           cols,
749	})
750	edLines, curR, curC := i.ed.Render(cols)
751
752	// "Sliding in" chips for messages the user typed while a turn is
753	// in flight. Shown directly above the status bar so they're close
754	// to the editor but don't push the chat around.
755	var queue []string
756	if len(i.queued) > 0 {
757		for _, q := range i.queued {
758			label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")
759			text := truncateLine(q, cols-15)
760			queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
761		}
762	}
763
764	// Bottom-sticky sections (always visible, never scroll).
765	bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)
766	bottom = append(bottom, dialog...)
767	bottom = append(bottom, suggest...)
768	bottom = append(bottom, queue...)
769	bottom = append(bottom, statusLines...)
770	bottom = append(bottom, edLines...)
771
772	_, rows := i.cfg.Terminal.Size()
773	chatRows := rows - len(bottom)
774	if chatRows < 1 {
775		chatRows = 1
776	}
777
778	// Apply scroll offset to the chat slice.
779	maxOffset := len(chat) - chatRows
780	if maxOffset < 0 {
781		maxOffset = 0
782	}
783	if i.scrollOffset > maxOffset {
784		i.scrollOffset = maxOffset
785	}
786	if i.scrollOffset < 0 {
787		i.scrollOffset = 0
788	}
789
790	var visibleChat []string
791	if len(chat) <= chatRows {
792		visibleChat = chat
793	} else {
794		end := len(chat) - i.scrollOffset
795		start := end - chatRows
796		if start < 0 {
797			start = 0
798		}
799		visibleChat = chat[start:end]
800	}
801
802	// A tiny "scrolled up" indicator in the top-right of the chat pane
803	// so you know you're not at the bottom. When the viewport was
804	// parked by /jump we include the turn number so the user remembers
805	// they're reading history rather than the live conversation.
806	if i.scrollOffset > 0 && len(visibleChat) > 0 {
807		var text string
808		if i.parkedTurn > 0 && i.parkedTotal > 0 {
809			text = fmt.Sprintf("  ↑ viewing turn %d of %d - %d lines more below (pgdn / end)",
810				i.parkedTurn, i.parkedTotal, i.scrollOffset)
811		} else {
812			text = fmt.Sprintf("  ↑ %d lines more below (end to jump)", i.scrollOffset)
813		}
814		note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
815		visibleChat = append([]string{note}, visibleChat...)
816		if len(visibleChat) > chatRows {
817			visibleChat = visibleChat[:chatRows]
818		}
819	}
820
821	frame := make([]string, 0, len(visibleChat)+len(bottom))
822	frame = append(frame, visibleChat...)
823	frame = append(frame, bottom...)
824
825	cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR
826	cursorCol := curC
827	i.rend.Draw(frame, cursorRow, cursorCol)
828}
829
830// truncateLine shortens s so it fits within n display cells, with an
831// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
832// novel doesn't blow past the status line.
833func truncateLine(s string, n int) string {
834	if n <= 0 {
835		return ""
836	}
837	// Collapse newlines — chips are single line.
838	s = strings.ReplaceAll(s, "\n", " ↩ ")
839	runes := []rune(s)
840	if len(runes) <= n {
841		return s
842	}
843	if n <= 1 {
844		return "…"
845	}
846	return string(runes[:n-1]) + "…"
847}
848
849// ctrlCExitWindow is how long after a ctrl+c press a *second* press
850// will exit instead of just clearing input. Long enough to be
851// deliberate (rules out accidental key chord), short enough that the
852// hint stays meaningful.
853const ctrlCExitWindow = 2 * time.Second
854
855// armCtrlCExit records the timestamp of the current ctrl+c so the next
856// one within ctrlCExitWindow exits.
857func (i *Interactive) armCtrlCExit() {
858	i.mu.Lock()
859	i.lastCtrlC = time.Now()
860	i.mu.Unlock()
861}
862
863// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
864// that another press should now exit.
865func (i *Interactive) ctrlCExitArmed() bool {
866	i.mu.Lock()
867	t := i.lastCtrlC
868	i.mu.Unlock()
869	return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
870}
871
872func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
873	// Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
874	// pressing ctrl+c then typing then ctrl+c much later doesn't quit
875	// unexpectedly. The hint message also goes stale; clear it.
876	if k.Kind != tui.KeyCtrlC {
877		i.mu.Lock()
878		if !i.lastCtrlC.IsZero() {
879			i.lastCtrlC = time.Time{}
880			if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
881				i.statusOK = ""
882			}
883		}
884		i.mu.Unlock()
885	}
886
887	// Dialogs consume keys while open (except ctrl+c, which always closes them).
888
889	// Confirm dialog has highest priority: the agent goroutine is
890	// blocked waiting for an answer, so we must not let keys leak
891	// anywhere else while it's up.
892	if i.confirmDialog.Active() {
893		i.confirmDialog.HandleKey(k)
894		i.invalidate()
895		return false
896	}
897	if i.dialog.Active() {
898		if k.Kind == tui.KeyCtrlC {
899			i.dialog.Close()
900			if i.cfg.AuthManager != nil {
901				i.cfg.AuthManager.CancelOAuth()
902			}
903			return false
904		}
905		act := i.dialog.HandleKey(k)
906		if act.StartAPIKey {
907			i.startAPIKeyFlow(act.Provider)
908		}
909		if act.StartOAuth {
910			i.startOAuthFlow(act.Provider)
911		}
912		return false
913	}
914	if i.modelDialog.Active() {
915		if k.Kind == tui.KeyCtrlC {
916			i.modelDialog.Close()
917			return false
918		}
919		act := i.modelDialog.HandleKey(k)
920		if act.Select {
921			i.applyModelSelection(act.Provider, act.Model)
922		}
923		return false
924	}
925	if i.sessionDialog.Active() {
926		if k.Kind == tui.KeyCtrlC {
927			i.sessionDialog.Close()
928			return false
929		}
930		act := i.sessionDialog.HandleKey(k)
931		if act.Select {
932			i.applySessionSelection(act.Path)
933		}
934		return false
935	}
936	if i.logoutDialog.Active() {
937		if k.Kind == tui.KeyCtrlC {
938			i.logoutDialog.Close()
939			i.invalidate()
940			return false
941		}
942		act := i.logoutDialog.HandleKey(k)
943		if act.Select {
944			i.doLogout(act.Target)
945		}
946		i.invalidate()
947		return false
948	}
949	if i.telegramDialog.Active() {
950		if k.Kind == tui.KeyCtrlC {
951			i.telegramDialog.Close()
952			i.invalidate()
953			return false
954		}
955		act := i.telegramDialog.HandleKey(k)
956		if act.Select {
957			i.doTelegram(act.Action)
958		}
959		i.invalidate()
960		return false
961	}
962	if i.sessionOpsDialog.Active() {
963		if k.Kind == tui.KeyCtrlC {
964			i.sessionOpsDialog.Close()
965			i.invalidate()
966			return false
967		}
968		act := i.sessionOpsDialog.HandleKey(k)
969		if act.Select {
970			i.doSessionOp(act.Action, "")
971		}
972		i.invalidate()
973		return false
974	}
975	if i.sessionTreeDialog.Active() {
976		if k.Kind == tui.KeyCtrlC {
977			i.sessionTreeDialog.Close()
978			i.invalidate()
979			return false
980		}
981		act := i.sessionTreeDialog.HandleKey(k)
982		if act.Select {
983			i.applySessionTreeSelection(act.Path)
984		}
985		i.invalidate()
986		return false
987	}
988	if i.jumpDialog.Active() {
989		if k.Kind == tui.KeyCtrlC {
990			i.jumpDialog.Close()
991			i.pendingFork = false
992			return false
993		}
994		act := i.jumpDialog.HandleKey(k)
995		if act.Select {
996			if i.pendingFork {
997				i.applyForkSelection(act.MessageIdx)
998			} else {
999				i.applyJumpSelection(act.MessageIdx, act.TurnNo)
1000			}
1001		}
1002		// If the user dismissed the dialog without selecting, also
1003		// clear the pending-fork flag so a later plain /jump isn't
1004		// hijacked.
1005		if act.Close {
1006			i.pendingFork = false
1007		}
1008		return false
1009	}
1010	if i.btwDialog.Active() {
1011		if k.Kind == tui.KeyCtrlC {
1012			i.btwDialog.Close()
1013			i.invalidate()
1014			return false
1015		}
1016		i.btwDialog.HandleKey(k, i.invalidate)
1017		return false
1018	}
1019	if i.skillsDialog.Active() {
1020		if k.Kind == tui.KeyCtrlC {
1021			i.skillsDialog.Close()
1022			i.invalidate()
1023			return false
1024		}
1025		i.skillsDialog.HandleKey(k)
1026		i.invalidate()
1027		return false
1028	}
1029	if i.changelogDialog.Active() {
1030		if closed := i.changelogDialog.HandleKey(k); closed {
1031			// User dismissed; let the parent persist the
1032			// LastChangelogShown marker via the close callback.
1033			if i.cfg.OnChangelogDismiss != nil {
1034				i.cfg.OnChangelogDismiss()
1035			}
1036		}
1037		i.invalidate()
1038		return false
1039	}
1040
1041	// Global keys.
1042	switch k.Kind {
1043	case tui.KeyCtrlC:
1044		// While busy: do NOT cancel the turn. ctrl+c during a
1045		// running turn is almost always reflex muscle memory
1046		// ("be quiet" in a shell) rather than a deliberate
1047		// decision to kill a multi-minute model call that's
1048		// already cost tokens. Use esc to interrupt a turn; use
1049		// a deliberate double-ctrl+c to exit zot entirely. First
1050		// press arms the exit hint, second press within
1051		// ctrlCExitWindow quits.
1052		if i.busy {
1053			if i.ctrlCExitArmed() {
1054				return true
1055			}
1056			i.mu.Lock()
1057			i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
1058			i.statusErr = ""
1059			i.mu.Unlock()
1060			i.armCtrlCExit()
1061			return false
1062		}
1063		// Idle: first press clears the editor (and any queued
1064		// follow-up messages); a second press within ctrlCExitWindow
1065		// exits. With both an empty editor and no queue the first
1066		// press still just arms — require a deliberate double-tap.
1067		hadInput := !i.ed.IsEmpty() || len(i.queued) > 0
1068		if hadInput {
1069			i.ed.Clear()
1070			i.suggest.Reset()
1071			i.mu.Lock()
1072			i.queued = nil
1073			i.statusOK = "input cleared"
1074			i.statusErr = ""
1075			i.mu.Unlock()
1076			i.armCtrlCExit()
1077			return false
1078		}
1079		if i.ctrlCExitArmed() {
1080			return true
1081		}
1082		i.mu.Lock()
1083		i.statusOK = "press ctrl+c again to exit"
1084		i.statusErr = ""
1085		i.mu.Unlock()
1086		i.armCtrlCExit()
1087		return false
1088	case tui.KeyEsc:
1089		// Esc interrupts a running turn. When idle, fall through so the
1090		// editor can clear itself.
1091		if i.busy && i.cancelTurn != nil {
1092			i.cancelTurn()
1093			// If a confirm dialog is pending, refuse it so the agent
1094			// goroutine unblocks and the context cancellation can
1095			// actually take effect.
1096			i.confirmDialog.CancelAll("turn cancelled")
1097			return false
1098		}
1099	case tui.KeyCtrlD:
1100		if i.ed.IsEmpty() && !i.busy {
1101			return true
1102		}
1103	case tui.KeyCtrlL:
1104		i.rend.Clear()
1105		i.invalidate()
1106		return false
1107	case tui.KeyCtrlO:
1108		// Toggle expansion of collapsed tool results. Affects every tool
1109		// call in the transcript — press again to re-collapse.
1110		i.mu.Lock()
1111		i.view.ExpandAll = !i.view.ExpandAll
1112		i.mu.Unlock()
1113		i.invalidate()
1114		return false
1115	case tui.KeyPageUp:
1116		i.scrollBy(+i.chatPage())
1117		return false
1118	case tui.KeyPageDown:
1119		i.scrollBy(-i.chatPage())
1120		return false
1121	case tui.KeyUp:
1122		// Wheel-up in alt screen sends Up arrows on most terminals.
1123		// When the editor is empty we use up/down for chat scroll —
1124		// independently of whether the agent is busy, so users can
1125		// scroll back through long streaming replies while they run.
1126		if i.ed.IsEmpty() {
1127			i.scrollBy(+3)
1128			return false
1129		}
1130	case tui.KeyDown:
1131		if i.ed.IsEmpty() {
1132			if i.scrollOffset > 0 {
1133				i.scrollBy(-3)
1134			}
1135			return false
1136		}
1137	}
1138
1139	// Note: we intentionally do NOT gate the editor on i.busy here.
1140	// Typing while the agent is working is supported — submitted
1141	// messages are queued and delivered as follow-up turns when the
1142	// current turn ends. See the submit handler below.
1143
1144	if k.Kind == tui.KeyEnter && k.Alt {
1145		i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
1146		return false
1147	}
1148
1149	// Slash suggestions: intercept up/down/tab/enter when the popup is visible.
1150	if i.suggest.Active(i.ed.Value()) {
1151		switch k.Kind {
1152		case tui.KeyUp:
1153			i.suggest.Up()
1154			return false
1155		case tui.KeyDown:
1156			i.suggest.Down()
1157			return false
1158		case tui.KeyTab:
1159			if name := i.suggest.Selection(i.ed.Value()); name != "" {
1160				i.ed.SetValue(name)
1161				i.suggest.Reset()
1162			}
1163			return false
1164		case tui.KeyEnter:
1165			// Enter on an ambiguous or partial slash prefix: complete to the
1166			// currently highlighted command and run it. That way typing
1167			// "/lo" + enter picks whichever of /login or /logout is selected
1168			// in the popup instead of submitting "/lo" as unknown. Also
1169			// clear the editor so the command doesn't linger after the
1170			// dialog opens/closes.
1171			if name := i.suggest.Selection(i.ed.Value()); name != "" {
1172				i.ed.Clear()
1173				i.suggest.Reset()
1174				return i.runSlash(ctx, name)
1175			}
1176		case tui.KeyEsc:
1177			i.ed.Clear()
1178			i.suggest.Reset()
1179			return false
1180		}
1181	}
1182
1183	if submit := i.ed.HandleKey(k); submit {
1184		// SubmitValue() expands any [pasted text #N +L lines]
1185		// placeholders back into their bodies; the raw Value()
1186		// is only what the user sees on screen.
1187		text := strings.TrimRight(i.ed.SubmitValue(), "\n")
1188		if text == "" {
1189			return false
1190		}
1191		i.ed.Clear()
1192		i.suggest.Reset()
1193
1194		if looksLikeSlashCommand(text) {
1195			head := text
1196			rest := ""
1197			if idx := strings.IndexAny(text, " \t"); idx >= 0 {
1198				head = text[:idx]
1199				rest = strings.TrimSpace(text[idx:])
1200			}
1201			if !isKnownSlashCommand(text) {
1202				// Try extensions before giving up. Extensions register
1203				// commands by bare name (no leading slash); strip it here.
1204				extName := strings.TrimPrefix(head, "/")
1205				if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1206					go i.invokeExtensionCommand(ctx, extName, rest)
1207					return false
1208				}
1209				i.mu.Lock()
1210				i.statusErr = "unknown command " + head + " — type /help to see the list"
1211				i.statusOK = ""
1212				i.mu.Unlock()
1213				return false
1214			}
1215			// Slash commands run regardless of busy state. Commands that
1216			// would mutate the transcript or replace the agent (/clear,
1217			// /compact, /logout, /login, /model) cancel the active turn
1218			// first and wait for the goroutine to wind down so they don't
1219			// race with a streaming response. Safe commands (/help,
1220			// /jump, /sessions, /jail, /unjail, /exit) run immediately
1221			// without disturbing the active turn.
1222			if slashCancelsTurn(head) {
1223				i.cancelAndWaitForIdle()
1224			}
1225			return i.runSlash(ctx, text)
1226		}
1227
1228		if i.agent == nil {
1229			i.mu.Lock()
1230			i.statusErr = "not logged in. type /login first."
1231			i.mu.Unlock()
1232			return false
1233		}
1234		// Mirror the user's typed prompt into the paired Telegram
1235		// chat (when the bridge is active) so the Telegram thread
1236		// stays a complete record of the session, not just the half
1237		// that originated on the phone. On a goroutine so the
1238		// network write doesn't delay the local turn.
1239		if i.telegramBridge != nil && i.telegramBridge.Active() {
1240			go i.telegramBridge.OnUserTyped(text)
1241		}
1242		// If a turn is already in flight, queue this prompt instead of
1243		// starting a second one. The drain loop at the end of startTurn
1244		// will pick it up when the current turn finishes.
1245		i.mu.Lock()
1246		if i.busy {
1247			i.queued = append(i.queued, text)
1248			i.mu.Unlock()
1249			i.invalidate()
1250			return false
1251		}
1252		i.mu.Unlock()
1253		i.startTurn(ctx, text)
1254	}
1255	return false
1256}
1257
1258// invokeExtensionCommand fires an extension-registered slash command
1259// in a background goroutine, awaits the response, and applies the
1260// requested action (prompt / insert / display / noop). Errors and
1261// timeouts surface as a status_err line.
1262func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
1263	resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
1264	if err != nil {
1265		i.mu.Lock()
1266		i.statusErr = "extension /" + name + ": " + err.Error()
1267		i.statusOK = ""
1268		i.mu.Unlock()
1269		i.invalidate()
1270		return
1271	}
1272	if resp.Error != "" {
1273		i.mu.Lock()
1274		i.statusErr = "extension /" + name + ": " + resp.Error
1275		i.statusOK = ""
1276		i.mu.Unlock()
1277		i.invalidate()
1278		return
1279	}
1280	switch resp.Action {
1281	case "prompt":
1282		if strings.TrimSpace(resp.Prompt) == "" {
1283			return
1284		}
1285		i.startTurn(i.runCtx, resp.Prompt)
1286	case "insert":
1287		i.ed.Insert(resp.Insert)
1288		i.invalidate()
1289	case "display":
1290		i.appendExtensionNote(name, resp.Display, "info")
1291	case "noop", "":
1292		// nothing
1293	default:
1294		i.mu.Lock()
1295		i.statusErr = "extension /" + name + ": unknown action " + resp.Action
1296		i.mu.Unlock()
1297		i.invalidate()
1298	}
1299}
1300
1301// appendExtensionNote renders an extension-originated note in the
1302// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
1303// "success" (tool/ok green).
1304func (i *Interactive) appendExtensionNote(extName, msg, level string) {
1305	if msg == "" {
1306		return
1307	}
1308	i.mu.Lock()
1309	defer i.mu.Unlock()
1310	color := i.cfg.Theme.Muted
1311	switch level {
1312	case "warn":
1313		color = i.cfg.Theme.Warning
1314	case "error":
1315		color = i.cfg.Theme.Error
1316	case "success":
1317		color = i.cfg.Theme.Tool
1318	}
1319	prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
1320	for _, line := range strings.Split(msg, "\n") {
1321		i.statusOK = "" // clear any stale ok
1322		i.statusErr = ""
1323		i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
1324	}
1325}
1326
1327// HostHooks implementation for the extension manager. The manager
1328// holds an interface, not a concrete *Interactive, so these methods
1329// are the only thing the manager sees.
1330
1331// Notify is the manager's NotifyFromExt entry point.
1332func (i *Interactive) Notify(extName, level, message string) {
1333	i.appendExtensionNote(extName, message, level)
1334	i.invalidate()
1335}
1336
1337// Submit feeds text through the agent loop as if the user had typed it.
1338func (i *Interactive) Submit(text string) {
1339	i.startTurn(i.runCtx, text)
1340}
1341
1342// SubmitOrQueue runs text immediately if the agent is idle, or
1343// appends it to the pending queue if a turn is already in flight.
1344// Used by the telegram bridge (and by the editor submit path) so
1345// both input sources share the same "queue behind an active turn"
1346// semantics. Images are ignored for now — only the text prompt is
1347// forwarded — because the queued-prompt path is text-only; a
1348// follow-up can expand the queue entry to carry images.
1349func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
1350	_ = images // reserved for future queued-with-images support
1351	i.mu.Lock()
1352	if i.agent == nil {
1353		i.statusErr = "not logged in. type /login first."
1354		i.mu.Unlock()
1355		i.invalidate()
1356		return
1357	}
1358	if i.busy {
1359		i.queued = append(i.queued, text)
1360		i.mu.Unlock()
1361		i.invalidate()
1362		return
1363	}
1364	i.mu.Unlock()
1365	i.startTurn(i.runCtx, text)
1366}
1367
1368// CancelTurn aborts the active turn if one is running. Used by the
1369// telegram bridge when the paired user sends /stop.
1370func (i *Interactive) CancelTurn() {
1371	i.mu.Lock()
1372	cancel := i.cancelTurn
1373	i.mu.Unlock()
1374	if cancel != nil {
1375		cancel()
1376	}
1377}
1378
1379// Insert places text at the cursor in the editor.
1380func (i *Interactive) Insert(text string) {
1381	i.ed.Insert(text)
1382	i.invalidate()
1383}
1384
1385// Display appends a styled note from extName to the chat without a
1386// model call.
1387func (i *Interactive) Display(extName, text string) {
1388	i.appendExtensionNote(extName, text, "info")
1389	i.invalidate()
1390}
1391
1392func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1393	parts := strings.Fields(cmd)
1394	switch parts[0] {
1395	case "/exit":
1396		return true
1397	case "/clear":
1398		if i.agent != nil {
1399			i.agent.SetMessages(nil)
1400		}
1401		i.mu.Lock()
1402		i.toolCalls = map[string]*tui.ToolCallView{}
1403		i.toolOrder = nil
1404		i.statusErr = ""
1405		i.statusOK = ""
1406		i.helpBlock = nil
1407		i.parkedTurn = 0
1408		i.parkedTotal = 0
1409		i.scrollOffset = 0
1410		i.extNotes = nil
1411		i.view.InvalidateRenderCache()
1412		i.mu.Unlock()
1413	case "/help":
1414		i.mu.Lock()
1415		i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
1416		i.statusErr = ""
1417		i.statusOK = ""
1418		// Pin the viewport to the newest content so the help block,
1419		// which we just appended to the end of the transcript, is
1420		// what the user actually sees.
1421		i.scrollOffset = 0
1422		i.mu.Unlock()
1423	case "/login":
1424		i.dialog.Open(i.cfg.ZotHome)
1425	case "/logout":
1426		if len(parts) >= 2 {
1427			// Explicit target: /logout anthropic | openai | all
1428			i.doLogout(parts[1])
1429			break
1430		}
1431		// No arg: open the picker over whichever providers are
1432		// currently logged in. If nothing's logged in, bail with a
1433		// status line.
1434		i.openLogoutDialog()
1435	case "/model":
1436		if len(parts) >= 2 {
1437			i.applyModelSelection("", parts[1])
1438		} else {
1439			i.modelDialog.Open(i.cfg.Model)
1440		}
1441	case "/sessions":
1442		i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
1443	case "/jump":
1444		i.openJumpDialog(parts[1:])
1445	case "/btw":
1446		i.openBtwDialog(parts[1:])
1447	case "/skills":
1448		i.openSkillsDialog()
1449	case "/compact":
1450		i.runCompact(ctx, false)
1451	case "/study":
1452		// Canned prompt that tells the agent to read every file
1453		// in the current directory so its later turns have the
1454		// whole project in context. Dispatched through the normal
1455		// queue-or-start path so it behaves identically to
1456		// typing the prompt by hand.
1457		const studyPrompt = "Read and understand everything in the current directory."
1458		i.mu.Lock()
1459		if i.busy {
1460			i.queued = append(i.queued, studyPrompt)
1461			i.mu.Unlock()
1462			i.invalidate()
1463			break
1464		}
1465		i.mu.Unlock()
1466		i.startTurn(ctx, studyPrompt)
1467	case "/jail":
1468		if i.cfg.Sandbox == nil {
1469			i.mu.Lock()
1470			i.statusErr = "sandbox not available in this build"
1471			i.mu.Unlock()
1472			break
1473		}
1474		i.cfg.Sandbox.Lock()
1475		i.mu.Lock()
1476		i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
1477		i.statusErr = ""
1478		i.mu.Unlock()
1479	case "/unjail":
1480		if i.cfg.Sandbox == nil {
1481			i.mu.Lock()
1482			i.statusErr = "sandbox not available in this build"
1483			i.mu.Unlock()
1484			break
1485		}
1486		i.cfg.Sandbox.Unlock()
1487		i.mu.Lock()
1488		i.statusOK = "unjailed"
1489		i.statusErr = ""
1490		i.mu.Unlock()
1491	case "/reload-ext":
1492		i.runReloadExt(ctx)
1493	case "/yolo":
1494		i.runYoloOn()
1495	case "/telegram", "/tg":
1496		if len(parts) >= 2 {
1497			i.doTelegram(parts[1])
1498			break
1499		}
1500		i.openTelegramDialog()
1501	case "/session":
1502		if len(parts) >= 2 {
1503			action := parts[1]
1504			arg := ""
1505			if len(parts) >= 3 {
1506				arg = strings.Join(parts[2:], " ")
1507			}
1508			i.doSessionOp(action, arg)
1509			break
1510		}
1511		i.openSessionOpsDialog()
1512	default:
1513		// Last-resort fallback: try the extension manager. Built-in
1514		// cases above always win; this branch only fires for slash
1515		// commands the extension manager registered. Same routing as
1516		// the editor's submit-handler dispatch path so the autocomplete
1517		// "enter on highlighted suggestion" flow also works.
1518		extName := strings.TrimPrefix(parts[0], "/")
1519		if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1520			rest := ""
1521			if len(parts) > 1 {
1522				rest = strings.Join(parts[1:], " ")
1523			}
1524			go i.invokeExtensionCommand(ctx, extName, rest)
1525			return false
1526		}
1527		i.mu.Lock()
1528		i.statusErr = "unknown command: " + parts[0]
1529		i.mu.Unlock()
1530	}
1531	return false
1532}
1533
1534// openLogoutDialog shows the provider picker for `/logout` with no
1535// argument. Only providers the user is currently logged into are
1536// listed, plus an "all" entry when more than one is present. If
1537// nothing's logged in, writes a status line instead of opening an
1538// empty dialog.
1539func (i *Interactive) openLogoutDialog() {
1540	if i.cfg.AuthManager == nil {
1541		i.mu.Lock()
1542		i.statusErr = "no auth manager configured"
1543		i.mu.Unlock()
1544		i.invalidate()
1545		return
1546	}
1547	store := i.cfg.AuthManager.Store()
1548	if store == nil {
1549		i.mu.Lock()
1550		i.statusErr = "auth store is not available"
1551		i.mu.Unlock()
1552		i.invalidate()
1553		return
1554	}
1555	creds, err := store.Load()
1556	if err != nil {
1557		i.mu.Lock()
1558		i.statusErr = "read auth store: " + err.Error()
1559		i.mu.Unlock()
1560		i.invalidate()
1561		return
1562	}
1563
1564	var items []logoutItem
1565	for _, p := range []string{"anthropic", "openai"} {
1566		if creds.Has(p) {
1567			items = append(items, logoutItem{
1568				label:  p,
1569				target: p,
1570				method: creds.Method(p),
1571			})
1572		}
1573	}
1574	if len(items) == 0 {
1575		i.mu.Lock()
1576		i.statusOK = "no credentials stored; already logged out"
1577		i.statusErr = ""
1578		i.mu.Unlock()
1579		i.invalidate()
1580		return
1581	}
1582	if len(items) > 1 {
1583		items = append(items, logoutItem{label: "all", target: "all"})
1584	}
1585
1586	i.logoutDialog.Open(items)
1587	i.invalidate()
1588}
1589
1590// doLogout clears credentials for the given provider (or all providers)
1591// from auth.json. If the active agent was using those credentials, it
1592// is torn down so the user is forced through /login before their next
1593// prompt.
1594//
1595// target: "anthropic" | "openai" | "all"
1596func (i *Interactive) doLogout(target string) {
1597	if i.cfg.AuthManager == nil {
1598		i.mu.Lock()
1599		i.statusErr = "no auth manager configured"
1600		i.mu.Unlock()
1601		return
1602	}
1603	store := i.cfg.AuthManager.Store()
1604	if store == nil {
1605		i.mu.Lock()
1606		i.statusErr = "auth store is not available"
1607		i.mu.Unlock()
1608		return
1609	}
1610
1611	var providers []string
1612	switch target {
1613	case "", "all":
1614		providers = []string{"anthropic", "openai"}
1615	case "anthropic", "openai":
1616		providers = []string{target}
1617	default:
1618		i.mu.Lock()
1619		i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"
1620		i.mu.Unlock()
1621		return
1622	}
1623
1624	var errs []string
1625	clearedCurrent := false
1626	for _, p := range providers {
1627		if err := store.Clear(p); err != nil {
1628			errs = append(errs, p+": "+err.Error())
1629			continue
1630		}
1631		if p == i.cfg.Provider {
1632			clearedCurrent = true
1633		}
1634	}
1635
1636	i.mu.Lock()
1637	defer i.mu.Unlock()
1638	if len(errs) > 0 {
1639		i.statusErr = "logout errors: " + strings.Join(errs, "; ")
1640		return
1641	}
1642	i.statusErr = ""
1643	if clearedCurrent {
1644		// The running agent was using a credential we just wiped. Drop
1645		// it so prompts can't go out with the stale client, and hint at
1646		// /login.
1647		i.agent = nil
1648		i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
1649	} else {
1650		i.statusOK = "logged out of " + strings.Join(providers, ", ")
1651	}
1652}
1653
1654func (i *Interactive) startAPIKeyFlow(provider string) {
1655	url, err := i.cfg.AuthManager.StartAPIKey(provider)
1656	if err != nil {
1657		i.dialog.ShowResult(false, err.Error())
1658		return
1659	}
1660	i.dialog.ShowWaiting(url)
1661}
1662
1663func (i *Interactive) startOAuthFlow(provider string) {
1664	url, err := i.cfg.AuthManager.StartOAuth(provider)
1665	if err != nil {
1666		i.dialog.ShowResult(false, err.Error())
1667		return
1668	}
1669	i.dialog.ShowWaiting(url)
1670}
1671
1672// applyModelSelection switches the active model (and provider, if the
1673// new model belongs to a different one). It rebuilds the underlying
1674// client when needed so the provider wire-protocol matches.
1675// cancelAndWaitForIdle cancels the active turn (if any) and blocks
1676// briefly until the turn goroutine has updated i.busy = false. Used
1677// before destructive slash commands so transcript-mutating work
1678// (/clear, /compact, /logout, /login completion, cross-provider
1679// /model swap) doesn't race with the still-running stream.
1680//
1681// The wait is bounded; if the turn doesn't release within the timeout
1682// we proceed anyway. Worst case is a brief overlap that the agent's
1683// own mutex protects against.
1684func (i *Interactive) cancelAndWaitForIdle() {
1685	i.mu.Lock()
1686	busy := i.busy
1687	cancel := i.cancelTurn
1688	i.mu.Unlock()
1689	if !busy {
1690		return
1691	}
1692	if cancel != nil {
1693		cancel()
1694	}
1695	deadline := time.Now().Add(2 * time.Second)
1696	for time.Now().Before(deadline) {
1697		i.mu.Lock()
1698		done := !i.busy
1699		i.mu.Unlock()
1700		if done {
1701			return
1702		}
1703		time.Sleep(10 * time.Millisecond)
1704	}
1705}
1706
1707// openBtwDialog opens the side-chat overlay with a frozen snapshot
1708// of the current main session. The optional argument is auto-
1709// submitted as the first question, so '/btw does X work?' fires the
1710// model call immediately instead of just opening an empty dialog.
1711func (i *Interactive) openBtwDialog(args []string) {
1712	if i.agent == nil {
1713		i.mu.Lock()
1714		i.statusErr = "not logged in. type /login first."
1715		i.mu.Unlock()
1716		return
1717	}
1718	seed := strings.TrimSpace(strings.Join(args, " "))
1719	i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed)
1720	i.invalidate()
1721}
1722
1723// openSkillsDialog opens the skill inspector. The picker reflects
1724// whatever SkillSnapshot returns at call time, so edits to a
1725// SKILL.md made during a session show up on the next /skills.
1726func (i *Interactive) openSkillsDialog() {
1727	var list []*skills.Skill
1728	if i.cfg.SkillSnapshot != nil {
1729		list = i.cfg.SkillSnapshot()
1730	}
1731	i.skillsDialog.Open(list)
1732	i.invalidate()
1733}
1734
1735// openJumpDialog builds a /jump picker from the current transcript.
1736// If the user typed "/jump foo" with a filter and it matches exactly
1737// one turn, jump there directly without showing the dialog.
1738func (i *Interactive) openJumpDialog(args []string) {
1739	if i.view == nil || len(i.view.Messages) == 0 {
1740		i.mu.Lock()
1741		i.statusErr = "nothing to jump to \u2014 the session is empty"
1742		i.mu.Unlock()
1743		return
1744	}
1745	filter := strings.TrimSpace(strings.Join(args, " "))
1746	i.jumpDialog.Open(i.view.Messages, filter)
1747	// Shortcut: with a filter argument that matches exactly one turn,
1748	// jump immediately and skip the picker.
1749	if filter != "" {
1750		if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
1751			t := tgts[0]
1752			i.jumpDialog.Close()
1753			i.applyJumpSelection(t.MessageIdx, t.TurnNo)
1754		}
1755	}
1756}
1757
1758// applyJumpSelection scrolls the chat viewport so the user message at
1759// msgIdx is visible at (or near) the top of the chat area. Uses the
1760// anchor slice returned by view.BuildWithAnchors so the mapping from
1761// message index to row is exact, regardless of variable-height tool
1762// blocks above the target.
1763func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
1764	cols := i.lastCols()
1765	chat, anchors := i.view.BuildWithAnchors(cols)
1766	var row int
1767	found := false
1768	for _, a := range anchors {
1769		if a.MessageIdx == msgIdx {
1770			row = a.Row
1771			found = true
1772			break
1773		}
1774	}
1775	if !found {
1776		i.mu.Lock()
1777		i.statusErr = "could not resolve jump target"
1778		i.mu.Unlock()
1779		return
1780	}
1781
1782	chatLen := len(chat)
1783	page := i.chatPage()
1784	if page < 1 {
1785		page = 1
1786	}
1787	// scrollOffset is measured from the bottom of the chat slice, so
1788	// to place `row` at the top of the viewport we want:
1789	//     chatLen - scrollOffset - page == row
1790	// Solve for scrollOffset and clamp to [0, chatLen-page].
1791	offset := chatLen - (row + page)
1792	if offset < 0 {
1793		offset = 0
1794	}
1795	maxOffset := chatLen - page
1796	if maxOffset < 0 {
1797		maxOffset = 0
1798	}
1799	if offset > maxOffset {
1800		offset = maxOffset
1801	}
1802
1803	i.mu.Lock()
1804	i.scrollOffset = offset
1805	i.parkedTurn = turnNo
1806	i.parkedTotal = totalTurnsLocked(i.view.Messages)
1807	i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
1808	i.statusErr = ""
1809	i.mu.Unlock()
1810}
1811
1812// totalTurnsLocked counts user messages in the transcript. Caller is
1813// assumed to hold i.mu (the name is a mild reminder; this function
1814// itself doesn't touch shared state beyond the slice it's handed).
1815func totalTurnsLocked(msgs []provider.Message) int {
1816	n := 0
1817	for _, m := range msgs {
1818		if m.Role == provider.RoleUser {
1819			n++
1820		}
1821	}
1822	return n
1823}
1824
1825// applySessionSelection loads the given session via the cli-provided
1826// callback and parks the viewport on the last turn so the user lands
1827// looking at where the conversation left off (their last prompt at the
1828// top of the chat, the assistant's last reply right below). Older
1829// history is one scroll up; pgdn or end snaps to the current tail.
1830//
1831// Without this, scrollOffset stayed at 0 (pinned to the live tail),
1832// which on a long resumed session showed only the last few rows of
1833// the final assistant message — the user read that as "only one liner
1834// happened, the resume didn't work".
1835func (i *Interactive) applySessionSelection(path string) {
1836	if i.cfg.LoadSession == nil {
1837		i.mu.Lock()
1838		i.statusErr = "session loading is not wired in this build"
1839		i.mu.Unlock()
1840		return
1841	}
1842	if err := i.cfg.LoadSession(path); err != nil {
1843		i.mu.Lock()
1844		i.statusErr = err.Error()
1845		i.mu.Unlock()
1846		return
1847	}
1848
1849	i.mu.Lock()
1850	i.statusOK = "resumed session: " + path
1851	i.statusErr = ""
1852	i.parkedTurn = 0
1853	i.parkedTotal = 0
1854	i.view.InvalidateRenderCache()
1855	// Pull the freshly-loaded transcript into the view so the anchor
1856	// math below sees the post-resume messages, not the empty pre-load
1857	// state. redraw() does the same on its next pass; we just front-run
1858	// it here to compute the scroll target.
1859	if i.agent != nil {
1860		i.view.Messages = i.agent.Messages()
1861	}
1862	msgs := i.view.Messages
1863	i.mu.Unlock()
1864
1865	i.scrollToLastTurn(msgs)
1866}
1867
1868// scrollToLastTurn parks the viewport at the most recent user turn,
1869// or at the top if the transcript has no user messages. Used after
1870// resume so the user lands looking at where they left off.
1871func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
1872	if len(msgs) == 0 {
1873		i.mu.Lock()
1874		i.scrollOffset = 0
1875		i.mu.Unlock()
1876		return
1877	}
1878	// Find the last user message index.
1879	lastUser := -1
1880	turnNo, totalTurns := 0, 0
1881	for idx, m := range msgs {
1882		if m.Role == provider.RoleUser {
1883			totalTurns++
1884			lastUser = idx
1885		}
1886	}
1887	if lastUser < 0 {
1888		i.mu.Lock()
1889		i.scrollOffset = 0
1890		i.mu.Unlock()
1891		return
1892	}
1893	turnNo = totalTurns
1894
1895	cols := i.lastCols()
1896	chat, anchors := i.view.BuildWithAnchors(cols)
1897	var row int
1898	found := false
1899	for _, a := range anchors {
1900		if a.MessageIdx == lastUser {
1901			row = a.Row
1902			found = true
1903			break
1904		}
1905	}
1906	if !found {
1907		i.mu.Lock()
1908		i.scrollOffset = 0
1909		i.mu.Unlock()
1910		return
1911	}
1912
1913	chatLen := len(chat)
1914	page := i.chatPage()
1915	if page < 1 {
1916		page = 1
1917	}
1918	offset := chatLen - (row + page)
1919	if offset < 0 {
1920		offset = 0
1921	}
1922	maxOffset := chatLen - page
1923	if maxOffset < 0 {
1924		maxOffset = 0
1925	}
1926	if offset > maxOffset {
1927		offset = maxOffset
1928	}
1929
1930	i.mu.Lock()
1931	i.scrollOffset = offset
1932	// Mark the parked-turn footer so the user sees "viewing turn N of
1933	// M - pgdn to catch up" — same affordance as /jump. Tells them at
1934	// a glance that they're looking at history, not the live tail.
1935	if offset > 0 {
1936		i.parkedTurn = turnNo
1937		i.parkedTotal = totalTurns
1938	}
1939	i.mu.Unlock()
1940	i.invalidate()
1941}
1942
1943func (i *Interactive) applyModelSelection(prov, model string) {
1944	if model == "" {
1945		return
1946	}
1947	m, err := provider.FindModel(prov, model)
1948	if err != nil {
1949		i.mu.Lock()
1950		i.statusErr = err.Error()
1951		i.mu.Unlock()
1952		return
1953	}
1954	// Same provider? Just swap the model on the existing agent.
1955	if i.agent != nil && m.Provider == i.cfg.Provider {
1956		i.mu.Lock()
1957		i.cfg.Model = m.ID
1958		i.agent.Model = m.ID
1959		i.statusOK = "model: " + m.ID
1960		i.statusErr = ""
1961		i.mu.Unlock()
1962		if i.cfg.PersistModel != nil {
1963			i.cfg.PersistModel(i.cfg.Provider, m.ID)
1964		}
1965		return
1966	}
1967	// Different provider: rebuild agent (needs credentials for target provider).
1968	if i.cfg.BuildAgentFor == nil {
1969		i.mu.Lock()
1970		i.statusErr = "cannot switch provider: no builder configured"
1971		i.mu.Unlock()
1972		return
1973	}
1974	// Snapshot the current transcript and cumulative usage BEFORE we
1975	// build the replacement agent so we can hand them off. Without
1976	// this the user perceives the entire session as wiped on a
1977	// cross-provider /model swap.
1978	var carryMsgs []provider.Message
1979	var carryCost provider.Usage
1980	if i.agent != nil {
1981		carryMsgs = i.agent.Messages()
1982		carryCost = i.agent.Cost()
1983	}
1984
1985	ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)
1986	if err != nil {
1987		i.mu.Lock()
1988		i.statusErr = err.Error()
1989		i.mu.Unlock()
1990		return
1991	}
1992
1993	// Replay the transcript and seed the cost on the freshly-built
1994	// agent. Messages travel cleanly between providers because they
1995	// use the same provider.Message shape; tool-call ids are local
1996	// to a turn so cross-provider continuation never confuses the
1997	// new model (it just sees the assistant's reply, no orphan
1998	// tool_use blocks because /model swaps are gated to idle state).
1999	if len(carryMsgs) > 0 {
2000		ag.SetMessages(carryMsgs)
2001	}
2002	ag.SeedCost(carryCost)
2003
2004	i.mu.Lock()
2005	i.agent = ag
2006	i.cfg.Provider = p
2007	i.cfg.Model = md
2008	i.statusOK = "switched to " + p + " / " + md
2009	i.statusErr = ""
2010	// Render cache keys are width+content based, so the new agent's
2011	// identical messages will reuse the existing entries. Nothing
2012	// to invalidate.
2013	i.mu.Unlock()
2014	if i.cfg.PersistModel != nil {
2015		i.cfg.PersistModel(p, md)
2016	}
2017}
2018
2019func (i *Interactive) handleAuthEvent(ev auth.Event) {
2020	switch ev.Kind {
2021	case "started":
2022		i.dialog.ShowWaiting(ev.URL)
2023	case "browser_open":
2024		// no-op
2025	case "error":
2026		i.dialog.ShowResult(false, ev.Message)
2027	case "success":
2028		// Rebuild the agent with the fresh credential.
2029		ag, prov, model, err := i.cfg.BuildAgent()
2030		if err != nil {
2031			i.dialog.ShowResult(false, err.Error())
2032			return
2033		}
2034		i.mu.Lock()
2035		i.agent = ag
2036		i.cfg.Provider = prov
2037		i.cfg.Model = model
2038		i.statusErr = ""
2039		i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
2040		i.mu.Unlock()
2041		i.dialog.ShowResult(true, "")
2042	}
2043}
2044
2045// runCompact invokes core.Agent.Compact and reflects the progress in
2046// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
2047// cancel via the same cancelTurn channel used for normal turns.
2048//
2049// When auto is true the spinner message is pinned to "condensing
2050// history" and the status bar surfaces "(auto)" next to the context
2051// percentage so it's obvious the system triggered this, not the user.
2052func (i *Interactive) runCompact(parent context.Context, auto bool) {
2053	if i.agent == nil {
2054		i.mu.Lock()
2055		i.statusErr = "not logged in. type /login first."
2056		i.mu.Unlock()
2057		return
2058	}
2059	ctx, cancel := context.WithCancel(parent)
2060	i.mu.Lock()
2061	i.busy = true
2062	if auto {
2063		i.spin.StartFixed("condensing history")
2064		i.autoCompacting = true
2065		i.statusOK = "condensing history… (esc to cancel)"
2066	} else {
2067		i.spin.Start()
2068		i.statusOK = "compacting..."
2069	}
2070	i.cancelTurn = cancel
2071	i.statusErr = ""
2072	i.streaming.Reset()
2073	i.streamOn = true
2074	i.scrollOffset = 0
2075	i.helpBlock = nil
2076	i.mu.Unlock()
2077	i.invalidate()
2078
2079	go func() {
2080		sink := func(delta string) {
2081			i.mu.Lock()
2082			i.streaming.WriteString(delta)
2083			i.mu.Unlock()
2084			i.invalidate()
2085		}
2086		summary, err := i.agent.Compact(ctx, 4, sink)
2087		i.mu.Lock()
2088		i.busy = false
2089		i.resetStreamingStateLocked()
2090		i.cancelTurn = nil
2091		i.autoCompacting = false
2092		switch {
2093		case err != nil && ctx.Err() != nil:
2094			i.statusErr = ""
2095			if auto {
2096				i.statusOK = "auto-condense cancelled"
2097			} else {
2098				i.statusOK = "compaction cancelled"
2099			}
2100		case err != nil:
2101			i.statusErr = "compaction failed: " + err.Error()
2102			i.statusOK = ""
2103		default:
2104			i.statusErr = ""
2105			i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary))
2106			i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
2107			i.toolCalls = map[string]*tui.ToolCallView{}
2108			i.toolOrder = nil
2109			// Transcript was rewritten in place — purge the per-message
2110			// render cache so stale entries keyed on the old messages
2111			// don't linger.
2112			i.view.InvalidateRenderCache()
2113		}
2114		i.mu.Unlock()
2115		i.invalidate()
2116	}()
2117}
2118
2119func (i *Interactive) startTurn(parent context.Context, prompt string) {
2120	if i.agent == nil {
2121		return
2122	}
2123	ctx, cancel := context.WithCancel(parent)
2124	i.mu.Lock()
2125	i.busy = true
2126	i.spin.Start()
2127	i.cancelTurn = cancel
2128	i.statusErr = ""
2129	i.statusOK = ""
2130	i.streaming.Reset()
2131	i.streamOn = true
2132	i.toolCalls = map[string]*tui.ToolCallView{}
2133	i.toolOrder = nil
2134	i.scrollOffset = 0 // jump back to the bottom on new turn
2135	i.parkedTurn = 0   // starting a turn clears the /jump parked state
2136	i.parkedTotal = 0
2137	i.helpBlock = nil // hide the help block once the user asks something
2138	i.mu.Unlock()
2139	i.invalidate()
2140
2141	sink := func(ev core.AgentEvent) {
2142		i.handleEvent(ev)
2143		i.invalidate()
2144	}
2145
2146	go func() {
2147		err := i.agent.Prompt(ctx, prompt, nil, sink)
2148		i.mu.Lock()
2149		i.busy = false
2150		// Don't touch streamPending / streamFlushPending here — the
2151		// pacer may still be draining the final deltas and needs to
2152		// paint them even though Prompt has returned. It will reset
2153		// streamOn on its own once the buffer empties.
2154		if len(i.streamPending) == 0 {
2155			i.streamOn = false
2156		}
2157		i.cancelTurn = nil
2158		if err != nil && ctx.Err() == nil {
2159			i.statusErr = err.Error()
2160		}
2161		// Persist the assistant's reply (and every tool row before
2162		// it) to the session file while the turn memory is hot.
2163		// Without this, WriteNewTranscript only fires at zot exit,
2164		// meaning a crash or ungraceful kill drops the whole
2165		// conversation. FlushSession is idempotent (it advances the
2166		// baseline so subsequent flushes only write new rows).
2167		flush := i.cfg.FlushSession
2168		i.mu.Unlock()
2169		if flush != nil {
2170			flush()
2171		}
2172		i.mu.Lock()
2173		// Pop the next queued message, if any, and relaunch.
2174		var next string
2175		var hasNext bool
2176		if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
2177			next, i.queued = i.queued[0], i.queued[1:]
2178			hasNext = true
2179		}
2180		// If the turn was cancelled or errored, drop the queue so the
2181		// user isn't bombarded with stale messages after an interrupt.
2182		if ctx.Err() != nil || err != nil {
2183			i.queued = nil
2184		}
2185		// Decide whether the next thing to do is an auto-compaction.
2186		// Only fires when the turn completed cleanly AND the queue is
2187		// empty (otherwise a queued message would race the condense).
2188		shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
2189		i.mu.Unlock()
2190		i.invalidate()
2191		parent := i.runCtx
2192		if parent == nil {
2193			parent = context.Background()
2194		}
2195		switch {
2196		case hasNext:
2197			i.startTurn(parent, next)
2198		case shouldAutoCompact:
2199			i.runCompact(parent, true)
2200		}
2201	}()
2202}
2203
2204// autoCompactThreshold is the context-window fraction at which the
2205// agent will auto-compact after a turn ends. 0.85 leaves enough
2206// headroom for one more user prompt + response before we bump the
2207// hard limit.
2208const autoCompactThreshold = 0.85
2209
2210// shouldAutoCompactLocked reports whether the last turn pushed context
2211// usage past the auto-compact threshold. Must be called with i.mu
2212// held; it reads lastCtxInput and the current model's context window.
2213func (i *Interactive) shouldAutoCompactLocked() bool {
2214	if i.agent == nil {
2215		return false
2216	}
2217	if i.autoCompacting {
2218		return false
2219	}
2220	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
2221	if err != nil || m.ContextWindow <= 0 {
2222		return false
2223	}
2224	if i.lastCtxInput <= 0 {
2225		return false
2226	}
2227	return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
2228}
2229
2230func (i *Interactive) handleEvent(ev core.AgentEvent) {
2231	i.mu.Lock()
2232	defer i.mu.Unlock()
2233	switch e := ev.(type) {
2234	case core.EvAssistantStart:
2235		// Fires at the top of every oneTurn, including follow-up
2236		// turns after tool use. Without this, the streaming buffer
2237		// is still marked off from the previous assistant message
2238		// and the final summary text pops in all at once instead
2239		// of typewriter-streaming delta by delta.
2240		i.streaming.Reset()
2241		i.streamPending = i.streamPending[:0]
2242		i.streamFlushPending = false
2243		i.streamOn = true
2244		// Clear the live tool-call overlay. Any tools from the
2245		// previous round are now fully folded into the transcript
2246		// (assistant tool_use block + tool role message with the
2247		// result), so keeping them in the overlay would duplicate
2248		// them in the view — once inside the finalised transcript
2249		// and once below the streaming block, with the streaming
2250		// summary sandwiched in between. The next EvToolUseStart
2251		// will populate fresh entries for this turn's tools.
2252		i.toolCalls = map[string]*tui.ToolCallView{}
2253		i.toolOrder = nil
2254	case core.EvTextDelta:
2255		// Buffer into streamPending; the paintPace ticker drains
2256		// it into i.streaming a few runes at a time for a smooth
2257		// typewriter effect independent of upstream chunk size.
2258		i.streamPending = append(i.streamPending, []rune(e.Delta)...)
2259		i.streamOn = true
2260	case core.EvAssistantMessage:
2261		// OnAssistant + telegram mirroring always fire on message
2262		// arrival — they read the FINAL message content, which is
2263		// complete regardless of what's still in the pacer.
2264		i.assistantMessageSideEffects(e.Message)
2265		// If the pacer still has characters to drain, keep streamOn
2266		// true and mark flush pending; the paintPace ticker will
2267		// drain the remainder and reset streaming state when done.
2268		// Otherwise (rare: full-replay sessions, abort paths) clear
2269		// synchronously so a later render doesn't show stale text.
2270		if len(i.streamPending) > 0 {
2271			i.streamFlushPending = true
2272			return
2273		}
2274		i.resetStreamingStateLocked()
2275	case core.EvToolUseStart:
2276		// Live streaming: pre-create the view so the user sees the
2277		// tool call being composed in real time. Any subsequent
2278		// EvToolCall for the same ID updates the same struct (the
2279		// final parsed args + name are already known here).
2280		if _, exists := i.toolCalls[e.ID]; !exists {
2281			i.toolCalls[e.ID] = &tui.ToolCallView{
2282				ID:        e.ID,
2283				Name:      e.Name,
2284				Streaming: true,
2285			}
2286			i.toolOrder = append(i.toolOrder, e.ID)
2287		}
2288	case core.EvToolUseArgs:
2289		if tc, ok := i.toolCalls[e.ID]; ok {
2290			tc.RawJSONBuf += e.Delta
2291			// Refresh the live path as soon as it parses; used in
2292			// the header (▸ write /Users/pat/Desktop/demo.ts)
2293			// while the content is still streaming.
2294			if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
2295				tc.LivePath = p
2296			} else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
2297				tc.LivePath = p
2298			}
2299		}
2300	case core.EvToolUseEnd:
2301		if tc, ok := i.toolCalls[e.ID]; ok {
2302			tc.Streaming = false
2303		}
2304	case core.EvToolCall:
2305		// If we already pre-created the view during streaming, just
2306		// refresh the final Args summary. Otherwise create a new one
2307		// (non-streaming providers or legacy paths).
2308		if tc, ok := i.toolCalls[e.ID]; ok {
2309			tc.Args = tui.ShortArgs(e.Name, e.Args)
2310			tc.Streaming = false
2311		} else {
2312			i.toolCalls[e.ID] = &tui.ToolCallView{
2313				ID:   e.ID,
2314				Name: e.Name,
2315				Args: tui.ShortArgs(e.Name, e.Args),
2316			}
2317			i.toolOrder = append(i.toolOrder, e.ID)
2318		}
2319	case core.EvToolResult:
2320		if tc, ok := i.toolCalls[e.ID]; ok {
2321			tc.Done = true
2322			tc.Error = e.Result.IsError
2323			var text strings.Builder
2324			for _, c := range e.Result.Content {
2325				if tb, ok := c.(provider.TextBlock); ok {
2326					if text.Len() > 0 {
2327						text.WriteString("\n")
2328					}
2329					text.WriteString(tb.Text)
2330				}
2331			}
2332			tc.Result = text.String()
2333		}
2334		if i.cfg.OnToolResult != nil {
2335			i.cfg.OnToolResult(e.ID, e.Result)
2336		}
2337	case core.EvUsage:
2338		i.cumUsage = e.Cumulative
2339		if e.Usage.InputTokens > 0 {
2340			i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
2341		}
2342	case core.EvTurnEnd:
2343		if e.Stop == provider.StopAborted {
2344			// Aborted turn: discard the partial streaming text (it
2345			// is not persisted in the transcript) and clear any
2346			// transient error. Also drop anything still buffered in
2347			// the pacer so nothing keeps drawing after the cancel.
2348			i.resetStreamingStateLocked()
2349			i.statusErr = ""
2350			i.statusOK = "cancelled"
2351			return
2352		}
2353		if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") {
2354			i.statusErr = e.Err.Error()
2355		}
2356	}
2357}
2358
2359// Agent returns the current agent, if any. Used by cli.go to flush the
2360// final transcript to the session file.
2361func (i *Interactive) Agent() *core.Agent {
2362	i.mu.Lock()
2363	defer i.mu.Unlock()
2364	return i.agent
2365}
2366
2367// silence unused import in some build configs
2368var _ = fmt.Sprintf
2369
2370// runReloadExt triggers a live reload of every extension (discovered
2371// + explicit). Runs on a goroutine so the TUI stays responsive; the
2372// Manager.Reload takes a couple of hundred ms to shut down subprocs
2373// and respawn them. Shows a status line throughout.
2374func (i *Interactive) runReloadExt(ctx context.Context) {
2375	if i.cfg.Extensions == nil {
2376		i.mu.Lock()
2377		i.statusErr = "no extension manager in this build"
2378		i.mu.Unlock()
2379		i.invalidate()
2380		return
2381	}
2382	i.mu.Lock()
2383	i.statusOK = "reloading extensions…"
2384	i.statusErr = ""
2385	i.mu.Unlock()
2386	i.invalidate()
2387
2388	go func() {
2389		stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
2390		msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
2391		if len(stats.Errors) > 0 {
2392			msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
2393		}
2394		i.mu.Lock()
2395		i.statusOK = msg
2396		i.statusErr = ""
2397		i.mu.Unlock()
2398		i.invalidate()
2399	}()
2400}
2401
2402// Confirm implements core.Confirmer. The agent goroutine calls
2403// this synchronously before every tool invocation when --no-yolo is
2404// active. We push the request onto the confirmDialog queue, trigger
2405// a redraw, and block the caller until the user answers.
2406//
2407// If the session is cancelled or the TUI exits mid-prompt, any
2408// pending request is refused via CancelAll so the agent doesn't
2409// deadlock.
2410func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
2411	resp := make(chan core.ConfirmDecision, 1)
2412	i.confirmDialog.Enqueue(&confirmRequest{
2413		toolName: toolName,
2414		preview:  preview,
2415		resp:     resp,
2416	})
2417	i.invalidate()
2418	return <-resp
2419}
2420
2421// runYoloOn disables --no-yolo for the rest of the session. Tool
2422// calls run without prompting after this; there's intentionally no
2423// way to re-enable gating mid-session, if the user wants that back
2424// they can exit and restart with --no-yolo.
2425func (i *Interactive) runYoloOn() {
2426	if i.cfg.ConfirmGate == nil {
2427		i.mu.Lock()
2428		i.statusOK = "yolo mode is already on (no --no-yolo in this session)"
2429		i.statusErr = ""
2430		i.mu.Unlock()
2431		i.invalidate()
2432		return
2433	}
2434	i.cfg.ConfirmGate.AllowAll()
2435	// Also auto-allow any currently pending confirmation so the
2436	// agent doesn't deadlock if /yolo is typed while a prompt is
2437	// open.
2438	i.confirmDialog.AllowAllPending()
2439	i.mu.Lock()
2440	i.statusOK = "yolo engaged: no more tool-call confirmations this session"
2441	i.statusErr = ""
2442	i.mu.Unlock()
2443	i.invalidate()
2444}
2445
2446// openTelegramDialog shows the picker for `/telegram` with no arg.
2447// Items depend on current state: disconnect + status when running,
2448// connect + status when stopped.
2449func (i *Interactive) openTelegramDialog() {
2450	items := i.telegramMenuItems()
2451	if len(items) == 0 {
2452		i.mu.Lock()
2453		i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
2454		i.mu.Unlock()
2455		i.invalidate()
2456		return
2457	}
2458	i.telegramDialog.Open(items)
2459	i.invalidate()
2460}
2461
2462// telegramMenuItems builds the dialog entries for the current
2463// bridge state. Returns empty when no bot.json exists so the
2464// caller can show a helpful status line instead of an empty menu.
2465func (i *Interactive) telegramMenuItems() []telegramItem {
2466	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
2467	if err != nil || cfg.BotToken == "" {
2468		return nil
2469	}
2470	var items []telegramItem
2471	if i.telegramBridge != nil && i.telegramBridge.Active() {
2472		items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
2473		st := i.telegramBridge.State()
2474		hint := "active"
2475		if st.Username != "" {
2476			hint += " as @" + st.Username
2477		}
2478		items = append(items, telegramItem{label: "status", action: "status", hint: hint})
2479	} else {
2480		label := "connect"
2481		hint := "start mirroring dms into this session"
2482		if cfg.AllowedUserID == 0 {
2483			hint = "awaiting pairing (send /start to the bot once connected)"
2484		}
2485		items = append(items, telegramItem{label: label, action: "connect", hint: hint})
2486		items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
2487	}
2488	return items
2489}
2490
2491// doTelegram dispatches one of the three explicit actions. Called
2492// from /telegram <action> or after the picker selects a row.
2493func (i *Interactive) doTelegram(action string) {
2494	switch action {
2495	case "connect":
2496		i.telegramConnect()
2497	case "disconnect":
2498		i.telegramDisconnect()
2499	case "status":
2500		i.telegramStatus()
2501	default:
2502		i.mu.Lock()
2503		i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
2504		i.mu.Unlock()
2505		i.invalidate()
2506	}
2507}
2508
2509// telegramConnect starts the bridge. Refuses if it's already
2510// running or if the on-disk bot.json is missing a token.
2511func (i *Interactive) telegramConnect() {
2512	if i.telegramBridge != nil && i.telegramBridge.Active() {
2513		i.mu.Lock()
2514		i.statusOK = "telegram already connected"
2515		i.statusErr = ""
2516		i.mu.Unlock()
2517		i.invalidate()
2518		return
2519	}
2520	cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
2521	if err != nil {
2522		i.mu.Lock()
2523		i.statusErr = "telegram: " + err.Error()
2524		i.mu.Unlock()
2525		i.invalidate()
2526		return
2527	}
2528	if cfg.BotToken == "" {
2529		i.mu.Lock()
2530		i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
2531		i.mu.Unlock()
2532		i.invalidate()
2533		return
2534	}
2535	// Refuse to start when a background daemon is already polling
2536	// the same bot. Two concurrent long-poll consumers race each
2537	// update and one always loses, so DMs get half-delivered. The
2538	// user can `zot telegram-bot stop` first, then /telegram connect.
2539	if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
2540		i.mu.Lock()
2541		i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
2542		i.mu.Unlock()
2543		i.invalidate()
2544		return
2545	}
2546	i.telegramBridge = &telegram.Bridge{
2547		Client: telegram.NewClient(cfg.BotToken),
2548		Config: cfg,
2549		Save: func(next telegram.Config) error {
2550			return telegram.SaveConfig(i.cfg.ZotHome, next)
2551		},
2552		Host: &telegramHost{iv: i},
2553	}
2554	if err := i.telegramBridge.Start(i.runCtx); err != nil {
2555		i.telegramBridge = nil
2556		i.mu.Lock()
2557		i.statusErr = "telegram connect failed: " + err.Error()
2558		i.mu.Unlock()
2559		i.invalidate()
2560		return
2561	}
2562	state := i.telegramBridge.State()
2563	label := "telegram connected"
2564	if state.Username != "" {
2565		label += " as @" + state.Username
2566	}
2567	if state.PairedID == 0 {
2568		label += " — send /start to the bot from your phone to claim it"
2569	}
2570	i.mu.Lock()
2571	i.statusOK = label
2572	i.statusErr = ""
2573	i.mu.Unlock()
2574	i.invalidate()
2575}
2576
2577// telegramDisconnect stops the bridge. No-op when already stopped.
2578func (i *Interactive) telegramDisconnect() {
2579	if i.telegramBridge == nil || !i.telegramBridge.Active() {
2580		i.mu.Lock()
2581		i.statusOK = "telegram already disconnected"
2582		i.statusErr = ""
2583		i.mu.Unlock()
2584		i.invalidate()
2585		return
2586	}
2587	i.telegramBridge.Stop()
2588	i.mu.Lock()
2589	i.statusOK = "telegram disconnected"
2590	i.statusErr = ""
2591	i.mu.Unlock()
2592	i.invalidate()
2593}
2594
2595// telegramStatus writes a one-liner describing the bridge state.
2596// Reports on both the in-tui bridge and the background daemon so
2597// the user isn't confused when the daemon owns the poll loop.
2598func (i *Interactive) telegramStatus() {
2599	var msg string
2600	if i.telegramBridge != nil && i.telegramBridge.Active() {
2601		s := i.telegramBridge.State()
2602		msg = "telegram: connected (tui bridge)"
2603		if s.Username != "" {
2604			msg += " as @" + s.Username
2605		}
2606		if s.PairedID != 0 {
2607			msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
2608		} else {
2609			msg += " - awaiting pairing"
2610		}
2611	} else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
2612		msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
2613	} else {
2614		cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
2615		if cfg.BotToken == "" {
2616			msg = "telegram: not configured. run `zot telegram-bot setup` first."
2617		} else {
2618			msg = "telegram: disconnected"
2619			if cfg.BotUsername != "" {
2620				msg += " (@" + cfg.BotUsername + " ready to connect)"
2621			}
2622		}
2623	}
2624	i.mu.Lock()
2625	i.statusOK = msg
2626	i.statusErr = ""
2627	i.mu.Unlock()
2628	i.invalidate()
2629}
2630
2631// telegramHost adapts *Interactive to telegram.Host so the bridge
2632// can call back into the TUI without importing modes directly.
2633type telegramHost struct{ iv *Interactive }
2634
2635func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
2636	h.iv.SubmitOrQueue(prompt, images)
2637}
2638
2639func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
2640
2641func (h *telegramHost) Notify(level, message string) {
2642	h.iv.mu.Lock()
2643	switch level {
2644	case "error", "warn":
2645		h.iv.statusErr = message
2646		h.iv.statusOK = ""
2647	default:
2648		h.iv.statusOK = message
2649		h.iv.statusErr = ""
2650	}
2651	h.iv.mu.Unlock()
2652	h.iv.invalidate()
2653}
2654
2655// openSessionOpsDialog shows the picker for `/session` with no arg.
2656// Always offers export, import, fork, tree; the handlers bail with
2657// a clear status message when the precondition isn't met (empty
2658// transcript on fork; no parent/siblings on tree).
2659func (i *Interactive) openSessionOpsDialog() {
2660	items := []sessionOpsItem{
2661		{label: "export", action: "export", hint: "write the current session to a .zotsession file"},
2662		{label: "import", action: "import", hint: "load a .zotsession file into this directory"},
2663		{label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
2664		{label: "tree", action: "tree", hint: "switch between branches in this directory"},
2665	}
2666	i.sessionOpsDialog.Open(items)
2667	i.invalidate()
2668}
2669
2670// doSessionOp dispatches export, import, fork, or tree. arg is the
2671// optional positional argument from e.g. /session export <path>
2672// or /session import <path>; fork and tree ignore it.
2673func (i *Interactive) doSessionOp(action, arg string) {
2674	switch action {
2675	case "export":
2676		i.doSessionExport(arg)
2677	case "import":
2678		i.doSessionImport(arg)
2679	case "fork":
2680		i.doSessionFork()
2681	case "tree":
2682		i.doSessionTree()
2683	default:
2684		i.mu.Lock()
2685		i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
2686		i.mu.Unlock()
2687		i.invalidate()
2688	}
2689}
2690
2691// doSessionExport writes the live session file to destination path
2692// dst. When dst is empty we default to ~/Downloads (falling back to
2693// the user's home directory if it doesn't exist). The helper
2694// expands a leading `~` and creates any missing parent directories.
2695func (i *Interactive) doSessionExport(dst string) {
2696	if i.cfg.CurrentSessionPath == nil {
2697		i.mu.Lock()
2698		i.statusErr = "export: no session is active (running with --no-session?)"
2699		i.mu.Unlock()
2700		i.invalidate()
2701		return
2702	}
2703	src := i.cfg.CurrentSessionPath()
2704	if src == "" {
2705		i.mu.Lock()
2706		i.statusErr = "export: no session is active (running with --no-session?)"
2707		i.mu.Unlock()
2708		i.invalidate()
2709		return
2710	}
2711	// Persist any in-memory agent messages to the session file so
2712	// the export carries the full conversation. Without this, the
2713	// default lazy-flush-at-exit strategy leaves most of a running
2714	// session unwritten and the export ends up with only the meta.
2715	if i.cfg.FlushSession != nil {
2716		i.cfg.FlushSession()
2717	}
2718	dst = unquotePath(dst)
2719	if dst == "" {
2720		dst = defaultExportDir()
2721	} else {
2722		dst = expandTilde(dst)
2723	}
2724	out, err := core.ExportSession(src, dst)
2725	if err != nil {
2726		i.mu.Lock()
2727		i.statusErr = "export: " + err.Error()
2728		i.mu.Unlock()
2729		i.invalidate()
2730		return
2731	}
2732	i.mu.Lock()
2733	i.statusOK = "exported session to " + friendlyPath(out)
2734	i.statusErr = ""
2735	i.mu.Unlock()
2736	i.invalidate()
2737}
2738
2739// doSessionImport copies the .zotsession file at src into the
2740// running cwd's sessions directory and loads it as the active
2741// session, same as `/sessions` -> pick. When src is empty we ask
2742// the user to pass a path (no usable default here).
2743func (i *Interactive) doSessionImport(src string) {
2744	src = unquotePath(src)
2745	if src == "" {
2746		i.mu.Lock()
2747		i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
2748		i.mu.Unlock()
2749		i.invalidate()
2750		return
2751	}
2752	src = expandTilde(src)
2753	if _, err := os.Stat(src); err != nil {
2754		i.mu.Lock()
2755		i.statusErr = "import: " + err.Error()
2756		i.mu.Unlock()
2757		i.invalidate()
2758		return
2759	}
2760	newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version)
2761	if err != nil {
2762		i.mu.Lock()
2763		i.statusErr = "import: " + err.Error()
2764		i.mu.Unlock()
2765		i.invalidate()
2766		return
2767	}
2768	if i.cfg.LoadSession == nil {
2769		i.mu.Lock()
2770		i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
2771		i.statusErr = ""
2772		i.mu.Unlock()
2773		i.invalidate()
2774		return
2775	}
2776	if err := i.cfg.LoadSession(newPath); err != nil {
2777		i.mu.Lock()
2778		i.statusErr = "import: load failed: " + err.Error()
2779		i.mu.Unlock()
2780		i.invalidate()
2781		return
2782	}
2783	i.mu.Lock()
2784	i.statusOK = "imported and switched to session " + friendlyPath(newPath)
2785	i.statusErr = ""
2786	i.mu.Unlock()
2787	i.invalidate()
2788}
2789
2790// defaultExportDir returns ~/Downloads when it exists, or ~ as a
2791// fallback, or /tmp on exotic machines with no home dir.
2792func defaultExportDir() string {
2793	home, err := os.UserHomeDir()
2794	if err != nil || home == "" {
2795		return os.TempDir()
2796	}
2797	downloads := filepath.Join(home, "Downloads")
2798	if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
2799		return downloads
2800	}
2801	return home
2802}
2803
2804// expandTilde turns a leading ~ into the user's home directory.
2805// Returns the input unchanged when there's no tilde or no home.
2806func expandTilde(p string) string {
2807	if p == "" || p[0] != '~' {
2808		return p
2809	}
2810	home, err := os.UserHomeDir()
2811	if err != nil || home == "" {
2812		return p
2813	}
2814	if len(p) == 1 {
2815		return home
2816	}
2817	if p[1] == '/' || p[1] == filepath.Separator {
2818		return filepath.Join(home, p[2:])
2819	}
2820	return p
2821}
2822
2823// unquotePath strips a matching pair of surrounding single or
2824// double quotes. Drag-drop paste in the tui auto-quotes dropped
2825// file paths so the shell-like `/session import 'foo bar.zs'`
2826// stays well-formed; when the TUI's own slash handler consumes
2827// the arg, we want the raw path back.
2828func unquotePath(p string) string {
2829	p = strings.TrimSpace(p)
2830	if len(p) >= 2 {
2831		first := p[0]
2832		last := p[len(p)-1]
2833		if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
2834			p = p[1 : len(p)-1]
2835		}
2836	}
2837	return p
2838}
2839
2840// friendlyPath collapses the user's home directory to a leading ~
2841// so status messages read cleanly. Falls back to the raw path when
2842// the home dir is unknown.
2843func friendlyPath(p string) string {
2844	home, err := os.UserHomeDir()
2845	if err != nil || home == "" {
2846		return p
2847	}
2848	if strings.HasPrefix(p, home+string(filepath.Separator)) {
2849		return "~" + p[len(home):]
2850	}
2851	return p
2852}
2853
2854// doSessionFork opens the /jump turn picker in "fork mode". The
2855// next selection branches the current session at that user turn
2856// instead of scrolling the viewport to it.
2857func (i *Interactive) doSessionFork() {
2858	if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
2859		i.mu.Lock()
2860		i.statusErr = "fork: no session is active (running with --no-session?)"
2861		i.mu.Unlock()
2862		i.invalidate()
2863		return
2864	}
2865	msgs := []provider.Message{}
2866	if i.agent != nil {
2867		msgs = i.agent.Messages()
2868	}
2869	if len(msgs) == 0 {
2870		i.mu.Lock()
2871		i.statusErr = "fork: transcript is empty; nothing to fork from"
2872		i.mu.Unlock()
2873		i.invalidate()
2874		return
2875	}
2876	i.pendingFork = true
2877	i.jumpDialog.Open(msgs, "")
2878	i.invalidate()
2879}
2880
2881// doSessionTree shows the branch topology picker for this cwd.
2882// Pick an entry to switch into it (same semantics as /sessions
2883// picking a past session, but with the parent/child indentation).
2884func (i *Interactive) doSessionTree() {
2885	if i.cfg.ZotHome == "" || i.cfg.CWD == "" {
2886		i.mu.Lock()
2887		i.statusErr = "tree: session storage not configured"
2888		i.mu.Unlock()
2889		i.invalidate()
2890		return
2891	}
2892	// Flush the running agent's transcript first so its message
2893	// count + latest preview are accurate in the tree.
2894	if i.cfg.FlushSession != nil {
2895		i.cfg.FlushSession()
2896	}
2897	roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD)
2898	if len(roots) == 0 {
2899		i.mu.Lock()
2900		i.statusErr = "tree: no sessions in this directory yet"
2901		i.mu.Unlock()
2902		i.invalidate()
2903		return
2904	}
2905	current := ""
2906	if i.cfg.CurrentSessionPath != nil {
2907		current = i.cfg.CurrentSessionPath()
2908	}
2909	if !i.sessionTreeDialog.Open(roots, current) {
2910		i.mu.Lock()
2911		i.statusErr = "tree: no branches to show"
2912		i.mu.Unlock()
2913	}
2914	i.invalidate()
2915}
2916
2917// applySessionTreeSelection switches the running agent to the
2918// session file at path. Thin wrapper around LoadSession that also
2919// writes a status line.
2920func (i *Interactive) applySessionTreeSelection(path string) {
2921	if i.cfg.LoadSession == nil {
2922		i.mu.Lock()
2923		i.statusErr = "tree: session swap not available in this build"
2924		i.mu.Unlock()
2925		i.invalidate()
2926		return
2927	}
2928	if err := i.cfg.LoadSession(path); err != nil {
2929		i.mu.Lock()
2930		i.statusErr = "tree: load failed: " + err.Error()
2931		i.mu.Unlock()
2932		i.invalidate()
2933		return
2934	}
2935	i.mu.Lock()
2936	i.statusOK = "switched to branch " + friendlyPath(path)
2937	i.statusErr = ""
2938	i.mu.Unlock()
2939	i.invalidate()
2940}
2941
2942// applyForkSelection branches the current session at msgIdx+1 (so
2943// the selected user message and everything before it is included
2944// in the new branch), then switches the running agent to the new
2945// file. Called from the jump-dialog handler when pendingFork=true.
2946func (i *Interactive) applyForkSelection(msgIdx int) {
2947	i.pendingFork = false
2948	if i.cfg.CurrentSessionPath == nil {
2949		i.mu.Lock()
2950		i.statusErr = "fork: no session is active"
2951		i.mu.Unlock()
2952		i.invalidate()
2953		return
2954	}
2955	src := i.cfg.CurrentSessionPath()
2956	if src == "" {
2957		i.mu.Lock()
2958		i.statusErr = "fork: no session is active"
2959		i.mu.Unlock()
2960		i.invalidate()
2961		return
2962	}
2963	if i.cfg.FlushSession != nil {
2964		i.cfg.FlushSession()
2965	}
2966	// msgIdx is 0-indexed message position; copy msgIdx+1 rows so
2967	// the selected user message is included.
2968	upTo := msgIdx + 1
2969	newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
2970	if err != nil {
2971		i.mu.Lock()
2972		i.statusErr = "fork: " + err.Error()
2973		i.mu.Unlock()
2974		i.invalidate()
2975		return
2976	}
2977	if i.cfg.LoadSession == nil {
2978		i.mu.Lock()
2979		i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
2980		i.statusErr = ""
2981		i.mu.Unlock()
2982		i.invalidate()
2983		return
2984	}
2985	if err := i.cfg.LoadSession(newPath); err != nil {
2986		i.mu.Lock()
2987		i.statusErr = "fork: switch failed: " + err.Error()
2988		i.mu.Unlock()
2989		i.invalidate()
2990		return
2991	}
2992	i.mu.Lock()
2993	i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
2994	i.statusErr = ""
2995	i.mu.Unlock()
2996	i.invalidate()
2997}
2998
2999// formatInt is a tiny strconv.Itoa shim; keeps the handler above
3000// from needing a strconv import just for one call.
3001func formatInt(n int) string {
3002	return fmt.Sprintf("%d", n)
3003}
3004
3005// assistantText returns the concatenated text of every TextBlock in
3006// m. Used by the streaming-view dedupe guard to tell when a live
3007// streamed reply has already been promoted into the transcript.
3008func assistantText(m provider.Message) string {
3009	var sb strings.Builder
3010	for _, c := range m.Content {
3011		if tb, ok := c.(provider.TextBlock); ok {
3012			if sb.Len() > 0 {
3013				sb.WriteByte('\n')
3014			}
3015			sb.WriteString(tb.Text)
3016		}
3017	}
3018	return sb.String()
3019}
3020
3021// resetStreamingStateLocked clears every piece of streaming state
3022// in one shot. Used by abort paths (turn cancel, compact finish,
3023// queue drain) so the pacer doesn't keep draining stale runes from
3024// a prior turn. Must be called with i.mu held.
3025func (i *Interactive) resetStreamingStateLocked() {
3026	i.streaming.Reset()
3027	i.streamPending = i.streamPending[:0]
3028	i.streamFlushPending = false
3029	i.streamOn = false
3030}
3031
3032// assistantMessageSideEffects runs the non-visual hooks attached to
3033// EvAssistantMessage: the host-provided OnAssistant callback and the
3034// telegram-bridge mirror. Called with i.mu held.
3035//
3036// Factored out of handleEvent because the streaming pacer may defer
3037// visual reset until after the last buffered rune has painted, but
3038// the callbacks themselves must fire on message arrival so
3039// downstream observers (session persistence, telegram, cost panels)
3040// don't wait on a UI animation to catch up.
3041func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
3042	if i.cfg.OnAssistant != nil {
3043		i.cfg.OnAssistant(m)
3044	}
3045	if i.telegramBridge != nil && i.telegramBridge.Active() {
3046		var sb strings.Builder
3047		for _, c := range m.Content {
3048			if tb, ok := c.(provider.TextBlock); ok {
3049				if sb.Len() > 0 {
3050					sb.WriteString("\n")
3051				}
3052				sb.WriteString(tb.Text)
3053			}
3054		}
3055		if text := sb.String(); strings.TrimSpace(text) != "" {
3056			go i.telegramBridge.OnAssistantText(text)
3057		}
3058	}
3059}
3060
3061// paintPaceRate is how many runes the streaming pacer releases per
3062// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
3063// that a 500-rune summary finishes in ~1.3s, slow enough to look
3064// like a human typing. Empirically matches the feel of provider
3065// paths that already drip-stream natively.
3066const paintPaceRate = 6
3067
3068// paintPaceInterval is the tick interval for the streaming pacer.
3069// 16ms lines up with the redraw throttle so we never paint faster
3070// than the terminal can keep up.
3071const paintPaceInterval = 16 * time.Millisecond
3072
3073// runStreamPacer drains buffered deltas from streamPending into
3074// streaming a small batch per tick, invalidating after each move.
3075// It stops when the context cancels (tui shutdown).
3076//
3077// Why a pacer: providers differ wildly in how they chunk their
3078// text_delta events. The API-key path on Anthropic emits ~30 drips
3079// for a 400-token summary; the OAuth path can coalesce the same
3080// summary into 3 fat chunks, visually indistinguishable from "the
3081// whole reply just appeared". The pacer normalizes that so every
3082// path looks the same on screen.
3083func (i *Interactive) runStreamPacer(ctx context.Context) {
3084	t := time.NewTicker(paintPaceInterval)
3085	defer t.Stop()
3086	for {
3087		select {
3088		case <-ctx.Done():
3089			return
3090		case <-t.C:
3091			i.mu.Lock()
3092			if len(i.streamPending) == 0 {
3093				// EvAssistantMessage already fired but the pacer
3094				// was still draining a tick ago. Everything is now
3095				// painted; clear the streaming flags so the next
3096				// redraw shows the finalised transcript message
3097				// and hides the streaming overlay.
3098				if i.streamFlushPending {
3099					i.streamFlushPending = false
3100					i.streaming.Reset()
3101					i.streamOn = false
3102					i.mu.Unlock()
3103					i.invalidate()
3104					continue
3105				}
3106				i.mu.Unlock()
3107				continue
3108			}
3109			n := paintPaceRate
3110			if n > len(i.streamPending) {
3111				n = len(i.streamPending)
3112			}
3113			i.streaming.WriteString(string(i.streamPending[:n]))
3114			i.streamPending = i.streamPending[n:]
3115			i.mu.Unlock()
3116			i.invalidate()
3117		}
3118	}
3119}

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