v0.0.15 3ff6d9e

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