v0.0.29 e425dbb

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