v0.0.17 36e0efe

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