v0.0.10 0a69274

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

↑↓ moveenter openesc close