v0.0.6 7141ebd

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close