v0.0.4 728cf4e

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	ZotHome string
53
54	// Sandbox is the shared sandbox pointer. Toggled by /lock and /unlock.
55	Sandbox *tools.Sandbox
56
57	// LoadSession swaps the current session for the one at path. The
58	// callback returns the new agent message slice so the TUI can invalidate.
59	LoadSession func(path string) error
60
61	// PersistModel is called whenever the user switches model or provider.
62	// It should update config.json and (if there's an active session)
63	// write a new meta row so resume picks up the same model.
64	PersistModel func(providerName, model string)
65
66	OnAssistant  func(m provider.Message)
67	OnToolResult func(id string, r core.ToolResult)
68}
69
70// Interactive is the TUI chat loop.
71type Interactive struct {
72	cfg  InteractiveConfig
73	view *tui.View
74	ed   *tui.Editor
75	rend *tui.Renderer
76
77	mu           sync.Mutex
78	agent        *core.Agent
79	streaming    strings.Builder
80	streamOn     bool
81	toolCalls    map[string]*tui.ToolCallView
82	toolOrder    []string
83	statusErr    string
84	statusOK     string
85	helpBlock    []string // rendered above the chat when /help was typed
86	cumUsage     provider.Usage
87	lastCtxInput int // input_tokens of the most recent turn — approximates current context size
88	busy         bool
89	dirty        chan struct{}
90	cancelTurn   context.CancelFunc
91	scrollOffset int // rows from the bottom; 0 = pinned to latest
92
93	// Messages typed while a turn is in flight. Each is delivered as
94	// its own follow-up turn once the current one finishes. Rendered
95	// above the status bar as "sliding in: ..." chips.
96	queued []string
97
98	// runCtx is the top-level context passed to Run(). Follow-up turns
99	// drained from `queued` are started against this context so they
100	// survive past the ctx of the key event that enqueued them.
101	runCtx context.Context
102
103	// autoCompacting is true while a model-triggered compaction is in
104	// flight. Surfaced in the status bar so the user can tell a
105	// condense pass from a regular assistant turn.
106	autoCompacting bool
107
108	dialog        *loginDialog
109	modelDialog   *modelDialog
110	sessionDialog *sessionDialog
111	suggest       *slashSuggester
112	spin          *spinner
113}
114
115// NewInteractive constructs an Interactive from cfg.
116func NewInteractive(cfg InteractiveConfig) *Interactive {
117	i := &Interactive{
118		cfg: cfg,
119		view: &tui.View{
120			Theme:      cfg.Theme,
121			ImageProto: tui.DetectImageProtocol(),
122		},
123		ed:            tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
124		rend:          tui.NewRenderer(cfg.Terminal),
125		toolCalls:     map[string]*tui.ToolCallView{},
126		dirty:         make(chan struct{}, 8),
127		dialog:        newLoginDialog(),
128		modelDialog:   newModelDialog(),
129		sessionDialog: newSessionDialog(),
130		suggest:       newSlashSuggester(),
131		spin:          newSpinner(),
132	}
133	if cfg.Agent != nil {
134		i.agent = cfg.Agent
135		i.view.Messages = cfg.Agent.Messages()
136	}
137	return i
138}
139
140// Run blocks until the user quits.
141func (i *Interactive) Run(ctx context.Context) error {
142	i.runCtx = ctx
143	term := i.cfg.Terminal
144	restore, err := term.EnterRaw()
145	if err != nil {
146		return err
147	}
148	defer restore()
149
150	_, _ = term.Write([]byte(tui.SeqBracketedPasteOn))
151	_, _ = term.Write([]byte(tui.SeqAltScreenOn))
152	defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
153
154	cols, rows := term.Size()
155	i.rend.Resize(cols, rows)
156	term.OnResize(func() {
157		c, r := term.Size()
158		i.rend.Resize(c, r)
159		i.invalidate()
160	})
161
162	if i.cfg.InitialInput != "" {
163		i.ed.SetValue(i.cfg.InitialInput)
164	}
165
166	// No credential at startup? Auto-open the login dialog, and mark
167	// the status line. The user can Esc out of the dialog if they
168	// want to dismiss it (e.g. to check /help or /exit first).
169	if i.agent == nil {
170		i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
171		i.dialog.Open()
172	}
173
174	// Input goroutine.
175	keys := make(chan tui.Key, 8)
176	go func() {
177		reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
178		for {
179			k, err := reader.Read()
180			if err != nil {
181				return
182			}
183			keys <- k
184		}
185	}()
186
187	// Subscribe to auth events.
188	var authEvents <-chan auth.Event
189	if i.cfg.AuthManager != nil {
190		authEvents = i.cfg.AuthManager.Events()
191	}
192
193	// Animation ticker: drives spinner and dialog-related redraws when
194	// nothing else changed. 120ms is slow enough that highlighting a huge
195	// transcript doesn't spin the cpu.
196	tick := time.NewTicker(120 * time.Millisecond)
197	defer tick.Stop()
198
199	// Redraw throttle: coalesce bursts of invalidate() calls so we paint
200	// at most once every redrawMinInterval. Huge tool-result dumps can
201	// fire hundreds of invalidations while the user is typing; without
202	// this, the input goroutine never gets CPU and keystrokes lag.
203	const redrawMinInterval = 16 * time.Millisecond
204	var lastRedraw time.Time
205	var pendingRedraw bool
206	var pendingTimer *time.Timer
207
208	drainPending := func() {
209		if pendingTimer != nil {
210			pendingTimer.Stop()
211			pendingTimer = nil
212		}
213		if pendingRedraw {
214			pendingRedraw = false
215			lastRedraw = time.Now()
216			i.redraw()
217		}
218	}
219
220	requestRedraw := func() {
221		since := time.Since(lastRedraw)
222		if since >= redrawMinInterval {
223			lastRedraw = time.Now()
224			i.redraw()
225			return
226		}
227		if pendingRedraw {
228			return // already scheduled
229		}
230		pendingRedraw = true
231		wait := redrawMinInterval - since
232		if pendingTimer == nil {
233			pendingTimer = time.AfterFunc(wait, func() {
234				// Poke the dirty channel; the main loop will call drainPending().
235				i.invalidate()
236			})
237		} else {
238			pendingTimer.Reset(wait)
239		}
240	}
241
242	i.invalidate()
243
244	for {
245		select {
246		case <-ctx.Done():
247			return ctx.Err()
248		case k := <-keys:
249			if done := i.handleKey(ctx, k); done {
250				return nil
251			}
252			i.invalidate()
253		case ev := <-authEvents:
254			i.handleAuthEvent(ev)
255			i.invalidate()
256		case <-i.dirty:
257			requestRedraw()
258		case <-tick.C:
259			if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() {
260				drainPending()
261				requestRedraw()
262			}
263		}
264	}
265}
266
267func (i *Interactive) invalidate() {
268	select {
269	case i.dirty <- struct{}{}:
270	default:
271	}
272}
273
274// lastCols returns the current terminal width in columns.
275func (i *Interactive) lastCols() int {
276	cols, _ := i.cfg.Terminal.Size()
277	return cols
278}
279
280// chatPage returns the number of chat rows currently visible, used
281// as the page size for PageUp/PageDown.
282func (i *Interactive) chatPage() int {
283	_, rows := i.cfg.Terminal.Size()
284	p := rows - 6 // rough reservation for status + editor + a dialog line
285	if p < 4 {
286		p = 4
287	}
288	return p
289}
290
291// scrollBy adjusts the scroll offset. Positive = up (into history).
292func (i *Interactive) scrollBy(delta int) {
293	i.mu.Lock()
294	i.scrollOffset += delta
295	if i.scrollOffset < 0 {
296		i.scrollOffset = 0
297	}
298	i.mu.Unlock()
299	i.invalidate()
300}
301
302// scrollToBottom pins the view to the latest content.
303func (i *Interactive) scrollToBottom() {
304	i.mu.Lock()
305	i.scrollOffset = 0
306	i.mu.Unlock()
307	i.invalidate()
308}
309
310func (i *Interactive) redraw() {
311	i.mu.Lock()
312	defer i.mu.Unlock()
313
314	cols, _ := i.cfg.Terminal.Size()
315	if i.agent != nil {
316		i.view.Messages = i.agent.Messages()
317	} else {
318		i.view.Messages = nil
319	}
320	i.view.Streaming = i.streaming.String()
321	i.view.StreamingActive = i.streamOn
322	// Belt and suspenders: if the transcript's last message is already an
323	// assistant message, the streaming view is stale (EvAssistantMessage may
324	// not have fired yet) — suppress it to avoid double-rendering the reply.
325	if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
326		i.view.StreamingActive = false
327	}
328	// Live tool-call view: only shown while a turn is in flight. Once
329	// the agent is idle, every tool call has already been folded into
330	// the transcript (as assistant.ToolCallBlock + a tool-role message),
331	// so showing v.ToolCalls a second time would duplicate them below
332	// the final assistant text — which looks like the summary came
333	// "before" the tools.
334	i.view.ToolCalls = i.view.ToolCalls[:0]
335	if i.busy {
336		for _, id := range i.toolOrder {
337			if tc, ok := i.toolCalls[id]; ok {
338				i.view.ToolCalls = append(i.view.ToolCalls, *tc)
339			}
340		}
341	}
342	i.view.Err = i.statusErr
343
344	chat := i.view.Build(cols)
345
346	// Welcome banner: shown at the top of the chat area when there is
347	// no transcript yet. Disappears after the first message is sent.
348	if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
349		chat = append(welcomeBanner(i.cfg.Theme), chat...)
350	}
351
352	// /help block: appended to the transcript so it appears at the
353	// bottom of the chat area (right above the status bar / editor).
354	// Prepending it would push long conversations off the top of the
355	// viewport, which users would miss entirely.
356	if len(i.helpBlock) > 0 {
357		chat = append(chat, i.helpBlock...)
358	}
359
360	if i.statusOK != "" {
361		chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, "✓ "+i.statusOK), "")
362	}
363
364	// Dialogs (login or model picker) render between chat and the editor.
365	var dialog []string
366	switch {
367	case i.dialog.Active():
368		dialog = i.dialog.Render(i.cfg.Theme, cols)
369	case i.modelDialog.Active():
370		dialog = i.modelDialog.Render(i.cfg.Theme, cols)
371	case i.sessionDialog.Active():
372		dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
373	}
374
375	// Slash-command autocomplete: popup above the status line, only
376	// when the editor starts with "/" and no dialog is already open.
377	var suggest []string
378	currentInput := i.ed.Value()
379	if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy {
380		suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
381	}
382
383	// Busy prefix shown at the far left of the status bar.
384	var busyPrefix string
385	if i.busy {
386		busyPrefix = fmt.Sprintf("%s %s · %s",
387			i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Frame()),
388			i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Message()),
389			i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
390		)
391	}
392
393	ctxMax := 0
394	if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
395		ctxMax = m.ContextWindow
396	}
397	status := tui.StatusBar(tui.StatusBarParams{
398		Theme:          i.cfg.Theme,
399		Provider:       i.cfg.Provider,
400		Model:          i.cfg.Model,
401		Busy:           i.busy,
402		BusyPrefix:     busyPrefix,
403		CWD:            i.cfg.CWD,
404		Locked:         i.cfg.Sandbox.Locked(),
405		Usage:          i.cumUsage,
406		Subscription:   i.cfg.AuthMethod == "oauth",
407		ContextUsed:    i.lastCtxInput,
408		ContextMax:     ctxMax,
409		AutoCompacting: i.autoCompacting,
410		Cols:           cols,
411	})
412	edLines, curR, curC := i.ed.Render(cols)
413
414	// "Sliding in" chips for messages the user typed while a turn is
415	// in flight. Shown directly above the status bar so they're close
416	// to the editor but don't push the chat around.
417	var queue []string
418	if len(i.queued) > 0 {
419		for _, q := range i.queued {
420			label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")
421			text := truncateLine(q, cols-15)
422			queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
423		}
424	}
425
426	// Bottom-sticky sections (always visible, never scroll).
427	bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)
428	bottom = append(bottom, dialog...)
429	bottom = append(bottom, suggest...)
430	bottom = append(bottom, queue...)
431	bottom = append(bottom, status)
432	bottom = append(bottom, edLines...)
433
434	_, rows := i.cfg.Terminal.Size()
435	chatRows := rows - len(bottom)
436	if chatRows < 1 {
437		chatRows = 1
438	}
439
440	// Apply scroll offset to the chat slice.
441	maxOffset := len(chat) - chatRows
442	if maxOffset < 0 {
443		maxOffset = 0
444	}
445	if i.scrollOffset > maxOffset {
446		i.scrollOffset = maxOffset
447	}
448	if i.scrollOffset < 0 {
449		i.scrollOffset = 0
450	}
451
452	var visibleChat []string
453	if len(chat) <= chatRows {
454		visibleChat = chat
455	} else {
456		end := len(chat) - i.scrollOffset
457		start := end - chatRows
458		if start < 0 {
459			start = 0
460		}
461		visibleChat = chat[start:end]
462	}
463
464	// A tiny "scrolled up" indicator in the top-right of the chat pane
465	// so you know you're not at the bottom.
466	if i.scrollOffset > 0 && len(visibleChat) > 0 {
467		note := i.cfg.Theme.FG256(i.cfg.Theme.Muted,
468			fmt.Sprintf("  ↑ %d lines more below (end to jump)", i.scrollOffset))
469		visibleChat = append([]string{note}, visibleChat...)
470		if len(visibleChat) > chatRows {
471			visibleChat = visibleChat[:chatRows]
472		}
473	}
474
475	frame := make([]string, 0, len(visibleChat)+len(bottom))
476	frame = append(frame, visibleChat...)
477	frame = append(frame, bottom...)
478
479	cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + 1 + curR
480	cursorCol := curC
481	i.rend.Draw(frame, cursorRow, cursorCol)
482}
483
484// truncateLine shortens s so it fits within n display cells, with an
485// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
486// novel doesn't blow past the status line.
487func truncateLine(s string, n int) string {
488	if n <= 0 {
489		return ""
490	}
491	// Collapse newlines — chips are single line.
492	s = strings.ReplaceAll(s, "\n", " ↩ ")
493	runes := []rune(s)
494	if len(runes) <= n {
495		return s
496	}
497	if n <= 1 {
498		return "…"
499	}
500	return string(runes[:n-1]) + "…"
501}
502
503func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
504	// Dialogs consume keys while open (except ctrl+c, which always closes them).
505	if i.dialog.Active() {
506		if k.Kind == tui.KeyCtrlC {
507			i.dialog.Close()
508			if i.cfg.AuthManager != nil {
509				i.cfg.AuthManager.CancelOAuth()
510			}
511			return false
512		}
513		act := i.dialog.HandleKey(k)
514		if act.StartAPIKey {
515			i.startAPIKeyFlow(act.Provider)
516		}
517		if act.StartOAuth {
518			i.startOAuthFlow(act.Provider)
519		}
520		return false
521	}
522	if i.modelDialog.Active() {
523		if k.Kind == tui.KeyCtrlC {
524			i.modelDialog.Close()
525			return false
526		}
527		act := i.modelDialog.HandleKey(k)
528		if act.Select {
529			i.applyModelSelection(act.Provider, act.Model)
530		}
531		return false
532	}
533	if i.sessionDialog.Active() {
534		if k.Kind == tui.KeyCtrlC {
535			i.sessionDialog.Close()
536			return false
537		}
538		act := i.sessionDialog.HandleKey(k)
539		if act.Select {
540			i.applySessionSelection(act.Path)
541		}
542		return false
543	}
544
545	// Global keys.
546	switch k.Kind {
547	case tui.KeyCtrlC:
548		if i.busy && i.cancelTurn != nil {
549			i.cancelTurn()
550			return false
551		}
552		return true
553	case tui.KeyEsc:
554		// Esc interrupts a running turn. When idle, fall through so the
555		// editor can clear itself.
556		if i.busy && i.cancelTurn != nil {
557			i.cancelTurn()
558			return false
559		}
560	case tui.KeyCtrlD:
561		if i.ed.IsEmpty() && !i.busy {
562			return true
563		}
564	case tui.KeyCtrlL:
565		i.rend.Clear()
566		i.invalidate()
567		return false
568	case tui.KeyCtrlO:
569		// Toggle expansion of collapsed tool results. Affects every tool
570		// call in the transcript — press again to re-collapse.
571		i.mu.Lock()
572		i.view.ExpandAll = !i.view.ExpandAll
573		i.mu.Unlock()
574		i.invalidate()
575		return false
576	case tui.KeyPageUp:
577		i.scrollBy(+i.chatPage())
578		return false
579	case tui.KeyPageDown:
580		i.scrollBy(-i.chatPage())
581		return false
582	case tui.KeyUp:
583		// Wheel-up in alt screen sends Up arrows on most terminals.
584		// When the editor is empty we use up/down for chat scroll —
585		// independently of whether the agent is busy, so users can
586		// scroll back through long streaming replies while they run.
587		if i.ed.IsEmpty() {
588			i.scrollBy(+3)
589			return false
590		}
591	case tui.KeyDown:
592		if i.ed.IsEmpty() {
593			if i.scrollOffset > 0 {
594				i.scrollBy(-3)
595			}
596			return false
597		}
598	}
599
600	// Note: we intentionally do NOT gate the editor on i.busy here.
601	// Typing while the agent is working is supported — submitted
602	// messages are queued and delivered as follow-up turns when the
603	// current turn ends. See the submit handler below.
604
605	if k.Kind == tui.KeyEnter && k.Alt {
606		i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
607		return false
608	}
609
610	// Slash suggestions: intercept up/down/tab/enter when the popup is visible.
611	if i.suggest.Active(i.ed.Value()) {
612		switch k.Kind {
613		case tui.KeyUp:
614			i.suggest.Up()
615			return false
616		case tui.KeyDown:
617			i.suggest.Down()
618			return false
619		case tui.KeyTab:
620			if name := i.suggest.Selection(i.ed.Value()); name != "" {
621				i.ed.SetValue(name)
622				i.suggest.Reset()
623			}
624			return false
625		case tui.KeyEnter:
626			// Enter on an ambiguous or partial slash prefix: complete to the
627			// currently highlighted command and run it. That way typing
628			// "/lo" + enter picks whichever of /login or /logout is selected
629			// in the popup instead of submitting "/lo" as unknown. Also
630			// clear the editor so the command doesn't linger after the
631			// dialog opens/closes.
632			if name := i.suggest.Selection(i.ed.Value()); name != "" {
633				i.ed.PushHistory(name)
634				i.ed.Clear()
635				i.suggest.Reset()
636				return i.runSlash(ctx, name)
637			}
638		case tui.KeyEsc:
639			i.ed.Clear()
640			i.suggest.Reset()
641			return false
642		}
643	}
644
645	if submit := i.ed.HandleKey(k); submit {
646		text := strings.TrimRight(i.ed.Value(), "\n")
647		if text == "" {
648			return false
649		}
650		i.ed.PushHistory(text)
651		i.ed.Clear()
652		i.suggest.Reset()
653
654		if looksLikeSlashCommand(text) {
655			if !isKnownSlashCommand(text) {
656				head := text
657				if idx := strings.IndexAny(text, " \t"); idx >= 0 {
658					head = text[:idx]
659				}
660				i.mu.Lock()
661				i.statusErr = "unknown command " + head + " — type /help to see the list"
662				i.statusOK = ""
663				i.mu.Unlock()
664				return false
665			}
666			// Slash commands need a quiet state (they may swap models,
667			// compact the transcript, open dialogs, etc). Refuse while
668			// a turn is in flight — esc / ctrl+c cancels first.
669			i.mu.Lock()
670			busy := i.busy
671			i.mu.Unlock()
672			if busy {
673				i.mu.Lock()
674				i.statusErr = "cancel the current turn (esc) before running a slash command"
675				i.mu.Unlock()
676				return false
677			}
678			return i.runSlash(ctx, text)
679		}
680
681		if i.agent == nil {
682			i.mu.Lock()
683			i.statusErr = "not logged in. type /login first."
684			i.mu.Unlock()
685			return false
686		}
687		// If a turn is already in flight, queue this prompt instead of
688		// starting a second one. The drain loop at the end of startTurn
689		// will pick it up when the current turn finishes.
690		i.mu.Lock()
691		if i.busy {
692			i.queued = append(i.queued, text)
693			i.mu.Unlock()
694			i.invalidate()
695			return false
696		}
697		i.mu.Unlock()
698		i.startTurn(ctx, text)
699	}
700	return false
701}
702
703func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
704	parts := strings.Fields(cmd)
705	switch parts[0] {
706	case "/exit":
707		return true
708	case "/clear":
709		if i.agent != nil {
710			i.agent.SetMessages(nil)
711		}
712		i.mu.Lock()
713		i.toolCalls = map[string]*tui.ToolCallView{}
714		i.toolOrder = nil
715		i.statusErr = ""
716		i.statusOK = ""
717		i.helpBlock = nil
718		i.mu.Unlock()
719	case "/help":
720		i.mu.Lock()
721		i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
722		i.statusErr = ""
723		i.statusOK = ""
724		// Pin the viewport to the newest content so the help block,
725		// which we just appended to the end of the transcript, is
726		// what the user actually sees.
727		i.scrollOffset = 0
728		i.mu.Unlock()
729	case "/login":
730		i.dialog.Open()
731	case "/logout":
732		target := "all"
733		if len(parts) >= 2 {
734			target = parts[1]
735		}
736		i.doLogout(target)
737	case "/model":
738		if len(parts) >= 2 {
739			i.applyModelSelection("", parts[1])
740		} else {
741			i.modelDialog.Open(i.cfg.Model)
742		}
743	case "/sessions":
744		i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
745	case "/compact":
746		i.runCompact(ctx, false)
747	case "/lock":
748		if i.cfg.Sandbox == nil {
749			i.mu.Lock()
750			i.statusErr = "sandbox not available in this build"
751			i.mu.Unlock()
752			break
753		}
754		i.cfg.Sandbox.Lock()
755		i.mu.Lock()
756		i.statusOK = "locked to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
757		i.statusErr = ""
758		i.mu.Unlock()
759	case "/unlock":
760		if i.cfg.Sandbox == nil {
761			i.mu.Lock()
762			i.statusErr = "sandbox not available in this build"
763			i.mu.Unlock()
764			break
765		}
766		i.cfg.Sandbox.Unlock()
767		i.mu.Lock()
768		i.statusOK = "unlocked"
769		i.statusErr = ""
770		i.mu.Unlock()
771	default:
772		i.mu.Lock()
773		i.statusErr = "unknown command: " + parts[0]
774		i.mu.Unlock()
775	}
776	return false
777}
778
779// doLogout clears credentials for the given provider (or all providers)
780// from auth.json. If the active agent was using those credentials, it
781// is torn down so the user is forced through /login before their next
782// prompt.
783//
784// target: "anthropic" | "openai" | "all"
785func (i *Interactive) doLogout(target string) {
786	if i.cfg.AuthManager == nil {
787		i.mu.Lock()
788		i.statusErr = "no auth manager configured"
789		i.mu.Unlock()
790		return
791	}
792	store := i.cfg.AuthManager.Store()
793	if store == nil {
794		i.mu.Lock()
795		i.statusErr = "auth store is not available"
796		i.mu.Unlock()
797		return
798	}
799
800	var providers []string
801	switch target {
802	case "", "all":
803		providers = []string{"anthropic", "openai"}
804	case "anthropic", "openai":
805		providers = []string{target}
806	default:
807		i.mu.Lock()
808		i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"
809		i.mu.Unlock()
810		return
811	}
812
813	var errs []string
814	clearedCurrent := false
815	for _, p := range providers {
816		if err := store.Clear(p); err != nil {
817			errs = append(errs, p+": "+err.Error())
818			continue
819		}
820		if p == i.cfg.Provider {
821			clearedCurrent = true
822		}
823	}
824
825	i.mu.Lock()
826	defer i.mu.Unlock()
827	if len(errs) > 0 {
828		i.statusErr = "logout errors: " + strings.Join(errs, "; ")
829		return
830	}
831	i.statusErr = ""
832	if clearedCurrent {
833		// The running agent was using a credential we just wiped. Drop
834		// it so prompts can't go out with the stale client, and hint at
835		// /login.
836		i.agent = nil
837		i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
838	} else {
839		i.statusOK = "logged out of " + strings.Join(providers, ", ")
840	}
841}
842
843func (i *Interactive) startAPIKeyFlow(provider string) {
844	url, err := i.cfg.AuthManager.StartAPIKey(provider)
845	if err != nil {
846		i.dialog.ShowResult(false, err.Error())
847		return
848	}
849	i.dialog.ShowWaiting(url)
850}
851
852func (i *Interactive) startOAuthFlow(provider string) {
853	url, err := i.cfg.AuthManager.StartOAuth(provider)
854	if err != nil {
855		i.dialog.ShowResult(false, err.Error())
856		return
857	}
858	i.dialog.ShowWaiting(url)
859}
860
861// applyModelSelection switches the active model (and provider, if the
862// new model belongs to a different one). It rebuilds the underlying
863// client when needed so the provider wire-protocol matches.
864// applySessionSelection loads the given session via the cli-provided callback.
865func (i *Interactive) applySessionSelection(path string) {
866	if i.cfg.LoadSession == nil {
867		i.mu.Lock()
868		i.statusErr = "session loading is not wired in this build"
869		i.mu.Unlock()
870		return
871	}
872	if err := i.cfg.LoadSession(path); err != nil {
873		i.mu.Lock()
874		i.statusErr = err.Error()
875		i.mu.Unlock()
876		return
877	}
878	i.mu.Lock()
879	i.statusOK = "resumed session: " + path
880	i.statusErr = ""
881	i.scrollOffset = 0
882	i.mu.Unlock()
883}
884
885func (i *Interactive) applyModelSelection(prov, model string) {
886	if model == "" {
887		return
888	}
889	m, err := provider.FindModel(prov, model)
890	if err != nil {
891		i.mu.Lock()
892		i.statusErr = err.Error()
893		i.mu.Unlock()
894		return
895	}
896	// Same provider? Just swap the model on the existing agent.
897	if i.agent != nil && m.Provider == i.cfg.Provider {
898		i.mu.Lock()
899		i.cfg.Model = m.ID
900		i.agent.Model = m.ID
901		i.statusOK = "model: " + m.ID
902		i.statusErr = ""
903		i.mu.Unlock()
904		if i.cfg.PersistModel != nil {
905			i.cfg.PersistModel(i.cfg.Provider, m.ID)
906		}
907		return
908	}
909	// Different provider: rebuild agent (needs credentials for target provider).
910	if i.cfg.BuildAgentFor == nil {
911		i.mu.Lock()
912		i.statusErr = "cannot switch provider: no builder configured"
913		i.mu.Unlock()
914		return
915	}
916	ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)
917	if err != nil {
918		i.mu.Lock()
919		i.statusErr = err.Error()
920		i.mu.Unlock()
921		return
922	}
923	i.mu.Lock()
924	i.agent = ag
925	i.cfg.Provider = p
926	i.cfg.Model = md
927	i.statusOK = "switched to " + p + " / " + md
928	i.statusErr = ""
929	i.mu.Unlock()
930	if i.cfg.PersistModel != nil {
931		i.cfg.PersistModel(p, md)
932	}
933}
934
935func (i *Interactive) handleAuthEvent(ev auth.Event) {
936	switch ev.Kind {
937	case "started":
938		i.dialog.ShowWaiting(ev.URL)
939	case "browser_open":
940		// no-op
941	case "error":
942		i.dialog.ShowResult(false, ev.Message)
943	case "success":
944		// Rebuild the agent with the fresh credential.
945		ag, prov, model, err := i.cfg.BuildAgent()
946		if err != nil {
947			i.dialog.ShowResult(false, err.Error())
948			return
949		}
950		i.mu.Lock()
951		i.agent = ag
952		i.cfg.Provider = prov
953		i.cfg.Model = model
954		i.statusErr = ""
955		i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
956		i.mu.Unlock()
957		i.dialog.ShowResult(true, "")
958	}
959}
960
961// runCompact invokes core.Agent.Compact and reflects the progress in
962// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
963// cancel via the same cancelTurn channel used for normal turns.
964//
965// When auto is true the spinner message is pinned to "condensing
966// history" and the status bar surfaces "(auto)" next to the context
967// percentage so it's obvious the system triggered this, not the user.
968func (i *Interactive) runCompact(parent context.Context, auto bool) {
969	if i.agent == nil {
970		i.mu.Lock()
971		i.statusErr = "not logged in. type /login first."
972		i.mu.Unlock()
973		return
974	}
975	ctx, cancel := context.WithCancel(parent)
976	i.mu.Lock()
977	i.busy = true
978	if auto {
979		i.spin.StartFixed("condensing history")
980		i.autoCompacting = true
981		i.statusOK = "condensing history… (esc to cancel)"
982	} else {
983		i.spin.Start()
984		i.statusOK = "compacting..."
985	}
986	i.cancelTurn = cancel
987	i.statusErr = ""
988	i.streaming.Reset()
989	i.streamOn = true
990	i.scrollOffset = 0
991	i.helpBlock = nil
992	i.mu.Unlock()
993	i.invalidate()
994
995	go func() {
996		sink := func(delta string) {
997			i.mu.Lock()
998			i.streaming.WriteString(delta)
999			i.mu.Unlock()
1000			i.invalidate()
1001		}
1002		summary, err := i.agent.Compact(ctx, 4, sink)
1003		i.mu.Lock()
1004		i.busy = false
1005		i.streamOn = false
1006		i.streaming.Reset()
1007		i.cancelTurn = nil
1008		i.autoCompacting = false
1009		switch {
1010		case err != nil && ctx.Err() != nil:
1011			i.statusErr = ""
1012			if auto {
1013				i.statusOK = "auto-condense cancelled"
1014			} else {
1015				i.statusOK = "compaction cancelled"
1016			}
1017		case err != nil:
1018			i.statusErr = "compaction failed: " + err.Error()
1019			i.statusOK = ""
1020		default:
1021			i.statusErr = ""
1022			i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary))
1023			i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
1024			i.toolCalls = map[string]*tui.ToolCallView{}
1025			i.toolOrder = nil
1026		}
1027		i.mu.Unlock()
1028		i.invalidate()
1029	}()
1030}
1031
1032func (i *Interactive) startTurn(parent context.Context, prompt string) {
1033	if i.agent == nil {
1034		return
1035	}
1036	ctx, cancel := context.WithCancel(parent)
1037	i.mu.Lock()
1038	i.busy = true
1039	i.spin.Start()
1040	i.cancelTurn = cancel
1041	i.statusErr = ""
1042	i.statusOK = ""
1043	i.streaming.Reset()
1044	i.streamOn = true
1045	i.toolCalls = map[string]*tui.ToolCallView{}
1046	i.toolOrder = nil
1047	i.scrollOffset = 0 // jump back to the bottom on new turn
1048	i.helpBlock = nil  // hide the help block once the user asks something
1049	i.mu.Unlock()
1050	i.invalidate()
1051
1052	sink := func(ev core.AgentEvent) {
1053		i.handleEvent(ev)
1054		i.invalidate()
1055	}
1056
1057	go func() {
1058		err := i.agent.Prompt(ctx, prompt, nil, sink)
1059		i.mu.Lock()
1060		i.busy = false
1061		i.streamOn = false
1062		i.cancelTurn = nil
1063		if err != nil && ctx.Err() == nil {
1064			i.statusErr = err.Error()
1065		}
1066		// Pop the next queued message, if any, and relaunch.
1067		var next string
1068		var hasNext bool
1069		if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
1070			next, i.queued = i.queued[0], i.queued[1:]
1071			hasNext = true
1072		}
1073		// If the turn was cancelled or errored, drop the queue so the
1074		// user isn't bombarded with stale messages after an interrupt.
1075		if ctx.Err() != nil || err != nil {
1076			i.queued = nil
1077		}
1078		// Decide whether the next thing to do is an auto-compaction.
1079		// Only fires when the turn completed cleanly AND the queue is
1080		// empty (otherwise a queued message would race the condense).
1081		shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
1082		i.mu.Unlock()
1083		i.invalidate()
1084		parent := i.runCtx
1085		if parent == nil {
1086			parent = context.Background()
1087		}
1088		switch {
1089		case hasNext:
1090			i.startTurn(parent, next)
1091		case shouldAutoCompact:
1092			i.runCompact(parent, true)
1093		}
1094	}()
1095}
1096
1097// autoCompactThreshold is the context-window fraction at which the
1098// agent will auto-compact after a turn ends. 0.85 leaves enough
1099// headroom for one more user prompt + response before we bump the
1100// hard limit.
1101const autoCompactThreshold = 0.85
1102
1103// shouldAutoCompactLocked reports whether the last turn pushed context
1104// usage past the auto-compact threshold. Must be called with i.mu
1105// held; it reads lastCtxInput and the current model's context window.
1106func (i *Interactive) shouldAutoCompactLocked() bool {
1107	if i.agent == nil {
1108		return false
1109	}
1110	if i.autoCompacting {
1111		return false
1112	}
1113	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
1114	if err != nil || m.ContextWindow <= 0 {
1115		return false
1116	}
1117	if i.lastCtxInput <= 0 {
1118		return false
1119	}
1120	return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
1121}
1122
1123func (i *Interactive) handleEvent(ev core.AgentEvent) {
1124	i.mu.Lock()
1125	defer i.mu.Unlock()
1126	switch e := ev.(type) {
1127	case core.EvTextDelta:
1128		i.streaming.WriteString(e.Delta)
1129	case core.EvAssistantMessage:
1130		i.streaming.Reset()
1131		i.streamOn = false
1132		if i.cfg.OnAssistant != nil {
1133			i.cfg.OnAssistant(e.Message)
1134		}
1135	case core.EvToolCall:
1136		tcv := &tui.ToolCallView{
1137			ID:   e.ID,
1138			Name: e.Name,
1139			Args: shortArgs(e.Args),
1140		}
1141		i.toolCalls[e.ID] = tcv
1142		i.toolOrder = append(i.toolOrder, e.ID)
1143	case core.EvToolResult:
1144		if tc, ok := i.toolCalls[e.ID]; ok {
1145			tc.Done = true
1146			tc.Error = e.Result.IsError
1147			var text strings.Builder
1148			for _, c := range e.Result.Content {
1149				if tb, ok := c.(provider.TextBlock); ok {
1150					if text.Len() > 0 {
1151						text.WriteString("\n")
1152					}
1153					text.WriteString(tb.Text)
1154				}
1155			}
1156			tc.Result = text.String()
1157		}
1158		if i.cfg.OnToolResult != nil {
1159			i.cfg.OnToolResult(e.ID, e.Result)
1160		}
1161	case core.EvUsage:
1162		i.cumUsage = e.Cumulative
1163		if e.Usage.InputTokens > 0 {
1164			i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
1165		}
1166	case core.EvTurnEnd:
1167		if e.Stop == provider.StopAborted {
1168			// Aborted turn: discard the partial streaming text (it is not
1169			// persisted in the transcript) and clear any transient error.
1170			i.streaming.Reset()
1171			i.streamOn = false
1172			i.statusErr = ""
1173			i.statusOK = "cancelled"
1174			return
1175		}
1176		if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") {
1177			i.statusErr = e.Err.Error()
1178		}
1179	}
1180}
1181
1182// Agent returns the current agent, if any. Used by cli.go to flush the
1183// final transcript to the session file.
1184func (i *Interactive) Agent() *core.Agent {
1185	i.mu.Lock()
1186	defer i.mu.Unlock()
1187	return i.agent
1188}
1189
1190func shortArgs(raw json.RawMessage) string {
1191	var v any
1192	if err := json.Unmarshal(raw, &v); err != nil {
1193		return ""
1194	}
1195	if m, ok := v.(map[string]any); ok {
1196		for _, k := range []string{"path", "file_path", "command"} {
1197			if s, ok := m[k].(string); ok {
1198				if len(s) > 60 {
1199					s = s[:57] + "..."
1200				}
1201				return s
1202			}
1203		}
1204	}
1205	b, _ := json.Marshal(v)
1206	s := string(b)
1207	if len(s) > 60 {
1208		s = s[:57] + "..."
1209	}
1210	return s
1211}
1212
1213// silence unused import in some build configs
1214var _ = 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