v0.0.23 74709a0

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

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