v0.0.7 6470487

interactive.go RawHistoryFolder

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