v0.0.35 99c9ba8

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

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 477

↑↓ moveenter openesc close