v0.0.49 f8c309e

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.
615	var busyPrefix string
616	if i.busy {
617		busyPrefix = fmt.Sprintf("%s %s · %s",
618			i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Frame()),
619			i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Message()),
620			i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
621		)
622	}
623
624	ctxMax := 0
625	if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
626		ctxMax = m.ContextWindow
627	}
628	statusLines := tui.StatusBar(tui.StatusBarParams{
629		Theme:          i.cfg.Theme,
630		Provider:       i.cfg.Provider,
631		Model:          i.cfg.Model,
632		Busy:           i.busy,
633		BusyPrefix:     busyPrefix,
634		CWD:            i.cfg.CWD,
635		Locked:         i.cfg.Sandbox.Locked(),
636		Usage:          i.cumUsage,
637		Subscription:   i.cfg.AuthMethod == "oauth",
638		ContextUsed:    i.lastCtxInput,
639		ContextMax:     ctxMax,
640		AutoCompacting: i.autoCompacting,
641		Cols:           cols,
642	})
643	edLines, curR, curC := i.ed.Render(cols)
644
645	// "Sliding in" chips for messages the user typed while a turn is
646	// in flight. Shown directly above the status bar so they're close
647	// to the editor but don't push the chat around.
648	var queue []string
649	if len(i.queued) > 0 {
650		for _, q := range i.queued {
651			label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")
652			text := truncateLine(q, cols-15)
653			queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
654		}
655	}
656
657	// Bottom-sticky sections (always visible, never scroll).
658	bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)
659	bottom = append(bottom, dialog...)
660	bottom = append(bottom, suggest...)
661	bottom = append(bottom, queue...)
662	bottom = append(bottom, statusLines...)
663	bottom = append(bottom, edLines...)
664
665	_, rows := i.cfg.Terminal.Size()
666	chatRows := rows - len(bottom)
667	if chatRows < 1 {
668		chatRows = 1
669	}
670
671	// Apply scroll offset to the chat slice.
672	maxOffset := len(chat) - chatRows
673	if maxOffset < 0 {
674		maxOffset = 0
675	}
676	if i.scrollOffset > maxOffset {
677		i.scrollOffset = maxOffset
678	}
679	if i.scrollOffset < 0 {
680		i.scrollOffset = 0
681	}
682
683	var visibleChat []string
684	if len(chat) <= chatRows {
685		visibleChat = chat
686	} else {
687		end := len(chat) - i.scrollOffset
688		start := end - chatRows
689		if start < 0 {
690			start = 0
691		}
692		visibleChat = chat[start:end]
693	}
694
695	// A tiny "scrolled up" indicator in the top-right of the chat pane
696	// so you know you're not at the bottom. When the viewport was
697	// parked by /jump we include the turn number so the user remembers
698	// they're reading history rather than the live conversation.
699	if i.scrollOffset > 0 && len(visibleChat) > 0 {
700		var text string
701		if i.parkedTurn > 0 && i.parkedTotal > 0 {
702			text = fmt.Sprintf("  ↑ viewing turn %d of %d · %d lines more below (pgdn / end)",
703				i.parkedTurn, i.parkedTotal, i.scrollOffset)
704		} else {
705			text = fmt.Sprintf("  ↑ %d lines more below (end to jump)", i.scrollOffset)
706		}
707		note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
708		visibleChat = append([]string{note}, visibleChat...)
709		if len(visibleChat) > chatRows {
710			visibleChat = visibleChat[:chatRows]
711		}
712	}
713
714	frame := make([]string, 0, len(visibleChat)+len(bottom))
715	frame = append(frame, visibleChat...)
716	frame = append(frame, bottom...)
717
718	cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR
719	cursorCol := curC
720	i.rend.Draw(frame, cursorRow, cursorCol)
721}
722
723// truncateLine shortens s so it fits within n display cells, with an
724// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
725// novel doesn't blow past the status line.
726func truncateLine(s string, n int) string {
727	if n <= 0 {
728		return ""
729	}
730	// Collapse newlines — chips are single line.
731	s = strings.ReplaceAll(s, "\n", " ↩ ")
732	runes := []rune(s)
733	if len(runes) <= n {
734		return s
735	}
736	if n <= 1 {
737		return "…"
738	}
739	return string(runes[:n-1]) + "…"
740}
741
742// ctrlCExitWindow is how long after a ctrl+c press a *second* press
743// will exit instead of just clearing input. Long enough to be
744// deliberate (rules out accidental key chord), short enough that the
745// hint stays meaningful.
746const ctrlCExitWindow = 2 * time.Second
747
748// armCtrlCExit records the timestamp of the current ctrl+c so the next
749// one within ctrlCExitWindow exits.
750func (i *Interactive) armCtrlCExit() {
751	i.mu.Lock()
752	i.lastCtrlC = time.Now()
753	i.mu.Unlock()
754}
755
756// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
757// that another press should now exit.
758func (i *Interactive) ctrlCExitArmed() bool {
759	i.mu.Lock()
760	t := i.lastCtrlC
761	i.mu.Unlock()
762	return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
763}
764
765func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
766	// Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
767	// pressing ctrl+c then typing then ctrl+c much later doesn't quit
768	// unexpectedly. The hint message also goes stale; clear it.
769	if k.Kind != tui.KeyCtrlC {
770		i.mu.Lock()
771		if !i.lastCtrlC.IsZero() {
772			i.lastCtrlC = time.Time{}
773			if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
774				i.statusOK = ""
775			}
776		}
777		i.mu.Unlock()
778	}
779
780	// Dialogs consume keys while open (except ctrl+c, which always closes them).
781
782	// Confirm dialog has highest priority: the agent goroutine is
783	// blocked waiting for an answer, so we must not let keys leak
784	// anywhere else while it's up.
785	if i.confirmDialog.Active() {
786		i.confirmDialog.HandleKey(k)
787		i.invalidate()
788		return false
789	}
790	if i.dialog.Active() {
791		if k.Kind == tui.KeyCtrlC {
792			i.dialog.Close()
793			if i.cfg.AuthManager != nil {
794				i.cfg.AuthManager.CancelOAuth()
795			}
796			return false
797		}
798		act := i.dialog.HandleKey(k)
799		if act.StartAPIKey {
800			i.startAPIKeyFlow(act.Provider)
801		}
802		if act.StartOAuth {
803			i.startOAuthFlow(act.Provider)
804		}
805		return false
806	}
807	if i.modelDialog.Active() {
808		if k.Kind == tui.KeyCtrlC {
809			i.modelDialog.Close()
810			return false
811		}
812		act := i.modelDialog.HandleKey(k)
813		if act.Select {
814			i.applyModelSelection(act.Provider, act.Model)
815		}
816		return false
817	}
818	if i.sessionDialog.Active() {
819		if k.Kind == tui.KeyCtrlC {
820			i.sessionDialog.Close()
821			return false
822		}
823		act := i.sessionDialog.HandleKey(k)
824		if act.Select {
825			i.applySessionSelection(act.Path)
826		}
827		return false
828	}
829	if i.logoutDialog.Active() {
830		if k.Kind == tui.KeyCtrlC {
831			i.logoutDialog.Close()
832			i.invalidate()
833			return false
834		}
835		act := i.logoutDialog.HandleKey(k)
836		if act.Select {
837			i.doLogout(act.Target)
838		}
839		i.invalidate()
840		return false
841	}
842	if i.jumpDialog.Active() {
843		if k.Kind == tui.KeyCtrlC {
844			i.jumpDialog.Close()
845			return false
846		}
847		act := i.jumpDialog.HandleKey(k)
848		if act.Select {
849			i.applyJumpSelection(act.MessageIdx, act.TurnNo)
850		}
851		return false
852	}
853	if i.btwDialog.Active() {
854		if k.Kind == tui.KeyCtrlC {
855			i.btwDialog.Close()
856			i.invalidate()
857			return false
858		}
859		i.btwDialog.HandleKey(k, i.invalidate)
860		return false
861	}
862	if i.skillsDialog.Active() {
863		if k.Kind == tui.KeyCtrlC {
864			i.skillsDialog.Close()
865			i.invalidate()
866			return false
867		}
868		i.skillsDialog.HandleKey(k)
869		i.invalidate()
870		return false
871	}
872	if i.changelogDialog.Active() {
873		if closed := i.changelogDialog.HandleKey(k); closed {
874			// User dismissed; let the parent persist the
875			// LastChangelogShown marker via the close callback.
876			if i.cfg.OnChangelogDismiss != nil {
877				i.cfg.OnChangelogDismiss()
878			}
879		}
880		i.invalidate()
881		return false
882	}
883
884	// Global keys.
885	switch k.Kind {
886	case tui.KeyCtrlC:
887		// While busy: cancel the active turn (same as esc). The exit
888		// hint stays armed so a quick second ctrl+c after the turn
889		// dies still exits, matching habits from other repls.
890		if i.busy && i.cancelTurn != nil {
891			i.cancelTurn()
892			i.armCtrlCExit()
893			return false
894		}
895		// Idle: first press clears the editor (and any queued
896		// follow-up messages); a second press within ctrlCExitWindow
897		// exits. With both an empty editor and no queue the first
898		// press still just arms — require a deliberate double-tap.
899		hadInput := !i.ed.IsEmpty() || len(i.queued) > 0
900		if hadInput {
901			i.ed.Clear()
902			i.suggest.Reset()
903			i.mu.Lock()
904			i.queued = nil
905			i.statusOK = "input cleared"
906			i.statusErr = ""
907			i.mu.Unlock()
908			i.armCtrlCExit()
909			return false
910		}
911		if i.ctrlCExitArmed() {
912			return true
913		}
914		i.mu.Lock()
915		i.statusOK = "press ctrl+c again to exit"
916		i.statusErr = ""
917		i.mu.Unlock()
918		i.armCtrlCExit()
919		return false
920	case tui.KeyEsc:
921		// Esc interrupts a running turn. When idle, fall through so the
922		// editor can clear itself.
923		if i.busy && i.cancelTurn != nil {
924			i.cancelTurn()
925			// If a confirm dialog is pending, refuse it so the agent
926			// goroutine unblocks and the context cancellation can
927			// actually take effect.
928			i.confirmDialog.CancelAll("turn cancelled")
929			return false
930		}
931	case tui.KeyCtrlD:
932		if i.ed.IsEmpty() && !i.busy {
933			return true
934		}
935	case tui.KeyCtrlL:
936		i.rend.Clear()
937		i.invalidate()
938		return false
939	case tui.KeyCtrlO:
940		// Toggle expansion of collapsed tool results. Affects every tool
941		// call in the transcript — press again to re-collapse.
942		i.mu.Lock()
943		i.view.ExpandAll = !i.view.ExpandAll
944		i.mu.Unlock()
945		i.invalidate()
946		return false
947	case tui.KeyPageUp:
948		i.scrollBy(+i.chatPage())
949		return false
950	case tui.KeyPageDown:
951		i.scrollBy(-i.chatPage())
952		return false
953	case tui.KeyUp:
954		// Wheel-up in alt screen sends Up arrows on most terminals.
955		// When the editor is empty we use up/down for chat scroll —
956		// independently of whether the agent is busy, so users can
957		// scroll back through long streaming replies while they run.
958		if i.ed.IsEmpty() {
959			i.scrollBy(+3)
960			return false
961		}
962	case tui.KeyDown:
963		if i.ed.IsEmpty() {
964			if i.scrollOffset > 0 {
965				i.scrollBy(-3)
966			}
967			return false
968		}
969	}
970
971	// Note: we intentionally do NOT gate the editor on i.busy here.
972	// Typing while the agent is working is supported — submitted
973	// messages are queued and delivered as follow-up turns when the
974	// current turn ends. See the submit handler below.
975
976	if k.Kind == tui.KeyEnter && k.Alt {
977		i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
978		return false
979	}
980
981	// Slash suggestions: intercept up/down/tab/enter when the popup is visible.
982	if i.suggest.Active(i.ed.Value()) {
983		switch k.Kind {
984		case tui.KeyUp:
985			i.suggest.Up()
986			return false
987		case tui.KeyDown:
988			i.suggest.Down()
989			return false
990		case tui.KeyTab:
991			if name := i.suggest.Selection(i.ed.Value()); name != "" {
992				i.ed.SetValue(name)
993				i.suggest.Reset()
994			}
995			return false
996		case tui.KeyEnter:
997			// Enter on an ambiguous or partial slash prefix: complete to the
998			// currently highlighted command and run it. That way typing
999			// "/lo" + enter picks whichever of /login or /logout is selected
1000			// in the popup instead of submitting "/lo" as unknown. Also
1001			// clear the editor so the command doesn't linger after the
1002			// dialog opens/closes.
1003			if name := i.suggest.Selection(i.ed.Value()); name != "" {
1004				i.ed.PushHistory(name)
1005				i.ed.Clear()
1006				i.suggest.Reset()
1007				return i.runSlash(ctx, name)
1008			}
1009		case tui.KeyEsc:
1010			i.ed.Clear()
1011			i.suggest.Reset()
1012			return false
1013		}
1014	}
1015
1016	if submit := i.ed.HandleKey(k); submit {
1017		text := strings.TrimRight(i.ed.Value(), "\n")
1018		if text == "" {
1019			return false
1020		}
1021		i.ed.PushHistory(text)
1022		i.ed.Clear()
1023		i.suggest.Reset()
1024
1025		if looksLikeSlashCommand(text) {
1026			head := text
1027			rest := ""
1028			if idx := strings.IndexAny(text, " \t"); idx >= 0 {
1029				head = text[:idx]
1030				rest = strings.TrimSpace(text[idx:])
1031			}
1032			if !isKnownSlashCommand(text) {
1033				// Try extensions before giving up. Extensions register
1034				// commands by bare name (no leading slash); strip it here.
1035				extName := strings.TrimPrefix(head, "/")
1036				if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1037					go i.invokeExtensionCommand(ctx, extName, rest)
1038					return false
1039				}
1040				i.mu.Lock()
1041				i.statusErr = "unknown command " + head + " — type /help to see the list"
1042				i.statusOK = ""
1043				i.mu.Unlock()
1044				return false
1045			}
1046			// Slash commands run regardless of busy state. Commands that
1047			// would mutate the transcript or replace the agent (/clear,
1048			// /compact, /logout, /login, /model) cancel the active turn
1049			// first and wait for the goroutine to wind down so they don't
1050			// race with a streaming response. Safe commands (/help,
1051			// /jump, /sessions, /lock, /unlock, /exit) run immediately
1052			// without disturbing the active turn.
1053			if slashCancelsTurn(head) {
1054				i.cancelAndWaitForIdle()
1055			}
1056			return i.runSlash(ctx, text)
1057		}
1058
1059		if i.agent == nil {
1060			i.mu.Lock()
1061			i.statusErr = "not logged in. type /login first."
1062			i.mu.Unlock()
1063			return false
1064		}
1065		// If a turn is already in flight, queue this prompt instead of
1066		// starting a second one. The drain loop at the end of startTurn
1067		// will pick it up when the current turn finishes.
1068		i.mu.Lock()
1069		if i.busy {
1070			i.queued = append(i.queued, text)
1071			i.mu.Unlock()
1072			i.invalidate()
1073			return false
1074		}
1075		i.mu.Unlock()
1076		i.startTurn(ctx, text)
1077	}
1078	return false
1079}
1080
1081// invokeExtensionCommand fires an extension-registered slash command
1082// in a background goroutine, awaits the response, and applies the
1083// requested action (prompt / insert / display / noop). Errors and
1084// timeouts surface as a status_err line.
1085func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
1086	resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
1087	if err != nil {
1088		i.mu.Lock()
1089		i.statusErr = "extension /" + name + ": " + err.Error()
1090		i.statusOK = ""
1091		i.mu.Unlock()
1092		i.invalidate()
1093		return
1094	}
1095	if resp.Error != "" {
1096		i.mu.Lock()
1097		i.statusErr = "extension /" + name + ": " + resp.Error
1098		i.statusOK = ""
1099		i.mu.Unlock()
1100		i.invalidate()
1101		return
1102	}
1103	switch resp.Action {
1104	case "prompt":
1105		if strings.TrimSpace(resp.Prompt) == "" {
1106			return
1107		}
1108		i.startTurn(i.runCtx, resp.Prompt)
1109	case "insert":
1110		i.ed.Insert(resp.Insert)
1111		i.invalidate()
1112	case "display":
1113		i.appendExtensionNote(name, resp.Display, "info")
1114	case "noop", "":
1115		// nothing
1116	default:
1117		i.mu.Lock()
1118		i.statusErr = "extension /" + name + ": unknown action " + resp.Action
1119		i.mu.Unlock()
1120		i.invalidate()
1121	}
1122}
1123
1124// appendExtensionNote renders an extension-originated note in the
1125// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
1126// "success" (tool/ok green).
1127func (i *Interactive) appendExtensionNote(extName, msg, level string) {
1128	if msg == "" {
1129		return
1130	}
1131	i.mu.Lock()
1132	defer i.mu.Unlock()
1133	color := i.cfg.Theme.Muted
1134	switch level {
1135	case "warn":
1136		color = i.cfg.Theme.Warning
1137	case "error":
1138		color = i.cfg.Theme.Error
1139	case "success":
1140		color = i.cfg.Theme.Tool
1141	}
1142	prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
1143	for _, line := range strings.Split(msg, "\n") {
1144		i.statusOK = "" // clear any stale ok
1145		i.statusErr = ""
1146		i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
1147	}
1148}
1149
1150// HostHooks implementation for the extension manager. The manager
1151// holds an interface, not a concrete *Interactive, so these methods
1152// are the only thing the manager sees.
1153
1154// Notify is the manager's NotifyFromExt entry point.
1155func (i *Interactive) Notify(extName, level, message string) {
1156	i.appendExtensionNote(extName, message, level)
1157	i.invalidate()
1158}
1159
1160// Submit feeds text through the agent loop as if the user had typed it.
1161func (i *Interactive) Submit(text string) {
1162	i.startTurn(i.runCtx, text)
1163}
1164
1165// Insert places text at the cursor in the editor.
1166func (i *Interactive) Insert(text string) {
1167	i.ed.Insert(text)
1168	i.invalidate()
1169}
1170
1171// Display appends a styled note from extName to the chat without a
1172// model call.
1173func (i *Interactive) Display(extName, text string) {
1174	i.appendExtensionNote(extName, text, "info")
1175	i.invalidate()
1176}
1177
1178func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1179	parts := strings.Fields(cmd)
1180	switch parts[0] {
1181	case "/exit":
1182		return true
1183	case "/clear":
1184		if i.agent != nil {
1185			i.agent.SetMessages(nil)
1186		}
1187		i.mu.Lock()
1188		i.toolCalls = map[string]*tui.ToolCallView{}
1189		i.toolOrder = nil
1190		i.statusErr = ""
1191		i.statusOK = ""
1192		i.helpBlock = nil
1193		i.parkedTurn = 0
1194		i.parkedTotal = 0
1195		i.scrollOffset = 0
1196		i.extNotes = nil
1197		i.view.InvalidateRenderCache()
1198		i.mu.Unlock()
1199	case "/help":
1200		i.mu.Lock()
1201		i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
1202		i.statusErr = ""
1203		i.statusOK = ""
1204		// Pin the viewport to the newest content so the help block,
1205		// which we just appended to the end of the transcript, is
1206		// what the user actually sees.
1207		i.scrollOffset = 0
1208		i.mu.Unlock()
1209	case "/login":
1210		i.dialog.Open()
1211	case "/logout":
1212		if len(parts) >= 2 {
1213			// Explicit target: /logout anthropic | openai | all
1214			i.doLogout(parts[1])
1215			break
1216		}
1217		// No arg: open the picker over whichever providers are
1218		// currently logged in. If nothing's logged in, bail with a
1219		// status line.
1220		i.openLogoutDialog()
1221	case "/model":
1222		if len(parts) >= 2 {
1223			i.applyModelSelection("", parts[1])
1224		} else {
1225			i.modelDialog.Open(i.cfg.Model)
1226		}
1227	case "/sessions":
1228		i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
1229	case "/jump":
1230		i.openJumpDialog(parts[1:])
1231	case "/btw":
1232		i.openBtwDialog(parts[1:])
1233	case "/skills":
1234		i.openSkillsDialog()
1235	case "/compact":
1236		i.runCompact(ctx, false)
1237	case "/lock":
1238		if i.cfg.Sandbox == nil {
1239			i.mu.Lock()
1240			i.statusErr = "sandbox not available in this build"
1241			i.mu.Unlock()
1242			break
1243		}
1244		i.cfg.Sandbox.Lock()
1245		i.mu.Lock()
1246		i.statusOK = "locked to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
1247		i.statusErr = ""
1248		i.mu.Unlock()
1249	case "/unlock":
1250		if i.cfg.Sandbox == nil {
1251			i.mu.Lock()
1252			i.statusErr = "sandbox not available in this build"
1253			i.mu.Unlock()
1254			break
1255		}
1256		i.cfg.Sandbox.Unlock()
1257		i.mu.Lock()
1258		i.statusOK = "unlocked"
1259		i.statusErr = ""
1260		i.mu.Unlock()
1261	case "/reload-ext":
1262		i.runReloadExt(ctx)
1263	case "/yolo":
1264		i.runYoloOn()
1265	default:
1266		// Last-resort fallback: try the extension manager. Built-in
1267		// cases above always win; this branch only fires for slash
1268		// commands the extension manager registered. Same routing as
1269		// the editor's submit-handler dispatch path so the autocomplete
1270		// "enter on highlighted suggestion" flow also works.
1271		extName := strings.TrimPrefix(parts[0], "/")
1272		if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1273			rest := ""
1274			if len(parts) > 1 {
1275				rest = strings.Join(parts[1:], " ")
1276			}
1277			go i.invokeExtensionCommand(ctx, extName, rest)
1278			return false
1279		}
1280		i.mu.Lock()
1281		i.statusErr = "unknown command: " + parts[0]
1282		i.mu.Unlock()
1283	}
1284	return false
1285}
1286
1287// openLogoutDialog shows the provider picker for `/logout` with no
1288// argument. Only providers the user is currently logged into are
1289// listed, plus an "all" entry when more than one is present. If
1290// nothing's logged in, writes a status line instead of opening an
1291// empty dialog.
1292func (i *Interactive) openLogoutDialog() {
1293	if i.cfg.AuthManager == nil {
1294		i.mu.Lock()
1295		i.statusErr = "no auth manager configured"
1296		i.mu.Unlock()
1297		i.invalidate()
1298		return
1299	}
1300	store := i.cfg.AuthManager.Store()
1301	if store == nil {
1302		i.mu.Lock()
1303		i.statusErr = "auth store is not available"
1304		i.mu.Unlock()
1305		i.invalidate()
1306		return
1307	}
1308	creds, err := store.Load()
1309	if err != nil {
1310		i.mu.Lock()
1311		i.statusErr = "read auth store: " + err.Error()
1312		i.mu.Unlock()
1313		i.invalidate()
1314		return
1315	}
1316
1317	var items []logoutItem
1318	for _, p := range []string{"anthropic", "openai"} {
1319		if creds.Has(p) {
1320			items = append(items, logoutItem{
1321				label:  p,
1322				target: p,
1323				method: creds.Method(p),
1324			})
1325		}
1326	}
1327	if len(items) == 0 {
1328		i.mu.Lock()
1329		i.statusOK = "no credentials stored; already logged out"
1330		i.statusErr = ""
1331		i.mu.Unlock()
1332		i.invalidate()
1333		return
1334	}
1335	if len(items) > 1 {
1336		items = append(items, logoutItem{label: "all", target: "all"})
1337	}
1338
1339	i.logoutDialog.Open(items)
1340	i.invalidate()
1341}
1342
1343// doLogout clears credentials for the given provider (or all providers)
1344// from auth.json. If the active agent was using those credentials, it
1345// is torn down so the user is forced through /login before their next
1346// prompt.
1347//
1348// target: "anthropic" | "openai" | "all"
1349func (i *Interactive) doLogout(target string) {
1350	if i.cfg.AuthManager == nil {
1351		i.mu.Lock()
1352		i.statusErr = "no auth manager configured"
1353		i.mu.Unlock()
1354		return
1355	}
1356	store := i.cfg.AuthManager.Store()
1357	if store == nil {
1358		i.mu.Lock()
1359		i.statusErr = "auth store is not available"
1360		i.mu.Unlock()
1361		return
1362	}
1363
1364	var providers []string
1365	switch target {
1366	case "", "all":
1367		providers = []string{"anthropic", "openai"}
1368	case "anthropic", "openai":
1369		providers = []string{target}
1370	default:
1371		i.mu.Lock()
1372		i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"
1373		i.mu.Unlock()
1374		return
1375	}
1376
1377	var errs []string
1378	clearedCurrent := false
1379	for _, p := range providers {
1380		if err := store.Clear(p); err != nil {
1381			errs = append(errs, p+": "+err.Error())
1382			continue
1383		}
1384		if p == i.cfg.Provider {
1385			clearedCurrent = true
1386		}
1387	}
1388
1389	i.mu.Lock()
1390	defer i.mu.Unlock()
1391	if len(errs) > 0 {
1392		i.statusErr = "logout errors: " + strings.Join(errs, "; ")
1393		return
1394	}
1395	i.statusErr = ""
1396	if clearedCurrent {
1397		// The running agent was using a credential we just wiped. Drop
1398		// it so prompts can't go out with the stale client, and hint at
1399		// /login.
1400		i.agent = nil
1401		i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
1402	} else {
1403		i.statusOK = "logged out of " + strings.Join(providers, ", ")
1404	}
1405}
1406
1407func (i *Interactive) startAPIKeyFlow(provider string) {
1408	url, err := i.cfg.AuthManager.StartAPIKey(provider)
1409	if err != nil {
1410		i.dialog.ShowResult(false, err.Error())
1411		return
1412	}
1413	i.dialog.ShowWaiting(url)
1414}
1415
1416func (i *Interactive) startOAuthFlow(provider string) {
1417	url, err := i.cfg.AuthManager.StartOAuth(provider)
1418	if err != nil {
1419		i.dialog.ShowResult(false, err.Error())
1420		return
1421	}
1422	i.dialog.ShowWaiting(url)
1423}
1424
1425// applyModelSelection switches the active model (and provider, if the
1426// new model belongs to a different one). It rebuilds the underlying
1427// client when needed so the provider wire-protocol matches.
1428// cancelAndWaitForIdle cancels the active turn (if any) and blocks
1429// briefly until the turn goroutine has updated i.busy = false. Used
1430// before destructive slash commands so transcript-mutating work
1431// (/clear, /compact, /logout, /login completion, cross-provider
1432// /model swap) doesn't race with the still-running stream.
1433//
1434// The wait is bounded; if the turn doesn't release within the timeout
1435// we proceed anyway. Worst case is a brief overlap that the agent's
1436// own mutex protects against.
1437func (i *Interactive) cancelAndWaitForIdle() {
1438	i.mu.Lock()
1439	busy := i.busy
1440	cancel := i.cancelTurn
1441	i.mu.Unlock()
1442	if !busy {
1443		return
1444	}
1445	if cancel != nil {
1446		cancel()
1447	}
1448	deadline := time.Now().Add(2 * time.Second)
1449	for time.Now().Before(deadline) {
1450		i.mu.Lock()
1451		done := !i.busy
1452		i.mu.Unlock()
1453		if done {
1454			return
1455		}
1456		time.Sleep(10 * time.Millisecond)
1457	}
1458}
1459
1460// openBtwDialog opens the side-chat overlay with a frozen snapshot
1461// of the current main session. The optional argument is auto-
1462// submitted as the first question, so '/btw does X work?' fires the
1463// model call immediately instead of just opening an empty dialog.
1464func (i *Interactive) openBtwDialog(args []string) {
1465	if i.agent == nil {
1466		i.mu.Lock()
1467		i.statusErr = "not logged in. type /login first."
1468		i.mu.Unlock()
1469		return
1470	}
1471	seed := strings.TrimSpace(strings.Join(args, " "))
1472	i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed)
1473	i.invalidate()
1474}
1475
1476// openSkillsDialog opens the skill inspector. The picker reflects
1477// whatever SkillSnapshot returns at call time, so edits to a
1478// SKILL.md made during a session show up on the next /skills.
1479func (i *Interactive) openSkillsDialog() {
1480	var list []*skills.Skill
1481	if i.cfg.SkillSnapshot != nil {
1482		list = i.cfg.SkillSnapshot()
1483	}
1484	i.skillsDialog.Open(list)
1485	i.invalidate()
1486}
1487
1488// openJumpDialog builds a /jump picker from the current transcript.
1489// If the user typed "/jump foo" with a filter and it matches exactly
1490// one turn, jump there directly without showing the dialog.
1491func (i *Interactive) openJumpDialog(args []string) {
1492	if i.view == nil || len(i.view.Messages) == 0 {
1493		i.mu.Lock()
1494		i.statusErr = "nothing to jump to \u2014 the session is empty"
1495		i.mu.Unlock()
1496		return
1497	}
1498	filter := strings.TrimSpace(strings.Join(args, " "))
1499	i.jumpDialog.Open(i.view.Messages, filter)
1500	// Shortcut: with a filter argument that matches exactly one turn,
1501	// jump immediately and skip the picker.
1502	if filter != "" {
1503		if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
1504			t := tgts[0]
1505			i.jumpDialog.Close()
1506			i.applyJumpSelection(t.MessageIdx, t.TurnNo)
1507		}
1508	}
1509}
1510
1511// applyJumpSelection scrolls the chat viewport so the user message at
1512// msgIdx is visible at (or near) the top of the chat area. Uses the
1513// anchor slice returned by view.BuildWithAnchors so the mapping from
1514// message index to row is exact, regardless of variable-height tool
1515// blocks above the target.
1516func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
1517	cols := i.lastCols()
1518	chat, anchors := i.view.BuildWithAnchors(cols)
1519	var row int
1520	found := false
1521	for _, a := range anchors {
1522		if a.MessageIdx == msgIdx {
1523			row = a.Row
1524			found = true
1525			break
1526		}
1527	}
1528	if !found {
1529		i.mu.Lock()
1530		i.statusErr = "could not resolve jump target"
1531		i.mu.Unlock()
1532		return
1533	}
1534
1535	chatLen := len(chat)
1536	page := i.chatPage()
1537	if page < 1 {
1538		page = 1
1539	}
1540	// scrollOffset is measured from the bottom of the chat slice, so
1541	// to place `row` at the top of the viewport we want:
1542	//     chatLen - scrollOffset - page == row
1543	// Solve for scrollOffset and clamp to [0, chatLen-page].
1544	offset := chatLen - (row + page)
1545	if offset < 0 {
1546		offset = 0
1547	}
1548	maxOffset := chatLen - page
1549	if maxOffset < 0 {
1550		maxOffset = 0
1551	}
1552	if offset > maxOffset {
1553		offset = maxOffset
1554	}
1555
1556	i.mu.Lock()
1557	i.scrollOffset = offset
1558	i.parkedTurn = turnNo
1559	i.parkedTotal = totalTurnsLocked(i.view.Messages)
1560	i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
1561	i.statusErr = ""
1562	i.mu.Unlock()
1563}
1564
1565// totalTurnsLocked counts user messages in the transcript. Caller is
1566// assumed to hold i.mu (the name is a mild reminder; this function
1567// itself doesn't touch shared state beyond the slice it's handed).
1568func totalTurnsLocked(msgs []provider.Message) int {
1569	n := 0
1570	for _, m := range msgs {
1571		if m.Role == provider.RoleUser {
1572			n++
1573		}
1574	}
1575	return n
1576}
1577
1578// applySessionSelection loads the given session via the cli-provided
1579// callback and parks the viewport on the last turn so the user lands
1580// looking at where the conversation left off (their last prompt at the
1581// top of the chat, the assistant's last reply right below). Older
1582// history is one scroll up; pgdn or end snaps to the current tail.
1583//
1584// Without this, scrollOffset stayed at 0 (pinned to the live tail),
1585// which on a long resumed session showed only the last few rows of
1586// the final assistant message — the user read that as "only one liner
1587// happened, the resume didn't work".
1588func (i *Interactive) applySessionSelection(path string) {
1589	if i.cfg.LoadSession == nil {
1590		i.mu.Lock()
1591		i.statusErr = "session loading is not wired in this build"
1592		i.mu.Unlock()
1593		return
1594	}
1595	if err := i.cfg.LoadSession(path); err != nil {
1596		i.mu.Lock()
1597		i.statusErr = err.Error()
1598		i.mu.Unlock()
1599		return
1600	}
1601
1602	i.mu.Lock()
1603	i.statusOK = "resumed session: " + path
1604	i.statusErr = ""
1605	i.parkedTurn = 0
1606	i.parkedTotal = 0
1607	i.view.InvalidateRenderCache()
1608	// Pull the freshly-loaded transcript into the view so the anchor
1609	// math below sees the post-resume messages, not the empty pre-load
1610	// state. redraw() does the same on its next pass; we just front-run
1611	// it here to compute the scroll target.
1612	if i.agent != nil {
1613		i.view.Messages = i.agent.Messages()
1614	}
1615	msgs := i.view.Messages
1616	i.mu.Unlock()
1617
1618	i.scrollToLastTurn(msgs)
1619}
1620
1621// scrollToLastTurn parks the viewport at the most recent user turn,
1622// or at the top if the transcript has no user messages. Used after
1623// resume so the user lands looking at where they left off.
1624func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
1625	if len(msgs) == 0 {
1626		i.mu.Lock()
1627		i.scrollOffset = 0
1628		i.mu.Unlock()
1629		return
1630	}
1631	// Find the last user message index.
1632	lastUser := -1
1633	turnNo, totalTurns := 0, 0
1634	for idx, m := range msgs {
1635		if m.Role == provider.RoleUser {
1636			totalTurns++
1637			lastUser = idx
1638		}
1639	}
1640	if lastUser < 0 {
1641		i.mu.Lock()
1642		i.scrollOffset = 0
1643		i.mu.Unlock()
1644		return
1645	}
1646	turnNo = totalTurns
1647
1648	cols := i.lastCols()
1649	chat, anchors := i.view.BuildWithAnchors(cols)
1650	var row int
1651	found := false
1652	for _, a := range anchors {
1653		if a.MessageIdx == lastUser {
1654			row = a.Row
1655			found = true
1656			break
1657		}
1658	}
1659	if !found {
1660		i.mu.Lock()
1661		i.scrollOffset = 0
1662		i.mu.Unlock()
1663		return
1664	}
1665
1666	chatLen := len(chat)
1667	page := i.chatPage()
1668	if page < 1 {
1669		page = 1
1670	}
1671	offset := chatLen - (row + page)
1672	if offset < 0 {
1673		offset = 0
1674	}
1675	maxOffset := chatLen - page
1676	if maxOffset < 0 {
1677		maxOffset = 0
1678	}
1679	if offset > maxOffset {
1680		offset = maxOffset
1681	}
1682
1683	i.mu.Lock()
1684	i.scrollOffset = offset
1685	// Mark the parked-turn footer so the user sees "viewing turn N of
1686	// M · pgdn to catch up" — same affordance as /jump. Tells them at
1687	// a glance that they're looking at history, not the live tail.
1688	if offset > 0 {
1689		i.parkedTurn = turnNo
1690		i.parkedTotal = totalTurns
1691	}
1692	i.mu.Unlock()
1693	i.invalidate()
1694}
1695
1696func (i *Interactive) applyModelSelection(prov, model string) {
1697	if model == "" {
1698		return
1699	}
1700	m, err := provider.FindModel(prov, model)
1701	if err != nil {
1702		i.mu.Lock()
1703		i.statusErr = err.Error()
1704		i.mu.Unlock()
1705		return
1706	}
1707	// Same provider? Just swap the model on the existing agent.
1708	if i.agent != nil && m.Provider == i.cfg.Provider {
1709		i.mu.Lock()
1710		i.cfg.Model = m.ID
1711		i.agent.Model = m.ID
1712		i.statusOK = "model: " + m.ID
1713		i.statusErr = ""
1714		i.mu.Unlock()
1715		if i.cfg.PersistModel != nil {
1716			i.cfg.PersistModel(i.cfg.Provider, m.ID)
1717		}
1718		return
1719	}
1720	// Different provider: rebuild agent (needs credentials for target provider).
1721	if i.cfg.BuildAgentFor == nil {
1722		i.mu.Lock()
1723		i.statusErr = "cannot switch provider: no builder configured"
1724		i.mu.Unlock()
1725		return
1726	}
1727	// Snapshot the current transcript and cumulative usage BEFORE we
1728	// build the replacement agent so we can hand them off. Without
1729	// this the user perceives the entire session as wiped on a
1730	// cross-provider /model swap.
1731	var carryMsgs []provider.Message
1732	var carryCost provider.Usage
1733	if i.agent != nil {
1734		carryMsgs = i.agent.Messages()
1735		carryCost = i.agent.Cost()
1736	}
1737
1738	ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)
1739	if err != nil {
1740		i.mu.Lock()
1741		i.statusErr = err.Error()
1742		i.mu.Unlock()
1743		return
1744	}
1745
1746	// Replay the transcript and seed the cost on the freshly-built
1747	// agent. Messages travel cleanly between providers because they
1748	// use the same provider.Message shape; tool-call ids are local
1749	// to a turn so cross-provider continuation never confuses the
1750	// new model (it just sees the assistant's reply, no orphan
1751	// tool_use blocks because /model swaps are gated to idle state).
1752	if len(carryMsgs) > 0 {
1753		ag.SetMessages(carryMsgs)
1754	}
1755	ag.SeedCost(carryCost)
1756
1757	i.mu.Lock()
1758	i.agent = ag
1759	i.cfg.Provider = p
1760	i.cfg.Model = md
1761	i.statusOK = "switched to " + p + " / " + md
1762	i.statusErr = ""
1763	// Render cache keys are width+content based, so the new agent's
1764	// identical messages will reuse the existing entries. Nothing
1765	// to invalidate.
1766	i.mu.Unlock()
1767	if i.cfg.PersistModel != nil {
1768		i.cfg.PersistModel(p, md)
1769	}
1770}
1771
1772func (i *Interactive) handleAuthEvent(ev auth.Event) {
1773	switch ev.Kind {
1774	case "started":
1775		i.dialog.ShowWaiting(ev.URL)
1776	case "browser_open":
1777		// no-op
1778	case "error":
1779		i.dialog.ShowResult(false, ev.Message)
1780	case "success":
1781		// Rebuild the agent with the fresh credential.
1782		ag, prov, model, err := i.cfg.BuildAgent()
1783		if err != nil {
1784			i.dialog.ShowResult(false, err.Error())
1785			return
1786		}
1787		i.mu.Lock()
1788		i.agent = ag
1789		i.cfg.Provider = prov
1790		i.cfg.Model = model
1791		i.statusErr = ""
1792		i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
1793		i.mu.Unlock()
1794		i.dialog.ShowResult(true, "")
1795	}
1796}
1797
1798// runCompact invokes core.Agent.Compact and reflects the progress in
1799// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
1800// cancel via the same cancelTurn channel used for normal turns.
1801//
1802// When auto is true the spinner message is pinned to "condensing
1803// history" and the status bar surfaces "(auto)" next to the context
1804// percentage so it's obvious the system triggered this, not the user.
1805func (i *Interactive) runCompact(parent context.Context, auto bool) {
1806	if i.agent == nil {
1807		i.mu.Lock()
1808		i.statusErr = "not logged in. type /login first."
1809		i.mu.Unlock()
1810		return
1811	}
1812	ctx, cancel := context.WithCancel(parent)
1813	i.mu.Lock()
1814	i.busy = true
1815	if auto {
1816		i.spin.StartFixed("condensing history")
1817		i.autoCompacting = true
1818		i.statusOK = "condensing history… (esc to cancel)"
1819	} else {
1820		i.spin.Start()
1821		i.statusOK = "compacting..."
1822	}
1823	i.cancelTurn = cancel
1824	i.statusErr = ""
1825	i.streaming.Reset()
1826	i.streamOn = true
1827	i.scrollOffset = 0
1828	i.helpBlock = nil
1829	i.mu.Unlock()
1830	i.invalidate()
1831
1832	go func() {
1833		sink := func(delta string) {
1834			i.mu.Lock()
1835			i.streaming.WriteString(delta)
1836			i.mu.Unlock()
1837			i.invalidate()
1838		}
1839		summary, err := i.agent.Compact(ctx, 4, sink)
1840		i.mu.Lock()
1841		i.busy = false
1842		i.streamOn = false
1843		i.streaming.Reset()
1844		i.cancelTurn = nil
1845		i.autoCompacting = false
1846		switch {
1847		case err != nil && ctx.Err() != nil:
1848			i.statusErr = ""
1849			if auto {
1850				i.statusOK = "auto-condense cancelled"
1851			} else {
1852				i.statusOK = "compaction cancelled"
1853			}
1854		case err != nil:
1855			i.statusErr = "compaction failed: " + err.Error()
1856			i.statusOK = ""
1857		default:
1858			i.statusErr = ""
1859			i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary))
1860			i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
1861			i.toolCalls = map[string]*tui.ToolCallView{}
1862			i.toolOrder = nil
1863			// Transcript was rewritten in place — purge the per-message
1864			// render cache so stale entries keyed on the old messages
1865			// don't linger.
1866			i.view.InvalidateRenderCache()
1867		}
1868		i.mu.Unlock()
1869		i.invalidate()
1870	}()
1871}
1872
1873func (i *Interactive) startTurn(parent context.Context, prompt string) {
1874	if i.agent == nil {
1875		return
1876	}
1877	ctx, cancel := context.WithCancel(parent)
1878	i.mu.Lock()
1879	i.busy = true
1880	i.spin.Start()
1881	i.cancelTurn = cancel
1882	i.statusErr = ""
1883	i.statusOK = ""
1884	i.streaming.Reset()
1885	i.streamOn = true
1886	i.toolCalls = map[string]*tui.ToolCallView{}
1887	i.toolOrder = nil
1888	i.scrollOffset = 0 // jump back to the bottom on new turn
1889	i.parkedTurn = 0   // starting a turn clears the /jump parked state
1890	i.parkedTotal = 0
1891	i.helpBlock = nil // hide the help block once the user asks something
1892	i.mu.Unlock()
1893	i.invalidate()
1894
1895	sink := func(ev core.AgentEvent) {
1896		i.handleEvent(ev)
1897		i.invalidate()
1898	}
1899
1900	go func() {
1901		err := i.agent.Prompt(ctx, prompt, nil, sink)
1902		i.mu.Lock()
1903		i.busy = false
1904		i.streamOn = false
1905		i.cancelTurn = nil
1906		if err != nil && ctx.Err() == nil {
1907			i.statusErr = err.Error()
1908		}
1909		// Pop the next queued message, if any, and relaunch.
1910		var next string
1911		var hasNext bool
1912		if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
1913			next, i.queued = i.queued[0], i.queued[1:]
1914			hasNext = true
1915		}
1916		// If the turn was cancelled or errored, drop the queue so the
1917		// user isn't bombarded with stale messages after an interrupt.
1918		if ctx.Err() != nil || err != nil {
1919			i.queued = nil
1920		}
1921		// Decide whether the next thing to do is an auto-compaction.
1922		// Only fires when the turn completed cleanly AND the queue is
1923		// empty (otherwise a queued message would race the condense).
1924		shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
1925		i.mu.Unlock()
1926		i.invalidate()
1927		parent := i.runCtx
1928		if parent == nil {
1929			parent = context.Background()
1930		}
1931		switch {
1932		case hasNext:
1933			i.startTurn(parent, next)
1934		case shouldAutoCompact:
1935			i.runCompact(parent, true)
1936		}
1937	}()
1938}
1939
1940// autoCompactThreshold is the context-window fraction at which the
1941// agent will auto-compact after a turn ends. 0.85 leaves enough
1942// headroom for one more user prompt + response before we bump the
1943// hard limit.
1944const autoCompactThreshold = 0.85
1945
1946// shouldAutoCompactLocked reports whether the last turn pushed context
1947// usage past the auto-compact threshold. Must be called with i.mu
1948// held; it reads lastCtxInput and the current model's context window.
1949func (i *Interactive) shouldAutoCompactLocked() bool {
1950	if i.agent == nil {
1951		return false
1952	}
1953	if i.autoCompacting {
1954		return false
1955	}
1956	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
1957	if err != nil || m.ContextWindow <= 0 {
1958		return false
1959	}
1960	if i.lastCtxInput <= 0 {
1961		return false
1962	}
1963	return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
1964}
1965
1966func (i *Interactive) handleEvent(ev core.AgentEvent) {
1967	i.mu.Lock()
1968	defer i.mu.Unlock()
1969	switch e := ev.(type) {
1970	case core.EvTextDelta:
1971		i.streaming.WriteString(e.Delta)
1972	case core.EvAssistantMessage:
1973		i.streaming.Reset()
1974		i.streamOn = false
1975		if i.cfg.OnAssistant != nil {
1976			i.cfg.OnAssistant(e.Message)
1977		}
1978	case core.EvToolCall:
1979		tcv := &tui.ToolCallView{
1980			ID:   e.ID,
1981			Name: e.Name,
1982			Args: shortArgs(e.Args),
1983		}
1984		i.toolCalls[e.ID] = tcv
1985		i.toolOrder = append(i.toolOrder, e.ID)
1986	case core.EvToolResult:
1987		if tc, ok := i.toolCalls[e.ID]; ok {
1988			tc.Done = true
1989			tc.Error = e.Result.IsError
1990			var text strings.Builder
1991			for _, c := range e.Result.Content {
1992				if tb, ok := c.(provider.TextBlock); ok {
1993					if text.Len() > 0 {
1994						text.WriteString("\n")
1995					}
1996					text.WriteString(tb.Text)
1997				}
1998			}
1999			tc.Result = text.String()
2000		}
2001		if i.cfg.OnToolResult != nil {
2002			i.cfg.OnToolResult(e.ID, e.Result)
2003		}
2004	case core.EvUsage:
2005		i.cumUsage = e.Cumulative
2006		if e.Usage.InputTokens > 0 {
2007			i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
2008		}
2009	case core.EvTurnEnd:
2010		if e.Stop == provider.StopAborted {
2011			// Aborted turn: discard the partial streaming text (it is not
2012			// persisted in the transcript) and clear any transient error.
2013			i.streaming.Reset()
2014			i.streamOn = false
2015			i.statusErr = ""
2016			i.statusOK = "cancelled"
2017			return
2018		}
2019		if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") {
2020			i.statusErr = e.Err.Error()
2021		}
2022	}
2023}
2024
2025// Agent returns the current agent, if any. Used by cli.go to flush the
2026// final transcript to the session file.
2027func (i *Interactive) Agent() *core.Agent {
2028	i.mu.Lock()
2029	defer i.mu.Unlock()
2030	return i.agent
2031}
2032
2033func shortArgs(raw json.RawMessage) string {
2034	var v any
2035	if err := json.Unmarshal(raw, &v); err != nil {
2036		return ""
2037	}
2038	if m, ok := v.(map[string]any); ok {
2039		for _, k := range []string{"path", "file_path", "command"} {
2040			if s, ok := m[k].(string); ok {
2041				if len(s) > 60 {
2042					s = s[:57] + "..."
2043				}
2044				return s
2045			}
2046		}
2047	}
2048	b, _ := json.Marshal(v)
2049	s := string(b)
2050	if len(s) > 60 {
2051		s = s[:57] + "..."
2052	}
2053	return s
2054}
2055
2056// silence unused import in some build configs
2057var _ = fmt.Sprintf
2058
2059// runReloadExt triggers a live reload of every extension (discovered
2060// + explicit). Runs on a goroutine so the TUI stays responsive; the
2061// Manager.Reload takes a couple of hundred ms to shut down subprocs
2062// and respawn them. Shows a status line throughout.
2063func (i *Interactive) runReloadExt(ctx context.Context) {
2064	if i.cfg.Extensions == nil {
2065		i.mu.Lock()
2066		i.statusErr = "no extension manager in this build"
2067		i.mu.Unlock()
2068		i.invalidate()
2069		return
2070	}
2071	i.mu.Lock()
2072	i.statusOK = "reloading extensions…"
2073	i.statusErr = ""
2074	i.mu.Unlock()
2075	i.invalidate()
2076
2077	go func() {
2078		stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
2079		msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
2080		if len(stats.Errors) > 0 {
2081			msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
2082		}
2083		i.mu.Lock()
2084		i.statusOK = msg
2085		i.statusErr = ""
2086		i.mu.Unlock()
2087		i.invalidate()
2088	}()
2089}
2090
2091// Confirm implements core.Confirmer. The agent goroutine calls
2092// this synchronously before every tool invocation when --no-yolo is
2093// active. We push the request onto the confirmDialog queue, trigger
2094// a redraw, and block the caller until the user answers.
2095//
2096// If the session is cancelled or the TUI exits mid-prompt, any
2097// pending request is refused via CancelAll so the agent doesn't
2098// deadlock.
2099func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
2100	resp := make(chan core.ConfirmDecision, 1)
2101	i.confirmDialog.Enqueue(&confirmRequest{
2102		toolName: toolName,
2103		preview:  preview,
2104		resp:     resp,
2105	})
2106	i.invalidate()
2107	return <-resp
2108}
2109
2110// runYoloOn disables --no-yolo for the rest of the session. Tool
2111// calls run without prompting after this; there's intentionally no
2112// way to re-enable gating mid-session, if the user wants that back
2113// they can exit and restart with --no-yolo.
2114func (i *Interactive) runYoloOn() {
2115	if i.cfg.ConfirmGate == nil {
2116		i.mu.Lock()
2117		i.statusOK = "yolo mode is already on (no --no-yolo in this session)"
2118		i.statusErr = ""
2119		i.mu.Unlock()
2120		i.invalidate()
2121		return
2122	}
2123	i.cfg.ConfirmGate.AllowAll()
2124	// Also auto-allow any currently pending confirmation so the
2125	// agent doesn't deadlock if /yolo is typed while a prompt is
2126	// open.
2127	i.confirmDialog.AllowAllPending()
2128	i.mu.Lock()
2129	i.statusOK = "yolo engaged: no more tool-call confirmations this session"
2130	i.statusErr = ""
2131	i.mu.Unlock()
2132	i.invalidate()
2133}

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