v0.0.51 c610a3a

interactive.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"strings"
8	"sync"
9	"time"
10
11	"github.com/patriceckhart/zot/internal/agent/extensions"
12	"github.com/patriceckhart/zot/internal/agent/tools"
13	"github.com/patriceckhart/zot/internal/auth"
14	"github.com/patriceckhart/zot/internal/core"
15	"github.com/patriceckhart/zot/internal/provider"
16	"github.com/patriceckhart/zot/internal/skills"
17	"github.com/patriceckhart/zot/internal/tui"
18)
19
20// InteractiveConfig configures the interactive loop.
21type InteractiveConfig struct {
22	Terminal     tui.Terminal
23	Theme        tui.Theme
24	Model        string
25	Provider     string
26	AuthMethod   string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
27	BaseURL      string
28	Reasoning    string
29	SystemPrompt string
30	Tools        core.Registry
31	MaxSteps     int
32	CWD          string
33
34	// Agent is optional. If nil, zot opens without credentials; the
35	// user must /login before they can prompt.
36	Agent *core.Agent
37
38	InitialInput string
39
40	// Auth is required. When the user runs /login, Interactive talks to
41	// AuthManager to open a browser and wait for the callback.
42	AuthManager *auth.Manager
43	// BuildAgent is called after a successful login to (re)construct the
44	// agent with the fresh credential. It returns the new agent and
45	// the concrete provider/model in use.
46	BuildAgent func() (*core.Agent, string, string, error)
47
48	// BuildAgentFor rebuilds the agent with an explicit provider/model
49	// override (used by the /model picker when switching providers).
50	// If providerOverride is empty, the current provider is kept.
51	BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
52
53	// ZotHome is the root directory for sessions/, used by /sessions
54	// and the update-check cache.
55	ZotHome string
56
57	// Version is the binary's current version (from main.version).
58	// Used only for display; the update check itself is done outside
59	// this package to avoid an import cycle.
60	Version string
61
62	// UpdateInfoChan is an optional channel that delivers the result
63	// of the github-release update check. Interactive reads at most
64	// one value, drops it if the check reported nothing, and otherwise
65	// surfaces a yellow "update available" banner at the top of the
66	// chat. Nil channel = no banner, no startup cost.
67	UpdateInfoChan <-chan UpdateInfo
68
69	// Sandbox is the shared sandbox pointer. Toggled by /lock and /unlock.
70	Sandbox *tools.Sandbox
71
72	// LoadSession swaps the current session for the one at path. The
73	// callback returns the new agent message slice so the TUI can invalidate.
74	LoadSession func(path string) error
75
76	// PersistModel is called whenever the user switches model or provider.
77	// It should update config.json and (if there's an active session)
78	// write a new meta row so resume picks up the same model.
79	PersistModel func(providerName, model string)
80
81	OnAssistant  func(m provider.Message)
82	OnToolResult func(id string, r core.ToolResult)
83
84	// Extensions, if non-nil, lets users invoke extension-registered
85	// slash commands. Commands declared by extensions are looked up
86	// AFTER the built-in catalog so a built-in name always wins.
87	Extensions *extensions.Manager
88
89	// SkillSnapshot, if non-nil, returns the current list of
90	// discovered SKILL.md files. Re-invoked each time /skills opens
91	// so the picker reflects edits made during the session.
92	SkillSnapshot func() []*skills.Skill
93
94	// ChangelogChan, if non-nil, delivers release-notes for the
95	// current binary version once at startup. Interactive opens a
96	// dismissible overlay when the channel produces a non-empty
97	// body. Receiver fires at most once.
98	ChangelogChan <-chan ChangelogPayload
99
100	// OnChangelogDismiss, if non-nil, is called once the user
101	// closes the changelog overlay. The cli wires this to a
102	// MarkChangelogShown call so the same version doesn't show
103	// again on the next launch.
104	OnChangelogDismiss func()
105
106	// NoYolo is true when --no-yolo was passed. Interactive opens
107	// a confirmation dialog before every tool call and blocks the
108	// tool until the user picks yes / always-this-tool /
109	// always-all / no. When false (default), tools run freely.
110	NoYolo bool
111
112	// ConfirmGate is the session-scoped gate wrapping this
113	// interactive's Confirmer. When non-nil, /yolo can call
114	// AllowAll() on it to disable confirmation for the rest of the
115	// session. When nil (yolo mode), /yolo reports that there's
116	// nothing to disable.
117	ConfirmGate *core.ConfirmGate
118}
119
120// ChangelogPayload mirrors agent.ChangelogInfo without the import
121// cycle. The cli builds one from the http response, the tui opens
122// the overlay when one arrives.
123type ChangelogPayload struct {
124	Version string
125	Body    string
126	URL     string
127}
128
129// Interactive is the TUI chat loop.
130type Interactive struct {
131	cfg  InteractiveConfig
132	view *tui.View
133	ed   *tui.Editor
134	rend *tui.Renderer
135
136	mu           sync.Mutex
137	agent        *core.Agent
138	streaming    strings.Builder
139	streamOn     bool
140	toolCalls    map[string]*tui.ToolCallView
141	toolOrder    []string
142	statusErr    string
143	statusOK     string
144	helpBlock    []string // rendered above the chat when /help was typed
145	cumUsage     provider.Usage
146	lastCtxInput int // input_tokens of the most recent turn — approximates current context size
147	busy         bool
148	dirty        chan struct{}
149	cancelTurn   context.CancelFunc
150	scrollOffset int // rows from the bottom; 0 = pinned to latest
151
152	// Messages typed while a turn is in flight. Each is delivered as
153	// its own follow-up turn once the current one finishes. Rendered
154	// above the status bar as "sliding in: ..." chips.
155	queued []string
156
157	// runCtx is the top-level context passed to Run(). Follow-up turns
158	// drained from `queued` are started against this context so they
159	// survive past the ctx of the key event that enqueued them.
160	runCtx context.Context
161
162	// autoCompacting is true while a model-triggered compaction is in
163	// flight. Surfaced in the status bar so the user can tell a
164	// condense pass from a regular assistant turn.
165	autoCompacting bool
166
167	// updateInfo is the result of the async update check. Zero value
168	// while the check hasn't completed or nothing is available.
169	updateInfo UpdateInfo
170
171	dialog          *loginDialog
172	modelDialog     *modelDialog
173	sessionDialog   *sessionDialog
174	jumpDialog      *jumpDialog
175	btwDialog       *btwDialog
176	skillsDialog    *skillsDialog
177	changelogDialog *changelogDialog
178	confirmDialog   *confirmDialog
179	logoutDialog    *logoutDialog
180	suggest         *slashSuggester
181	spin            *spinner
182
183	// parkedTurn is the 1-based turn number the viewport is currently
184	// scrolled to by /jump. 0 = not parked, showing the tail as usual.
185	// Rendered as a muted footer at the bottom of the chat so users
186	// don't forget they're looking at history.
187	parkedTurn  int
188	parkedTotal int
189
190	// lastCtrlC is when the user last pressed ctrl+c. The first press
191	// clears the editor / cancels a turn / shows a hint; a second press
192	// within ctrlCExitWindow exits. Mirrors the python-repl convention.
193	lastCtrlC time.Time
194
195	// welcomeStart is when the interactive run began. The welcome
196	// banner shows the binary version for welcomeVersionDuration
197	// after this point and reverts to plain text after.
198	welcomeStart time.Time
199
200	// extNotes are one-shot styled lines pushed by extensions via
201	// Notify / Display. They live above the editor (just below the
202	// transcript) until cleared by /clear or another reset.
203	extNotes []string
204}
205
206// welcomeVersionDuration is how long the welcome banner shows the
207// version suffix before reverting to the plain headline. 1.5s is
208// enough to read at a glance and keeps the splash short.
209const welcomeVersionDuration = 1500 * time.Millisecond
210
211// NewInteractive constructs an Interactive from cfg.
212func NewInteractive(cfg InteractiveConfig) *Interactive {
213	i := &Interactive{
214		cfg: cfg,
215		view: &tui.View{
216			Theme:      cfg.Theme,
217			ImageProto: tui.DetectImageProtocol(),
218		},
219		ed:              tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
220		rend:            tui.NewRenderer(cfg.Terminal),
221		toolCalls:       map[string]*tui.ToolCallView{},
222		dirty:           make(chan struct{}, 8),
223		dialog:          newLoginDialog(),
224		modelDialog:     newModelDialog(),
225		sessionDialog:   newSessionDialog(),
226		jumpDialog:      newJumpDialog(),
227		btwDialog:       newBtwDialog(),
228		skillsDialog:    newSkillsDialog(),
229		changelogDialog: newChangelogDialog(),
230		confirmDialog:   newConfirmDialog(),
231		logoutDialog:    newLogoutDialog(),
232		suggest:         newSlashSuggester(),
233		spin:            newSpinner(),
234	}
235	if cfg.Agent != nil {
236		i.agent = cfg.Agent
237		i.view.Messages = cfg.Agent.Messages()
238	}
239	return i
240}
241
242// Run blocks until the user quits.
243func (i *Interactive) Run(ctx context.Context) error {
244	i.runCtx = ctx
245	term := i.cfg.Terminal
246	restore, err := term.EnterRaw()
247	if err != nil {
248		return err
249	}
250	defer restore()
251
252	_, _ = term.Write([]byte(tui.SeqBracketedPasteOn))
253	_, _ = term.Write([]byte(tui.SeqAltScreenOn))
254	defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
255
256	cols, rows := term.Size()
257	i.rend.Resize(cols, rows)
258	term.OnResize(func() {
259		c, r := term.Size()
260		i.rend.Resize(c, r)
261		// Force an immediate redraw on resize. The throttled invalidate
262		// path is fine for animation, but a window resize is a discrete
263		// user action where any visible delay (or stale frame) reads as
264		// brokenness. redraw() is mutex-safe; the worst that happens is
265		// a duplicate paint if the throttler is mid-flight, which is
266		// invisible.
267		i.redraw()
268	})
269
270	if i.cfg.InitialInput != "" {
271		i.ed.SetValue(i.cfg.InitialInput)
272	}
273
274	// Stamp the welcome time and schedule a one-shot redraw at the
275	// expiry so the version suffix disappears on its own even if the
276	// user hasn't typed anything yet.
277	i.welcomeStart = time.Now()
278	time.AfterFunc(welcomeVersionDuration, i.invalidate)
279
280	// If the agent was constructed with a pre-loaded transcript
281	// (--continue, --resume, --session) park the viewport on the
282	// most recent turn so the user lands looking at where the
283	// previous session left off rather than at the bottom of an
284	// already-rendered final reply.
285	if i.agent != nil {
286		if msgs := i.agent.Messages(); len(msgs) > 0 {
287			i.scrollToLastTurn(msgs)
288		}
289	}
290
291	// No credential at startup? Auto-open the login dialog, and mark
292	// the status line. The user can Esc out of the dialog if they
293	// want to dismiss it (e.g. to check /help or /exit first).
294	if i.agent == nil {
295		i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
296		i.dialog.Open()
297	}
298
299	// Input goroutine.
300	keys := make(chan tui.Key, 8)
301	go func() {
302		reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
303		for {
304			k, err := reader.Read()
305			if err != nil {
306				return
307			}
308			keys <- k
309		}
310	}()
311
312	// Subscribe to auth events.
313	var authEvents <-chan auth.Event
314	if i.cfg.AuthManager != nil {
315		authEvents = i.cfg.AuthManager.Events()
316	}
317
318	// Animation ticker: drives spinner and dialog-related redraws when
319	// nothing else changed. 120ms is slow enough that highlighting a huge
320	// transcript doesn't spin the cpu.
321	tick := time.NewTicker(120 * time.Millisecond)
322	defer tick.Stop()
323
324	// Redraw throttle: coalesce bursts of invalidate() calls so we paint
325	// at most once every redrawMinInterval. Huge tool-result dumps can
326	// fire hundreds of invalidations while the user is typing; without
327	// this, the input goroutine never gets CPU and keystrokes lag.
328	const redrawMinInterval = 16 * time.Millisecond
329	var lastRedraw time.Time
330	var pendingRedraw bool
331	var pendingTimer *time.Timer
332
333	drainPending := func() {
334		if pendingTimer != nil {
335			pendingTimer.Stop()
336			pendingTimer = nil
337		}
338		if pendingRedraw {
339			pendingRedraw = false
340			lastRedraw = time.Now()
341			i.redraw()
342		}
343	}
344
345	requestRedraw := func() {
346		since := time.Since(lastRedraw)
347		if since >= redrawMinInterval {
348			// Redrawing right now subsumes any pending redraw, so clear
349			// the throttle state. Without this, a pending flag stays
350			// stuck at true and subsequent invalidate() calls within
351			// redrawMinInterval get dropped — which is exactly how the
352			// final "turn finished" frame went missing until the user
353			// nudged the ui by typing or scrolling.
354			if pendingTimer != nil {
355				pendingTimer.Stop()
356			}
357			pendingRedraw = false
358			lastRedraw = time.Now()
359			i.redraw()
360			return
361		}
362		if pendingRedraw {
363			return // already scheduled
364		}
365		pendingRedraw = true
366		wait := redrawMinInterval - since
367		if pendingTimer == nil {
368			pendingTimer = time.AfterFunc(wait, func() {
369				// Poke the dirty channel so the main loop wakes and
370				// drains the pending redraw on its own goroutine. We
371				// can't call drainPending here directly — it touches
372				// closure state shared with the main loop.
373				i.invalidate()
374			})
375		} else {
376			pendingTimer.Reset(wait)
377		}
378	}
379
380	i.invalidate()
381
382	updates := i.cfg.UpdateInfoChan  // nil-safe; nil channel blocks forever in select
383	changelog := i.cfg.ChangelogChan // single-shot, see case below
384
385	for {
386		select {
387		case <-ctx.Done():
388			return ctx.Err()
389		case k := <-keys:
390			if done := i.handleKey(ctx, k); done {
391				return nil
392			}
393			i.invalidate()
394		case ev := <-authEvents:
395			i.handleAuthEvent(ev)
396			i.invalidate()
397		case info, ok := <-updates:
398			if ok && info.Available {
399				i.mu.Lock()
400				i.updateInfo = info
401				i.mu.Unlock()
402				i.invalidate()
403			}
404			updates = nil // single-shot; subsequent iterations skip this case
405		case cl, ok := <-changelog:
406			if ok && cl.Body != "" {
407				i.changelogDialog.Open(cl.Version, cl.URL, cl.Body)
408				i.invalidate()
409			}
410			changelog = nil // single-shot
411		case <-i.dirty:
412			requestRedraw()
413		case <-tick.C:
414			// Always drain a pending redraw on the tick. This is the
415			// safety net that catches the case where the dirty channel
416			// was saturated when the final "turn finished" invalidate
417			// fired, or where the throttle scheduled a deferred redraw
418			// and the AfterFunc-driven invalidate got dropped on a
419			// full channel.
420			drainPending()
421			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() {
422				requestRedraw() // keep the spinner / dialog animation moving
423			}
424		}
425	}
426}
427
428func (i *Interactive) invalidate() {
429	select {
430	case i.dirty <- struct{}{}:
431	default:
432	}
433}
434
435// lastCols returns the current terminal width in columns.
436func (i *Interactive) lastCols() int {
437	cols, _ := i.cfg.Terminal.Size()
438	return cols
439}
440
441// chatPage returns the number of chat rows currently visible, used
442// as the page size for PageUp/PageDown.
443func (i *Interactive) chatPage() int {
444	_, rows := i.cfg.Terminal.Size()
445	p := rows - 6 // rough reservation for status + editor + a dialog line
446	if p < 4 {
447		p = 4
448	}
449	return p
450}
451
452// scrollBy adjusts the scroll offset. Positive = up (into history).
453// Clearing the parked-turn label when we're back at the bottom means
454// the "viewing turn N" footer goes away automatically as soon as you
455// scroll back to the live tail.
456func (i *Interactive) scrollBy(delta int) {
457	i.mu.Lock()
458	i.scrollOffset += delta
459	if i.scrollOffset < 0 {
460		i.scrollOffset = 0
461	}
462	if i.scrollOffset == 0 {
463		i.parkedTurn = 0
464		i.parkedTotal = 0
465	}
466	i.mu.Unlock()
467	i.invalidate()
468}
469
470// scrollToBottom pins the view to the latest content.
471func (i *Interactive) scrollToBottom() {
472	i.mu.Lock()
473	i.scrollOffset = 0
474	i.parkedTurn = 0
475	i.parkedTotal = 0
476	i.mu.Unlock()
477	i.invalidate()
478}
479
480func (i *Interactive) redraw() {
481	i.mu.Lock()
482	defer i.mu.Unlock()
483
484	cols, _ := i.cfg.Terminal.Size()
485	if i.agent != nil {
486		i.view.Messages = i.agent.Messages()
487	} else {
488		i.view.Messages = nil
489	}
490	i.view.Streaming = i.streaming.String()
491	i.view.StreamingActive = i.streamOn
492	// Belt and suspenders: if the transcript's last message is already an
493	// assistant message, the streaming view is stale (EvAssistantMessage may
494	// not have fired yet) — suppress it to avoid double-rendering the reply.
495	if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
496		i.view.StreamingActive = false
497	}
498	// Live tool-call view: only shown while a turn is in flight. Once
499	// the agent is idle, every tool call has already been folded into
500	// the transcript (as assistant.ToolCallBlock + a tool-role message),
501	// so showing v.ToolCalls a second time would duplicate them below
502	// the final assistant text — which looks like the summary came
503	// "before" the tools.
504	i.view.ToolCalls = i.view.ToolCalls[:0]
505	if i.busy {
506		for _, id := range i.toolOrder {
507			if tc, ok := i.toolCalls[id]; ok {
508				i.view.ToolCalls = append(i.view.ToolCalls, *tc)
509			}
510		}
511	}
512	i.view.Err = i.statusErr
513
514	chat := i.view.Build(cols)
515
516	// Welcome banner: shown at the top of the chat area when there is
517	// no transcript yet. Disappears after the first message is sent.
518	// The version suffix is shown for welcomeVersionDuration after
519	// startup, then drops off automatically.
520	if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
521		showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
522		chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
523	}
524
525	// Update-available banner: prepended above everything else so it's
526	// the first thing the user sees when opening a new zot session.
527	// Once rendered, it stays until the user updates to a newer
528	// version — we don't persist a "dismissed" flag because this is
529	// cheap and re-showing it is how most users remember to update.
530	if i.updateInfo.Available {
531		banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
532		chat = append(banner, chat...)
533	}
534
535	// /help block: appended to the transcript so it appears at the
536	// bottom of the chat area (right above the status bar / editor).
537	// Prepending it would push long conversations off the top of the
538	// viewport, which users would miss entirely.
539	if len(i.helpBlock) > 0 {
540		chat = append(chat, i.helpBlock...)
541	}
542
543	if i.statusOK != "" {
544		// Hard-truncate the OK line to the visible width so a long
545		// session path ("resumed session: /Users/.../sessions/...")
546		// doesn't overflow past the right edge and look broken on a
547		// narrow terminal.
548		line := "✓ " + i.statusOK
549		if cols > 4 && len(line) > cols {
550			line = line[:cols-1] + "…"
551		}
552		chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
553	}
554
555	// Extension notes (notify / display) live just under the
556	// transcript, above the dialog/editor band. Cleared by /clear.
557	if len(i.extNotes) > 0 {
558		chat = append(chat, i.extNotes...)
559		chat = append(chat, "")
560	}
561
562	// Dialogs (login or model picker) render between chat and the editor.
563	var dialog []string
564	switch {
565	case i.dialog.Active():
566		dialog = i.dialog.Render(i.cfg.Theme, cols)
567	case i.modelDialog.Active():
568		dialog = i.modelDialog.Render(i.cfg.Theme, cols)
569	case i.sessionDialog.Active():
570		dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
571	case i.jumpDialog.Active():
572		dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
573	case i.btwDialog.Active():
574		dialog = i.btwDialog.Render(i.cfg.Theme, cols)
575	case i.skillsDialog.Active():
576		dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
577	case i.changelogDialog.Active():
578		dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
579	case i.confirmDialog.Active():
580		dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
581	case i.logoutDialog.Active():
582		dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
583	}
584
585	// Slash-command autocomplete: popup above the status line, only
586	// when the editor starts with "/" and no dialog is already open.
587	// Feed extension-registered commands into the suggester first so
588	// they show up in tab-complete + the popup alongside the built-ins.
589	if i.cfg.Extensions != nil {
590		catalog := i.cfg.Extensions.Commands()
591		extra := make([]slashCommand, 0, len(catalog))
592		for _, c := range catalog {
593			// The popup renders extension commands under a dedicated
594			// "── extensions ───" divider, so the description doesn't
595			// need to repeat the source. If the description is empty,
596			// fall back to the extension name so the row isn't blank.
597			desc := c.Description
598			if strings.TrimSpace(desc) == "" {
599				desc = "(" + c.Extension + ")"
600			}
601			extra = append(extra, slashCommand{
602				Name: "/" + c.Name,
603				Desc: desc,
604			})
605		}
606		i.suggest.SetExtra(extra)
607	}
608	var suggest []string
609	currentInput := i.ed.Value()
610	if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy {
611		suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
612	}
613
614	// Busy prefix shown at the far left of the status bar. The
615	// spinner glyph and its funny-line message share the `zot`
616	// label colour (Theme.Assistant) so the whole "who's working"
617	// band reads at a glance. Elapsed time stays muted because it
618	// drifts every second and shouldn't grab focus.
619	var busyPrefix string
620	if i.busy {
621		busyPrefix = fmt.Sprintf("%s %s · %s",
622			i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()),
623			i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()),
624			i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
625		)
626	}
627
628	ctxMax := 0
629	if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
630		ctxMax = m.ContextWindow
631	}
632	statusLines := tui.StatusBar(tui.StatusBarParams{
633		Theme:          i.cfg.Theme,
634		Provider:       i.cfg.Provider,
635		Model:          i.cfg.Model,
636		Busy:           i.busy,
637		BusyPrefix:     busyPrefix,
638		CWD:            i.cfg.CWD,
639		Locked:         i.cfg.Sandbox.Locked(),
640		Usage:          i.cumUsage,
641		Subscription:   i.cfg.AuthMethod == "oauth",
642		ContextUsed:    i.lastCtxInput,
643		ContextMax:     ctxMax,
644		AutoCompacting: i.autoCompacting,
645		Cols:           cols,
646	})
647	edLines, curR, curC := i.ed.Render(cols)
648
649	// "Sliding in" chips for messages the user typed while a turn is
650	// in flight. Shown directly above the status bar so they're close
651	// to the editor but don't push the chat around.
652	var queue []string
653	if len(i.queued) > 0 {
654		for _, q := range i.queued {
655			label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")
656			text := truncateLine(q, cols-15)
657			queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
658		}
659	}
660
661	// Bottom-sticky sections (always visible, never scroll).
662	bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)
663	bottom = append(bottom, dialog...)
664	bottom = append(bottom, suggest...)
665	bottom = append(bottom, queue...)
666	bottom = append(bottom, statusLines...)
667	bottom = append(bottom, edLines...)
668
669	_, rows := i.cfg.Terminal.Size()
670	chatRows := rows - len(bottom)
671	if chatRows < 1 {
672		chatRows = 1
673	}
674
675	// Apply scroll offset to the chat slice.
676	maxOffset := len(chat) - chatRows
677	if maxOffset < 0 {
678		maxOffset = 0
679	}
680	if i.scrollOffset > maxOffset {
681		i.scrollOffset = maxOffset
682	}
683	if i.scrollOffset < 0 {
684		i.scrollOffset = 0
685	}
686
687	var visibleChat []string
688	if len(chat) <= chatRows {
689		visibleChat = chat
690	} else {
691		end := len(chat) - i.scrollOffset
692		start := end - chatRows
693		if start < 0 {
694			start = 0
695		}
696		visibleChat = chat[start:end]
697	}
698
699	// A tiny "scrolled up" indicator in the top-right of the chat pane
700	// so you know you're not at the bottom. When the viewport was
701	// parked by /jump we include the turn number so the user remembers
702	// they're reading history rather than the live conversation.
703	if i.scrollOffset > 0 && len(visibleChat) > 0 {
704		var text string
705		if i.parkedTurn > 0 && i.parkedTotal > 0 {
706			text = fmt.Sprintf("  ↑ viewing turn %d of %d · %d lines more below (pgdn / end)",
707				i.parkedTurn, i.parkedTotal, i.scrollOffset)
708		} else {
709			text = fmt.Sprintf("  ↑ %d lines more below (end to jump)", i.scrollOffset)
710		}
711		note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
712		visibleChat = append([]string{note}, visibleChat...)
713		if len(visibleChat) > chatRows {
714			visibleChat = visibleChat[:chatRows]
715		}
716	}
717
718	frame := make([]string, 0, len(visibleChat)+len(bottom))
719	frame = append(frame, visibleChat...)
720	frame = append(frame, bottom...)
721
722	cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR
723	cursorCol := curC
724	i.rend.Draw(frame, cursorRow, cursorCol)
725}
726
727// truncateLine shortens s so it fits within n display cells, with an
728// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
729// novel doesn't blow past the status line.
730func truncateLine(s string, n int) string {
731	if n <= 0 {
732		return ""
733	}
734	// Collapse newlines — chips are single line.
735	s = strings.ReplaceAll(s, "\n", " ↩ ")
736	runes := []rune(s)
737	if len(runes) <= n {
738		return s
739	}
740	if n <= 1 {
741		return "…"
742	}
743	return string(runes[:n-1]) + "…"
744}
745
746// ctrlCExitWindow is how long after a ctrl+c press a *second* press
747// will exit instead of just clearing input. Long enough to be
748// deliberate (rules out accidental key chord), short enough that the
749// hint stays meaningful.
750const ctrlCExitWindow = 2 * time.Second
751
752// armCtrlCExit records the timestamp of the current ctrl+c so the next
753// one within ctrlCExitWindow exits.
754func (i *Interactive) armCtrlCExit() {
755	i.mu.Lock()
756	i.lastCtrlC = time.Now()
757	i.mu.Unlock()
758}
759
760// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
761// that another press should now exit.
762func (i *Interactive) ctrlCExitArmed() bool {
763	i.mu.Lock()
764	t := i.lastCtrlC
765	i.mu.Unlock()
766	return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
767}
768
769func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
770	// Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
771	// pressing ctrl+c then typing then ctrl+c much later doesn't quit
772	// unexpectedly. The hint message also goes stale; clear it.
773	if k.Kind != tui.KeyCtrlC {
774		i.mu.Lock()
775		if !i.lastCtrlC.IsZero() {
776			i.lastCtrlC = time.Time{}
777			if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
778				i.statusOK = ""
779			}
780		}
781		i.mu.Unlock()
782	}
783
784	// Dialogs consume keys while open (except ctrl+c, which always closes them).
785
786	// Confirm dialog has highest priority: the agent goroutine is
787	// blocked waiting for an answer, so we must not let keys leak
788	// anywhere else while it's up.
789	if i.confirmDialog.Active() {
790		i.confirmDialog.HandleKey(k)
791		i.invalidate()
792		return false
793	}
794	if i.dialog.Active() {
795		if k.Kind == tui.KeyCtrlC {
796			i.dialog.Close()
797			if i.cfg.AuthManager != nil {
798				i.cfg.AuthManager.CancelOAuth()
799			}
800			return false
801		}
802		act := i.dialog.HandleKey(k)
803		if act.StartAPIKey {
804			i.startAPIKeyFlow(act.Provider)
805		}
806		if act.StartOAuth {
807			i.startOAuthFlow(act.Provider)
808		}
809		return false
810	}
811	if i.modelDialog.Active() {
812		if k.Kind == tui.KeyCtrlC {
813			i.modelDialog.Close()
814			return false
815		}
816		act := i.modelDialog.HandleKey(k)
817		if act.Select {
818			i.applyModelSelection(act.Provider, act.Model)
819		}
820		return false
821	}
822	if i.sessionDialog.Active() {
823		if k.Kind == tui.KeyCtrlC {
824			i.sessionDialog.Close()
825			return false
826		}
827		act := i.sessionDialog.HandleKey(k)
828		if act.Select {
829			i.applySessionSelection(act.Path)
830		}
831		return false
832	}
833	if i.logoutDialog.Active() {
834		if k.Kind == tui.KeyCtrlC {
835			i.logoutDialog.Close()
836			i.invalidate()
837			return false
838		}
839		act := i.logoutDialog.HandleKey(k)
840		if act.Select {
841			i.doLogout(act.Target)
842		}
843		i.invalidate()
844		return false
845	}
846	if i.jumpDialog.Active() {
847		if k.Kind == tui.KeyCtrlC {
848			i.jumpDialog.Close()
849			return false
850		}
851		act := i.jumpDialog.HandleKey(k)
852		if act.Select {
853			i.applyJumpSelection(act.MessageIdx, act.TurnNo)
854		}
855		return false
856	}
857	if i.btwDialog.Active() {
858		if k.Kind == tui.KeyCtrlC {
859			i.btwDialog.Close()
860			i.invalidate()
861			return false
862		}
863		i.btwDialog.HandleKey(k, i.invalidate)
864		return false
865	}
866	if i.skillsDialog.Active() {
867		if k.Kind == tui.KeyCtrlC {
868			i.skillsDialog.Close()
869			i.invalidate()
870			return false
871		}
872		i.skillsDialog.HandleKey(k)
873		i.invalidate()
874		return false
875	}
876	if i.changelogDialog.Active() {
877		if closed := i.changelogDialog.HandleKey(k); closed {
878			// User dismissed; let the parent persist the
879			// LastChangelogShown marker via the close callback.
880			if i.cfg.OnChangelogDismiss != nil {
881				i.cfg.OnChangelogDismiss()
882			}
883		}
884		i.invalidate()
885		return false
886	}
887
888	// Global keys.
889	switch k.Kind {
890	case tui.KeyCtrlC:
891		// While busy: cancel the active turn (same as esc). The exit
892		// hint stays armed so a quick second ctrl+c after the turn
893		// dies still exits, matching habits from other repls.
894		if i.busy && i.cancelTurn != nil {
895			i.cancelTurn()
896			i.armCtrlCExit()
897			return false
898		}
899		// Idle: first press clears the editor (and any queued
900		// follow-up messages); a second press within ctrlCExitWindow
901		// exits. With both an empty editor and no queue the first
902		// press still just arms — require a deliberate double-tap.
903		hadInput := !i.ed.IsEmpty() || len(i.queued) > 0
904		if hadInput {
905			i.ed.Clear()
906			i.suggest.Reset()
907			i.mu.Lock()
908			i.queued = nil
909			i.statusOK = "input cleared"
910			i.statusErr = ""
911			i.mu.Unlock()
912			i.armCtrlCExit()
913			return false
914		}
915		if i.ctrlCExitArmed() {
916			return true
917		}
918		i.mu.Lock()
919		i.statusOK = "press ctrl+c again to exit"
920		i.statusErr = ""
921		i.mu.Unlock()
922		i.armCtrlCExit()
923		return false
924	case tui.KeyEsc:
925		// Esc interrupts a running turn. When idle, fall through so the
926		// editor can clear itself.
927		if i.busy && i.cancelTurn != nil {
928			i.cancelTurn()
929			// If a confirm dialog is pending, refuse it so the agent
930			// goroutine unblocks and the context cancellation can
931			// actually take effect.
932			i.confirmDialog.CancelAll("turn cancelled")
933			return false
934		}
935	case tui.KeyCtrlD:
936		if i.ed.IsEmpty() && !i.busy {
937			return true
938		}
939	case tui.KeyCtrlL:
940		i.rend.Clear()
941		i.invalidate()
942		return false
943	case tui.KeyCtrlO:
944		// Toggle expansion of collapsed tool results. Affects every tool
945		// call in the transcript — press again to re-collapse.
946		i.mu.Lock()
947		i.view.ExpandAll = !i.view.ExpandAll
948		i.mu.Unlock()
949		i.invalidate()
950		return false
951	case tui.KeyPageUp:
952		i.scrollBy(+i.chatPage())
953		return false
954	case tui.KeyPageDown:
955		i.scrollBy(-i.chatPage())
956		return false
957	case tui.KeyUp:
958		// Wheel-up in alt screen sends Up arrows on most terminals.
959		// When the editor is empty we use up/down for chat scroll —
960		// independently of whether the agent is busy, so users can
961		// scroll back through long streaming replies while they run.
962		if i.ed.IsEmpty() {
963			i.scrollBy(+3)
964			return false
965		}
966	case tui.KeyDown:
967		if i.ed.IsEmpty() {
968			if i.scrollOffset > 0 {
969				i.scrollBy(-3)
970			}
971			return false
972		}
973	}
974
975	// Note: we intentionally do NOT gate the editor on i.busy here.
976	// Typing while the agent is working is supported — submitted
977	// messages are queued and delivered as follow-up turns when the
978	// current turn ends. See the submit handler below.
979
980	if k.Kind == tui.KeyEnter && k.Alt {
981		i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
982		return false
983	}
984
985	// Slash suggestions: intercept up/down/tab/enter when the popup is visible.
986	if i.suggest.Active(i.ed.Value()) {
987		switch k.Kind {
988		case tui.KeyUp:
989			i.suggest.Up()
990			return false
991		case tui.KeyDown:
992			i.suggest.Down()
993			return false
994		case tui.KeyTab:
995			if name := i.suggest.Selection(i.ed.Value()); name != "" {
996				i.ed.SetValue(name)
997				i.suggest.Reset()
998			}
999			return false
1000		case tui.KeyEnter:
1001			// Enter on an ambiguous or partial slash prefix: complete to the
1002			// currently highlighted command and run it. That way typing
1003			// "/lo" + enter picks whichever of /login or /logout is selected
1004			// in the popup instead of submitting "/lo" as unknown. Also
1005			// clear the editor so the command doesn't linger after the
1006			// dialog opens/closes.
1007			if name := i.suggest.Selection(i.ed.Value()); name != "" {
1008				i.ed.PushHistory(name)
1009				i.ed.Clear()
1010				i.suggest.Reset()
1011				return i.runSlash(ctx, name)
1012			}
1013		case tui.KeyEsc:
1014			i.ed.Clear()
1015			i.suggest.Reset()
1016			return false
1017		}
1018	}
1019
1020	if submit := i.ed.HandleKey(k); submit {
1021		text := strings.TrimRight(i.ed.Value(), "\n")
1022		if text == "" {
1023			return false
1024		}
1025		i.ed.PushHistory(text)
1026		i.ed.Clear()
1027		i.suggest.Reset()
1028
1029		if looksLikeSlashCommand(text) {
1030			head := text
1031			rest := ""
1032			if idx := strings.IndexAny(text, " \t"); idx >= 0 {
1033				head = text[:idx]
1034				rest = strings.TrimSpace(text[idx:])
1035			}
1036			if !isKnownSlashCommand(text) {
1037				// Try extensions before giving up. Extensions register
1038				// commands by bare name (no leading slash); strip it here.
1039				extName := strings.TrimPrefix(head, "/")
1040				if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1041					go i.invokeExtensionCommand(ctx, extName, rest)
1042					return false
1043				}
1044				i.mu.Lock()
1045				i.statusErr = "unknown command " + head + " — type /help to see the list"
1046				i.statusOK = ""
1047				i.mu.Unlock()
1048				return false
1049			}
1050			// Slash commands run regardless of busy state. Commands that
1051			// would mutate the transcript or replace the agent (/clear,
1052			// /compact, /logout, /login, /model) cancel the active turn
1053			// first and wait for the goroutine to wind down so they don't
1054			// race with a streaming response. Safe commands (/help,
1055			// /jump, /sessions, /lock, /unlock, /exit) run immediately
1056			// without disturbing the active turn.
1057			if slashCancelsTurn(head) {
1058				i.cancelAndWaitForIdle()
1059			}
1060			return i.runSlash(ctx, text)
1061		}
1062
1063		if i.agent == nil {
1064			i.mu.Lock()
1065			i.statusErr = "not logged in. type /login first."
1066			i.mu.Unlock()
1067			return false
1068		}
1069		// If a turn is already in flight, queue this prompt instead of
1070		// starting a second one. The drain loop at the end of startTurn
1071		// will pick it up when the current turn finishes.
1072		i.mu.Lock()
1073		if i.busy {
1074			i.queued = append(i.queued, text)
1075			i.mu.Unlock()
1076			i.invalidate()
1077			return false
1078		}
1079		i.mu.Unlock()
1080		i.startTurn(ctx, text)
1081	}
1082	return false
1083}
1084
1085// invokeExtensionCommand fires an extension-registered slash command
1086// in a background goroutine, awaits the response, and applies the
1087// requested action (prompt / insert / display / noop). Errors and
1088// timeouts surface as a status_err line.
1089func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
1090	resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
1091	if err != nil {
1092		i.mu.Lock()
1093		i.statusErr = "extension /" + name + ": " + err.Error()
1094		i.statusOK = ""
1095		i.mu.Unlock()
1096		i.invalidate()
1097		return
1098	}
1099	if resp.Error != "" {
1100		i.mu.Lock()
1101		i.statusErr = "extension /" + name + ": " + resp.Error
1102		i.statusOK = ""
1103		i.mu.Unlock()
1104		i.invalidate()
1105		return
1106	}
1107	switch resp.Action {
1108	case "prompt":
1109		if strings.TrimSpace(resp.Prompt) == "" {
1110			return
1111		}
1112		i.startTurn(i.runCtx, resp.Prompt)
1113	case "insert":
1114		i.ed.Insert(resp.Insert)
1115		i.invalidate()
1116	case "display":
1117		i.appendExtensionNote(name, resp.Display, "info")
1118	case "noop", "":
1119		// nothing
1120	default:
1121		i.mu.Lock()
1122		i.statusErr = "extension /" + name + ": unknown action " + resp.Action
1123		i.mu.Unlock()
1124		i.invalidate()
1125	}
1126}
1127
1128// appendExtensionNote renders an extension-originated note in the
1129// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
1130// "success" (tool/ok green).
1131func (i *Interactive) appendExtensionNote(extName, msg, level string) {
1132	if msg == "" {
1133		return
1134	}
1135	i.mu.Lock()
1136	defer i.mu.Unlock()
1137	color := i.cfg.Theme.Muted
1138	switch level {
1139	case "warn":
1140		color = i.cfg.Theme.Warning
1141	case "error":
1142		color = i.cfg.Theme.Error
1143	case "success":
1144		color = i.cfg.Theme.Tool
1145	}
1146	prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
1147	for _, line := range strings.Split(msg, "\n") {
1148		i.statusOK = "" // clear any stale ok
1149		i.statusErr = ""
1150		i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
1151	}
1152}
1153
1154// HostHooks implementation for the extension manager. The manager
1155// holds an interface, not a concrete *Interactive, so these methods
1156// are the only thing the manager sees.
1157
1158// Notify is the manager's NotifyFromExt entry point.
1159func (i *Interactive) Notify(extName, level, message string) {
1160	i.appendExtensionNote(extName, message, level)
1161	i.invalidate()
1162}
1163
1164// Submit feeds text through the agent loop as if the user had typed it.
1165func (i *Interactive) Submit(text string) {
1166	i.startTurn(i.runCtx, text)
1167}
1168
1169// Insert places text at the cursor in the editor.
1170func (i *Interactive) Insert(text string) {
1171	i.ed.Insert(text)
1172	i.invalidate()
1173}
1174
1175// Display appends a styled note from extName to the chat without a
1176// model call.
1177func (i *Interactive) Display(extName, text string) {
1178	i.appendExtensionNote(extName, text, "info")
1179	i.invalidate()
1180}
1181
1182func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1183	parts := strings.Fields(cmd)
1184	switch parts[0] {
1185	case "/exit":
1186		return true
1187	case "/clear":
1188		if i.agent != nil {
1189			i.agent.SetMessages(nil)
1190		}
1191		i.mu.Lock()
1192		i.toolCalls = map[string]*tui.ToolCallView{}
1193		i.toolOrder = nil
1194		i.statusErr = ""
1195		i.statusOK = ""
1196		i.helpBlock = nil
1197		i.parkedTurn = 0
1198		i.parkedTotal = 0
1199		i.scrollOffset = 0
1200		i.extNotes = nil
1201		i.view.InvalidateRenderCache()
1202		i.mu.Unlock()
1203	case "/help":
1204		i.mu.Lock()
1205		i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
1206		i.statusErr = ""
1207		i.statusOK = ""
1208		// Pin the viewport to the newest content so the help block,
1209		// which we just appended to the end of the transcript, is
1210		// what the user actually sees.
1211		i.scrollOffset = 0
1212		i.mu.Unlock()
1213	case "/login":
1214		i.dialog.Open()
1215	case "/logout":
1216		if len(parts) >= 2 {
1217			// Explicit target: /logout anthropic | openai | all
1218			i.doLogout(parts[1])
1219			break
1220		}
1221		// No arg: open the picker over whichever providers are
1222		// currently logged in. If nothing's logged in, bail with a
1223		// status line.
1224		i.openLogoutDialog()
1225	case "/model":
1226		if len(parts) >= 2 {
1227			i.applyModelSelection("", parts[1])
1228		} else {
1229			i.modelDialog.Open(i.cfg.Model)
1230		}
1231	case "/sessions":
1232		i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
1233	case "/jump":
1234		i.openJumpDialog(parts[1:])
1235	case "/btw":
1236		i.openBtwDialog(parts[1:])
1237	case "/skills":
1238		i.openSkillsDialog()
1239	case "/compact":
1240		i.runCompact(ctx, false)
1241	case "/lock":
1242		if i.cfg.Sandbox == nil {
1243			i.mu.Lock()
1244			i.statusErr = "sandbox not available in this build"
1245			i.mu.Unlock()
1246			break
1247		}
1248		i.cfg.Sandbox.Lock()
1249		i.mu.Lock()
1250		i.statusOK = "locked to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
1251		i.statusErr = ""
1252		i.mu.Unlock()
1253	case "/unlock":
1254		if i.cfg.Sandbox == nil {
1255			i.mu.Lock()
1256			i.statusErr = "sandbox not available in this build"
1257			i.mu.Unlock()
1258			break
1259		}
1260		i.cfg.Sandbox.Unlock()
1261		i.mu.Lock()
1262		i.statusOK = "unlocked"
1263		i.statusErr = ""
1264		i.mu.Unlock()
1265	case "/reload-ext":
1266		i.runReloadExt(ctx)
1267	case "/yolo":
1268		i.runYoloOn()
1269	default:
1270		// Last-resort fallback: try the extension manager. Built-in
1271		// cases above always win; this branch only fires for slash
1272		// commands the extension manager registered. Same routing as
1273		// the editor's submit-handler dispatch path so the autocomplete
1274		// "enter on highlighted suggestion" flow also works.
1275		extName := strings.TrimPrefix(parts[0], "/")
1276		if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1277			rest := ""
1278			if len(parts) > 1 {
1279				rest = strings.Join(parts[1:], " ")
1280			}
1281			go i.invokeExtensionCommand(ctx, extName, rest)
1282			return false
1283		}
1284		i.mu.Lock()
1285		i.statusErr = "unknown command: " + parts[0]
1286		i.mu.Unlock()
1287	}
1288	return false
1289}
1290
1291// openLogoutDialog shows the provider picker for `/logout` with no
1292// argument. Only providers the user is currently logged into are
1293// listed, plus an "all" entry when more than one is present. If
1294// nothing's logged in, writes a status line instead of opening an
1295// empty dialog.
1296func (i *Interactive) openLogoutDialog() {
1297	if i.cfg.AuthManager == nil {
1298		i.mu.Lock()
1299		i.statusErr = "no auth manager configured"
1300		i.mu.Unlock()
1301		i.invalidate()
1302		return
1303	}
1304	store := i.cfg.AuthManager.Store()
1305	if store == nil {
1306		i.mu.Lock()
1307		i.statusErr = "auth store is not available"
1308		i.mu.Unlock()
1309		i.invalidate()
1310		return
1311	}
1312	creds, err := store.Load()
1313	if err != nil {
1314		i.mu.Lock()
1315		i.statusErr = "read auth store: " + err.Error()
1316		i.mu.Unlock()
1317		i.invalidate()
1318		return
1319	}
1320
1321	var items []logoutItem
1322	for _, p := range []string{"anthropic", "openai"} {
1323		if creds.Has(p) {
1324			items = append(items, logoutItem{
1325				label:  p,
1326				target: p,
1327				method: creds.Method(p),
1328			})
1329		}
1330	}
1331	if len(items) == 0 {
1332		i.mu.Lock()
1333		i.statusOK = "no credentials stored; already logged out"
1334		i.statusErr = ""
1335		i.mu.Unlock()
1336		i.invalidate()
1337		return
1338	}
1339	if len(items) > 1 {
1340		items = append(items, logoutItem{label: "all", target: "all"})
1341	}
1342
1343	i.logoutDialog.Open(items)
1344	i.invalidate()
1345}
1346
1347// doLogout clears credentials for the given provider (or all providers)
1348// from auth.json. If the active agent was using those credentials, it
1349// is torn down so the user is forced through /login before their next
1350// prompt.
1351//
1352// target: "anthropic" | "openai" | "all"
1353func (i *Interactive) doLogout(target string) {
1354	if i.cfg.AuthManager == nil {
1355		i.mu.Lock()
1356		i.statusErr = "no auth manager configured"
1357		i.mu.Unlock()
1358		return
1359	}
1360	store := i.cfg.AuthManager.Store()
1361	if store == nil {
1362		i.mu.Lock()
1363		i.statusErr = "auth store is not available"
1364		i.mu.Unlock()
1365		return
1366	}
1367
1368	var providers []string
1369	switch target {
1370	case "", "all":
1371		providers = []string{"anthropic", "openai"}
1372	case "anthropic", "openai":
1373		providers = []string{target}
1374	default:
1375		i.mu.Lock()
1376		i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"
1377		i.mu.Unlock()
1378		return
1379	}
1380
1381	var errs []string
1382	clearedCurrent := false
1383	for _, p := range providers {
1384		if err := store.Clear(p); err != nil {
1385			errs = append(errs, p+": "+err.Error())
1386			continue
1387		}
1388		if p == i.cfg.Provider {
1389			clearedCurrent = true
1390		}
1391	}
1392
1393	i.mu.Lock()
1394	defer i.mu.Unlock()
1395	if len(errs) > 0 {
1396		i.statusErr = "logout errors: " + strings.Join(errs, "; ")
1397		return
1398	}
1399	i.statusErr = ""
1400	if clearedCurrent {
1401		// The running agent was using a credential we just wiped. Drop
1402		// it so prompts can't go out with the stale client, and hint at
1403		// /login.
1404		i.agent = nil
1405		i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
1406	} else {
1407		i.statusOK = "logged out of " + strings.Join(providers, ", ")
1408	}
1409}
1410
1411func (i *Interactive) startAPIKeyFlow(provider string) {
1412	url, err := i.cfg.AuthManager.StartAPIKey(provider)
1413	if err != nil {
1414		i.dialog.ShowResult(false, err.Error())
1415		return
1416	}
1417	i.dialog.ShowWaiting(url)
1418}
1419
1420func (i *Interactive) startOAuthFlow(provider string) {
1421	url, err := i.cfg.AuthManager.StartOAuth(provider)
1422	if err != nil {
1423		i.dialog.ShowResult(false, err.Error())
1424		return
1425	}
1426	i.dialog.ShowWaiting(url)
1427}
1428
1429// applyModelSelection switches the active model (and provider, if the
1430// new model belongs to a different one). It rebuilds the underlying
1431// client when needed so the provider wire-protocol matches.
1432// cancelAndWaitForIdle cancels the active turn (if any) and blocks
1433// briefly until the turn goroutine has updated i.busy = false. Used
1434// before destructive slash commands so transcript-mutating work
1435// (/clear, /compact, /logout, /login completion, cross-provider
1436// /model swap) doesn't race with the still-running stream.
1437//
1438// The wait is bounded; if the turn doesn't release within the timeout
1439// we proceed anyway. Worst case is a brief overlap that the agent's
1440// own mutex protects against.
1441func (i *Interactive) cancelAndWaitForIdle() {
1442	i.mu.Lock()
1443	busy := i.busy
1444	cancel := i.cancelTurn
1445	i.mu.Unlock()
1446	if !busy {
1447		return
1448	}
1449	if cancel != nil {
1450		cancel()
1451	}
1452	deadline := time.Now().Add(2 * time.Second)
1453	for time.Now().Before(deadline) {
1454		i.mu.Lock()
1455		done := !i.busy
1456		i.mu.Unlock()
1457		if done {
1458			return
1459		}
1460		time.Sleep(10 * time.Millisecond)
1461	}
1462}
1463
1464// openBtwDialog opens the side-chat overlay with a frozen snapshot
1465// of the current main session. The optional argument is auto-
1466// submitted as the first question, so '/btw does X work?' fires the
1467// model call immediately instead of just opening an empty dialog.
1468func (i *Interactive) openBtwDialog(args []string) {
1469	if i.agent == nil {
1470		i.mu.Lock()
1471		i.statusErr = "not logged in. type /login first."
1472		i.mu.Unlock()
1473		return
1474	}
1475	seed := strings.TrimSpace(strings.Join(args, " "))
1476	i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed)
1477	i.invalidate()
1478}
1479
1480// openSkillsDialog opens the skill inspector. The picker reflects
1481// whatever SkillSnapshot returns at call time, so edits to a
1482// SKILL.md made during a session show up on the next /skills.
1483func (i *Interactive) openSkillsDialog() {
1484	var list []*skills.Skill
1485	if i.cfg.SkillSnapshot != nil {
1486		list = i.cfg.SkillSnapshot()
1487	}
1488	i.skillsDialog.Open(list)
1489	i.invalidate()
1490}
1491
1492// openJumpDialog builds a /jump picker from the current transcript.
1493// If the user typed "/jump foo" with a filter and it matches exactly
1494// one turn, jump there directly without showing the dialog.
1495func (i *Interactive) openJumpDialog(args []string) {
1496	if i.view == nil || len(i.view.Messages) == 0 {
1497		i.mu.Lock()
1498		i.statusErr = "nothing to jump to \u2014 the session is empty"
1499		i.mu.Unlock()
1500		return
1501	}
1502	filter := strings.TrimSpace(strings.Join(args, " "))
1503	i.jumpDialog.Open(i.view.Messages, filter)
1504	// Shortcut: with a filter argument that matches exactly one turn,
1505	// jump immediately and skip the picker.
1506	if filter != "" {
1507		if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
1508			t := tgts[0]
1509			i.jumpDialog.Close()
1510			i.applyJumpSelection(t.MessageIdx, t.TurnNo)
1511		}
1512	}
1513}
1514
1515// applyJumpSelection scrolls the chat viewport so the user message at
1516// msgIdx is visible at (or near) the top of the chat area. Uses the
1517// anchor slice returned by view.BuildWithAnchors so the mapping from
1518// message index to row is exact, regardless of variable-height tool
1519// blocks above the target.
1520func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
1521	cols := i.lastCols()
1522	chat, anchors := i.view.BuildWithAnchors(cols)
1523	var row int
1524	found := false
1525	for _, a := range anchors {
1526		if a.MessageIdx == msgIdx {
1527			row = a.Row
1528			found = true
1529			break
1530		}
1531	}
1532	if !found {
1533		i.mu.Lock()
1534		i.statusErr = "could not resolve jump target"
1535		i.mu.Unlock()
1536		return
1537	}
1538
1539	chatLen := len(chat)
1540	page := i.chatPage()
1541	if page < 1 {
1542		page = 1
1543	}
1544	// scrollOffset is measured from the bottom of the chat slice, so
1545	// to place `row` at the top of the viewport we want:
1546	//     chatLen - scrollOffset - page == row
1547	// Solve for scrollOffset and clamp to [0, chatLen-page].
1548	offset := chatLen - (row + page)
1549	if offset < 0 {
1550		offset = 0
1551	}
1552	maxOffset := chatLen - page
1553	if maxOffset < 0 {
1554		maxOffset = 0
1555	}
1556	if offset > maxOffset {
1557		offset = maxOffset
1558	}
1559
1560	i.mu.Lock()
1561	i.scrollOffset = offset
1562	i.parkedTurn = turnNo
1563	i.parkedTotal = totalTurnsLocked(i.view.Messages)
1564	i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
1565	i.statusErr = ""
1566	i.mu.Unlock()
1567}
1568
1569// totalTurnsLocked counts user messages in the transcript. Caller is
1570// assumed to hold i.mu (the name is a mild reminder; this function
1571// itself doesn't touch shared state beyond the slice it's handed).
1572func totalTurnsLocked(msgs []provider.Message) int {
1573	n := 0
1574	for _, m := range msgs {
1575		if m.Role == provider.RoleUser {
1576			n++
1577		}
1578	}
1579	return n
1580}
1581
1582// applySessionSelection loads the given session via the cli-provided
1583// callback and parks the viewport on the last turn so the user lands
1584// looking at where the conversation left off (their last prompt at the
1585// top of the chat, the assistant's last reply right below). Older
1586// history is one scroll up; pgdn or end snaps to the current tail.
1587//
1588// Without this, scrollOffset stayed at 0 (pinned to the live tail),
1589// which on a long resumed session showed only the last few rows of
1590// the final assistant message — the user read that as "only one liner
1591// happened, the resume didn't work".
1592func (i *Interactive) applySessionSelection(path string) {
1593	if i.cfg.LoadSession == nil {
1594		i.mu.Lock()
1595		i.statusErr = "session loading is not wired in this build"
1596		i.mu.Unlock()
1597		return
1598	}
1599	if err := i.cfg.LoadSession(path); err != nil {
1600		i.mu.Lock()
1601		i.statusErr = err.Error()
1602		i.mu.Unlock()
1603		return
1604	}
1605
1606	i.mu.Lock()
1607	i.statusOK = "resumed session: " + path
1608	i.statusErr = ""
1609	i.parkedTurn = 0
1610	i.parkedTotal = 0
1611	i.view.InvalidateRenderCache()
1612	// Pull the freshly-loaded transcript into the view so the anchor
1613	// math below sees the post-resume messages, not the empty pre-load
1614	// state. redraw() does the same on its next pass; we just front-run
1615	// it here to compute the scroll target.
1616	if i.agent != nil {
1617		i.view.Messages = i.agent.Messages()
1618	}
1619	msgs := i.view.Messages
1620	i.mu.Unlock()
1621
1622	i.scrollToLastTurn(msgs)
1623}
1624
1625// scrollToLastTurn parks the viewport at the most recent user turn,
1626// or at the top if the transcript has no user messages. Used after
1627// resume so the user lands looking at where they left off.
1628func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
1629	if len(msgs) == 0 {
1630		i.mu.Lock()
1631		i.scrollOffset = 0
1632		i.mu.Unlock()
1633		return
1634	}
1635	// Find the last user message index.
1636	lastUser := -1
1637	turnNo, totalTurns := 0, 0
1638	for idx, m := range msgs {
1639		if m.Role == provider.RoleUser {
1640			totalTurns++
1641			lastUser = idx
1642		}
1643	}
1644	if lastUser < 0 {
1645		i.mu.Lock()
1646		i.scrollOffset = 0
1647		i.mu.Unlock()
1648		return
1649	}
1650	turnNo = totalTurns
1651
1652	cols := i.lastCols()
1653	chat, anchors := i.view.BuildWithAnchors(cols)
1654	var row int
1655	found := false
1656	for _, a := range anchors {
1657		if a.MessageIdx == lastUser {
1658			row = a.Row
1659			found = true
1660			break
1661		}
1662	}
1663	if !found {
1664		i.mu.Lock()
1665		i.scrollOffset = 0
1666		i.mu.Unlock()
1667		return
1668	}
1669
1670	chatLen := len(chat)
1671	page := i.chatPage()
1672	if page < 1 {
1673		page = 1
1674	}
1675	offset := chatLen - (row + page)
1676	if offset < 0 {
1677		offset = 0
1678	}
1679	maxOffset := chatLen - page
1680	if maxOffset < 0 {
1681		maxOffset = 0
1682	}
1683	if offset > maxOffset {
1684		offset = maxOffset
1685	}
1686
1687	i.mu.Lock()
1688	i.scrollOffset = offset
1689	// Mark the parked-turn footer so the user sees "viewing turn N of
1690	// M · pgdn to catch up" — same affordance as /jump. Tells them at
1691	// a glance that they're looking at history, not the live tail.
1692	if offset > 0 {
1693		i.parkedTurn = turnNo
1694		i.parkedTotal = totalTurns
1695	}
1696	i.mu.Unlock()
1697	i.invalidate()
1698}
1699
1700func (i *Interactive) applyModelSelection(prov, model string) {
1701	if model == "" {
1702		return
1703	}
1704	m, err := provider.FindModel(prov, model)
1705	if err != nil {
1706		i.mu.Lock()
1707		i.statusErr = err.Error()
1708		i.mu.Unlock()
1709		return
1710	}
1711	// Same provider? Just swap the model on the existing agent.
1712	if i.agent != nil && m.Provider == i.cfg.Provider {
1713		i.mu.Lock()
1714		i.cfg.Model = m.ID
1715		i.agent.Model = m.ID
1716		i.statusOK = "model: " + m.ID
1717		i.statusErr = ""
1718		i.mu.Unlock()
1719		if i.cfg.PersistModel != nil {
1720			i.cfg.PersistModel(i.cfg.Provider, m.ID)
1721		}
1722		return
1723	}
1724	// Different provider: rebuild agent (needs credentials for target provider).
1725	if i.cfg.BuildAgentFor == nil {
1726		i.mu.Lock()
1727		i.statusErr = "cannot switch provider: no builder configured"
1728		i.mu.Unlock()
1729		return
1730	}
1731	// Snapshot the current transcript and cumulative usage BEFORE we
1732	// build the replacement agent so we can hand them off. Without
1733	// this the user perceives the entire session as wiped on a
1734	// cross-provider /model swap.
1735	var carryMsgs []provider.Message
1736	var carryCost provider.Usage
1737	if i.agent != nil {
1738		carryMsgs = i.agent.Messages()
1739		carryCost = i.agent.Cost()
1740	}
1741
1742	ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)
1743	if err != nil {
1744		i.mu.Lock()
1745		i.statusErr = err.Error()
1746		i.mu.Unlock()
1747		return
1748	}
1749
1750	// Replay the transcript and seed the cost on the freshly-built
1751	// agent. Messages travel cleanly between providers because they
1752	// use the same provider.Message shape; tool-call ids are local
1753	// to a turn so cross-provider continuation never confuses the
1754	// new model (it just sees the assistant's reply, no orphan
1755	// tool_use blocks because /model swaps are gated to idle state).
1756	if len(carryMsgs) > 0 {
1757		ag.SetMessages(carryMsgs)
1758	}
1759	ag.SeedCost(carryCost)
1760
1761	i.mu.Lock()
1762	i.agent = ag
1763	i.cfg.Provider = p
1764	i.cfg.Model = md
1765	i.statusOK = "switched to " + p + " / " + md
1766	i.statusErr = ""
1767	// Render cache keys are width+content based, so the new agent's
1768	// identical messages will reuse the existing entries. Nothing
1769	// to invalidate.
1770	i.mu.Unlock()
1771	if i.cfg.PersistModel != nil {
1772		i.cfg.PersistModel(p, md)
1773	}
1774}
1775
1776func (i *Interactive) handleAuthEvent(ev auth.Event) {
1777	switch ev.Kind {
1778	case "started":
1779		i.dialog.ShowWaiting(ev.URL)
1780	case "browser_open":
1781		// no-op
1782	case "error":
1783		i.dialog.ShowResult(false, ev.Message)
1784	case "success":
1785		// Rebuild the agent with the fresh credential.
1786		ag, prov, model, err := i.cfg.BuildAgent()
1787		if err != nil {
1788			i.dialog.ShowResult(false, err.Error())
1789			return
1790		}
1791		i.mu.Lock()
1792		i.agent = ag
1793		i.cfg.Provider = prov
1794		i.cfg.Model = model
1795		i.statusErr = ""
1796		i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
1797		i.mu.Unlock()
1798		i.dialog.ShowResult(true, "")
1799	}
1800}
1801
1802// runCompact invokes core.Agent.Compact and reflects the progress in
1803// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
1804// cancel via the same cancelTurn channel used for normal turns.
1805//
1806// When auto is true the spinner message is pinned to "condensing
1807// history" and the status bar surfaces "(auto)" next to the context
1808// percentage so it's obvious the system triggered this, not the user.
1809func (i *Interactive) runCompact(parent context.Context, auto bool) {
1810	if i.agent == nil {
1811		i.mu.Lock()
1812		i.statusErr = "not logged in. type /login first."
1813		i.mu.Unlock()
1814		return
1815	}
1816	ctx, cancel := context.WithCancel(parent)
1817	i.mu.Lock()
1818	i.busy = true
1819	if auto {
1820		i.spin.StartFixed("condensing history")
1821		i.autoCompacting = true
1822		i.statusOK = "condensing history… (esc to cancel)"
1823	} else {
1824		i.spin.Start()
1825		i.statusOK = "compacting..."
1826	}
1827	i.cancelTurn = cancel
1828	i.statusErr = ""
1829	i.streaming.Reset()
1830	i.streamOn = true
1831	i.scrollOffset = 0
1832	i.helpBlock = nil
1833	i.mu.Unlock()
1834	i.invalidate()
1835
1836	go func() {
1837		sink := func(delta string) {
1838			i.mu.Lock()
1839			i.streaming.WriteString(delta)
1840			i.mu.Unlock()
1841			i.invalidate()
1842		}
1843		summary, err := i.agent.Compact(ctx, 4, sink)
1844		i.mu.Lock()
1845		i.busy = false
1846		i.streamOn = false
1847		i.streaming.Reset()
1848		i.cancelTurn = nil
1849		i.autoCompacting = false
1850		switch {
1851		case err != nil && ctx.Err() != nil:
1852			i.statusErr = ""
1853			if auto {
1854				i.statusOK = "auto-condense cancelled"
1855			} else {
1856				i.statusOK = "compaction cancelled"
1857			}
1858		case err != nil:
1859			i.statusErr = "compaction failed: " + err.Error()
1860			i.statusOK = ""
1861		default:
1862			i.statusErr = ""
1863			i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary))
1864			i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
1865			i.toolCalls = map[string]*tui.ToolCallView{}
1866			i.toolOrder = nil
1867			// Transcript was rewritten in place — purge the per-message
1868			// render cache so stale entries keyed on the old messages
1869			// don't linger.
1870			i.view.InvalidateRenderCache()
1871		}
1872		i.mu.Unlock()
1873		i.invalidate()
1874	}()
1875}
1876
1877func (i *Interactive) startTurn(parent context.Context, prompt string) {
1878	if i.agent == nil {
1879		return
1880	}
1881	ctx, cancel := context.WithCancel(parent)
1882	i.mu.Lock()
1883	i.busy = true
1884	i.spin.Start()
1885	i.cancelTurn = cancel
1886	i.statusErr = ""
1887	i.statusOK = ""
1888	i.streaming.Reset()
1889	i.streamOn = true
1890	i.toolCalls = map[string]*tui.ToolCallView{}
1891	i.toolOrder = nil
1892	i.scrollOffset = 0 // jump back to the bottom on new turn
1893	i.parkedTurn = 0   // starting a turn clears the /jump parked state
1894	i.parkedTotal = 0
1895	i.helpBlock = nil // hide the help block once the user asks something
1896	i.mu.Unlock()
1897	i.invalidate()
1898
1899	sink := func(ev core.AgentEvent) {
1900		i.handleEvent(ev)
1901		i.invalidate()
1902	}
1903
1904	go func() {
1905		err := i.agent.Prompt(ctx, prompt, nil, sink)
1906		i.mu.Lock()
1907		i.busy = false
1908		i.streamOn = false
1909		i.cancelTurn = nil
1910		if err != nil && ctx.Err() == nil {
1911			i.statusErr = err.Error()
1912		}
1913		// Pop the next queued message, if any, and relaunch.
1914		var next string
1915		var hasNext bool
1916		if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
1917			next, i.queued = i.queued[0], i.queued[1:]
1918			hasNext = true
1919		}
1920		// If the turn was cancelled or errored, drop the queue so the
1921		// user isn't bombarded with stale messages after an interrupt.
1922		if ctx.Err() != nil || err != nil {
1923			i.queued = nil
1924		}
1925		// Decide whether the next thing to do is an auto-compaction.
1926		// Only fires when the turn completed cleanly AND the queue is
1927		// empty (otherwise a queued message would race the condense).
1928		shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
1929		i.mu.Unlock()
1930		i.invalidate()
1931		parent := i.runCtx
1932		if parent == nil {
1933			parent = context.Background()
1934		}
1935		switch {
1936		case hasNext:
1937			i.startTurn(parent, next)
1938		case shouldAutoCompact:
1939			i.runCompact(parent, true)
1940		}
1941	}()
1942}
1943
1944// autoCompactThreshold is the context-window fraction at which the
1945// agent will auto-compact after a turn ends. 0.85 leaves enough
1946// headroom for one more user prompt + response before we bump the
1947// hard limit.
1948const autoCompactThreshold = 0.85
1949
1950// shouldAutoCompactLocked reports whether the last turn pushed context
1951// usage past the auto-compact threshold. Must be called with i.mu
1952// held; it reads lastCtxInput and the current model's context window.
1953func (i *Interactive) shouldAutoCompactLocked() bool {
1954	if i.agent == nil {
1955		return false
1956	}
1957	if i.autoCompacting {
1958		return false
1959	}
1960	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
1961	if err != nil || m.ContextWindow <= 0 {
1962		return false
1963	}
1964	if i.lastCtxInput <= 0 {
1965		return false
1966	}
1967	return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
1968}
1969
1970func (i *Interactive) handleEvent(ev core.AgentEvent) {
1971	i.mu.Lock()
1972	defer i.mu.Unlock()
1973	switch e := ev.(type) {
1974	case core.EvTextDelta:
1975		i.streaming.WriteString(e.Delta)
1976	case core.EvAssistantMessage:
1977		i.streaming.Reset()
1978		i.streamOn = false
1979		if i.cfg.OnAssistant != nil {
1980			i.cfg.OnAssistant(e.Message)
1981		}
1982	case core.EvToolUseStart:
1983		// Live streaming: pre-create the view so the user sees the
1984		// tool call being composed in real time. Any subsequent
1985		// EvToolCall for the same ID updates the same struct (the
1986		// final parsed args + name are already known here).
1987		if _, exists := i.toolCalls[e.ID]; !exists {
1988			i.toolCalls[e.ID] = &tui.ToolCallView{
1989				ID:        e.ID,
1990				Name:      e.Name,
1991				Streaming: true,
1992			}
1993			i.toolOrder = append(i.toolOrder, e.ID)
1994		}
1995	case core.EvToolUseArgs:
1996		if tc, ok := i.toolCalls[e.ID]; ok {
1997			tc.RawJSONBuf += e.Delta
1998			// Refresh the live path as soon as it parses; used in
1999			// the header (▸ write /Users/pat/Desktop/demo.ts)
2000			// while the content is still streaming.
2001			if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
2002				tc.LivePath = p
2003			} else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
2004				tc.LivePath = p
2005			}
2006		}
2007	case core.EvToolUseEnd:
2008		if tc, ok := i.toolCalls[e.ID]; ok {
2009			tc.Streaming = false
2010		}
2011	case core.EvToolCall:
2012		// If we already pre-created the view during streaming, just
2013		// refresh the final Args summary. Otherwise create a new one
2014		// (non-streaming providers or legacy paths).
2015		if tc, ok := i.toolCalls[e.ID]; ok {
2016			tc.Args = shortArgs(e.Args)
2017			tc.Streaming = false
2018		} else {
2019			i.toolCalls[e.ID] = &tui.ToolCallView{
2020				ID:   e.ID,
2021				Name: e.Name,
2022				Args: shortArgs(e.Args),
2023			}
2024			i.toolOrder = append(i.toolOrder, e.ID)
2025		}
2026	case core.EvToolResult:
2027		if tc, ok := i.toolCalls[e.ID]; ok {
2028			tc.Done = true
2029			tc.Error = e.Result.IsError
2030			var text strings.Builder
2031			for _, c := range e.Result.Content {
2032				if tb, ok := c.(provider.TextBlock); ok {
2033					if text.Len() > 0 {
2034						text.WriteString("\n")
2035					}
2036					text.WriteString(tb.Text)
2037				}
2038			}
2039			tc.Result = text.String()
2040		}
2041		if i.cfg.OnToolResult != nil {
2042			i.cfg.OnToolResult(e.ID, e.Result)
2043		}
2044	case core.EvUsage:
2045		i.cumUsage = e.Cumulative
2046		if e.Usage.InputTokens > 0 {
2047			i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
2048		}
2049	case core.EvTurnEnd:
2050		if e.Stop == provider.StopAborted {
2051			// Aborted turn: discard the partial streaming text (it is not
2052			// persisted in the transcript) and clear any transient error.
2053			i.streaming.Reset()
2054			i.streamOn = false
2055			i.statusErr = ""
2056			i.statusOK = "cancelled"
2057			return
2058		}
2059		if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") {
2060			i.statusErr = e.Err.Error()
2061		}
2062	}
2063}
2064
2065// Agent returns the current agent, if any. Used by cli.go to flush the
2066// final transcript to the session file.
2067func (i *Interactive) Agent() *core.Agent {
2068	i.mu.Lock()
2069	defer i.mu.Unlock()
2070	return i.agent
2071}
2072
2073func shortArgs(raw json.RawMessage) string {
2074	var v any
2075	if err := json.Unmarshal(raw, &v); err != nil {
2076		return ""
2077	}
2078	if m, ok := v.(map[string]any); ok {
2079		for _, k := range []string{"path", "file_path", "command"} {
2080			if s, ok := m[k].(string); ok {
2081				if len(s) > 60 {
2082					s = s[:57] + "..."
2083				}
2084				return s
2085			}
2086		}
2087	}
2088	b, _ := json.Marshal(v)
2089	s := string(b)
2090	if len(s) > 60 {
2091		s = s[:57] + "..."
2092	}
2093	return s
2094}
2095
2096// silence unused import in some build configs
2097var _ = fmt.Sprintf
2098
2099// runReloadExt triggers a live reload of every extension (discovered
2100// + explicit). Runs on a goroutine so the TUI stays responsive; the
2101// Manager.Reload takes a couple of hundred ms to shut down subprocs
2102// and respawn them. Shows a status line throughout.
2103func (i *Interactive) runReloadExt(ctx context.Context) {
2104	if i.cfg.Extensions == nil {
2105		i.mu.Lock()
2106		i.statusErr = "no extension manager in this build"
2107		i.mu.Unlock()
2108		i.invalidate()
2109		return
2110	}
2111	i.mu.Lock()
2112	i.statusOK = "reloading extensions…"
2113	i.statusErr = ""
2114	i.mu.Unlock()
2115	i.invalidate()
2116
2117	go func() {
2118		stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
2119		msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
2120		if len(stats.Errors) > 0 {
2121			msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
2122		}
2123		i.mu.Lock()
2124		i.statusOK = msg
2125		i.statusErr = ""
2126		i.mu.Unlock()
2127		i.invalidate()
2128	}()
2129}
2130
2131// Confirm implements core.Confirmer. The agent goroutine calls
2132// this synchronously before every tool invocation when --no-yolo is
2133// active. We push the request onto the confirmDialog queue, trigger
2134// a redraw, and block the caller until the user answers.
2135//
2136// If the session is cancelled or the TUI exits mid-prompt, any
2137// pending request is refused via CancelAll so the agent doesn't
2138// deadlock.
2139func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
2140	resp := make(chan core.ConfirmDecision, 1)
2141	i.confirmDialog.Enqueue(&confirmRequest{
2142		toolName: toolName,
2143		preview:  preview,
2144		resp:     resp,
2145	})
2146	i.invalidate()
2147	return <-resp
2148}
2149
2150// runYoloOn disables --no-yolo for the rest of the session. Tool
2151// calls run without prompting after this; there's intentionally no
2152// way to re-enable gating mid-session, if the user wants that back
2153// they can exit and restart with --no-yolo.
2154func (i *Interactive) runYoloOn() {
2155	if i.cfg.ConfirmGate == nil {
2156		i.mu.Lock()
2157		i.statusOK = "yolo mode is already on (no --no-yolo in this session)"
2158		i.statusErr = ""
2159		i.mu.Unlock()
2160		i.invalidate()
2161		return
2162	}
2163	i.cfg.ConfirmGate.AllowAll()
2164	// Also auto-allow any currently pending confirmation so the
2165	// agent doesn't deadlock if /yolo is typed while a prompt is
2166	// open.
2167	i.confirmDialog.AllowAllPending()
2168	i.mu.Lock()
2169	i.statusOK = "yolo engaged: no more tool-call confirmations this session"
2170	i.statusErr = ""
2171	i.mu.Unlock()
2172	i.invalidate()
2173}

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