v0.0.21 4dde650

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