Vectrum
Sign In
462 lines · 13.7 KB · Go
package tui

import (
	"encoding/json"
	"fmt"
	"sort"
	"strings"

	"github.com/charmbracelet/lipgloss"
	"github.com/charmbracelet/x/ansi"
)

type entryKind int

const (
	entryUser entryKind = iota
	entryAssistant
	entryTool
	entryNotice
	entryError
	// entryRaw is already-styled text the transcript passes through
	// untouched: the welcome block, which owns its own layout.
	entryRaw
	// entryLocal is a command the human ran from the prompt box rather than
	// something the model asked for.
	entryLocal
)

// entry is one thing that happened, kept structured rather than pre-rendered
// so a resize or a verbosity toggle can redraw the whole history correctly.
type entry struct {
	kind    entryKind
	text    string
	tool    string
	input   string
	result  string
	isError bool
	// note is a decision recorded against this call — an approval or a
	// denial — kept on the entry so it renders with the call rather than
	// after the result that came back later.
	note string
	// open means more is still arriving: streamed text, or a tool call whose
	// result has not come back yet.
	open bool
	// md is text laid out as Markdown, held because laying it out again costs
	// milliseconds and a single streamed token redraws every entry there is.
	// It is dropped whenever the text, the width or the palette changes.
	md      string
	mdWidth int
}

// collapsedLines is how much of a tool result is shown before it is elided.
// Diffs get a larger budget because a diff is the record of what changed.
const (
	collapsedLines = 6
	collapsedDiff  = 40
)

// The transcript reads as a column of events hanging off a gutter: a dot
// opens each one, and everything belonging to it is indented under the dot.
const (
	dot    = "⏺"
	branch = "⎿"
	gutter = "  "
)

// transcript is the scrollback. It renders to a single string for the
// viewport, and caches that string because re-rendering a long session on
// every frame is wasteful.
type transcript struct {
	entries []entry
	width   int
	verbose bool

	cache string
	dirty bool
	// gen is the theme generation the cache was built under.
	gen int
}

func newTranscript(width int) *transcript {
	return &transcript{width: width, dirty: true}
}

func (t *transcript) setWidth(w int) {
	if w != t.width {
		t.width, t.dirty = w, true
	}
}

func (t *transcript) setVerbose(v bool) {
	if v != t.verbose {
		t.verbose, t.dirty = v, true
	}
}

func (t *transcript) clear() {
	t.entries, t.dirty = nil, true
}

func (t *transcript) add(e entry) {
	t.closeOpen()
	t.entries = append(t.entries, e)
	t.dirty = true
}

func (t *transcript) addUser(s string)   { t.add(entry{kind: entryUser, text: s}) }
func (t *transcript) addNotice(s string) { t.add(entry{kind: entryNotice, text: s}) }
func (t *transcript) addError(s string)  { t.add(entry{kind: entryError, text: s}) }
func (t *transcript) addRaw(s string)    { t.add(entry{kind: entryRaw, text: s}) }

// closeOpen finalises a streaming assistant entry. A tool call arriving
// mid-sentence ends the sentence, which is what the plain renderer does with
// its newline bookkeeping.
func (t *transcript) closeOpen() {
	if n := len(t.entries); n > 0 && t.entries[n-1].kind == entryAssistant && t.entries[n-1].open {
		t.entries[n-1].open = false
		t.entries[n-1].text = strings.TrimRight(t.entries[n-1].text, "\n")
		t.entries[n-1].md = ""
		t.dirty = true
	}
}

// appendDelta adds streamed text, continuing the current assistant entry
// rather than starting a new one per token.
func (t *transcript) appendDelta(s string) {
	if n := len(t.entries); n > 0 && t.entries[n-1].kind == entryAssistant && t.entries[n-1].open {
		t.entries[n-1].text += s
		t.entries[n-1].md = ""
		t.dirty = true
		return
	}
	t.entries = append(t.entries, entry{kind: entryAssistant, text: s, open: true})
	t.dirty = true
}

// startLocal opens an entry for a command the human is running themselves.
// It reuses the tool entry's shape — the marker where the tool name goes, the
// command where its arguments go — so the result renders through the same
// collapsing and the same ctrl+r.
func (t *transcript) startLocal(spec localSpec, command string) {
	t.closeOpen()
	t.entries = append(t.entries, entry{kind: entryLocal, tool: spec.marker, input: command, open: true})
	t.dirty = true
}

// finishLocal attaches the report to the run still waiting for one.
func (t *transcript) finishLocal(result string, isErr bool) {
	for i := len(t.entries) - 1; i >= 0; i-- {
		if t.entries[i].kind != entryLocal || !t.entries[i].open {
			continue
		}
		t.entries[i].result, t.entries[i].isError, t.entries[i].open = result, isErr, false
		t.dirty = true
		return
	}
	t.entries = append(t.entries, entry{kind: entryLocal, result: result, isError: isErr})
	t.dirty = true
}

func (t *transcript) startTool(name, input string) {
	t.closeOpen()
	t.entries = append(t.entries, entry{kind: entryTool, tool: name, input: input, open: true})
	t.dirty = true
}

// annotateTool records a decision against the most recent unfinished call of
// that tool. Approval happens before the tool runs, so appending it as its own
// entry would print it after the result it gated.
func (t *transcript) annotateTool(name, note string) {
	for i := len(t.entries) - 1; i >= 0; i-- {
		if t.entries[i].kind == entryTool && t.entries[i].open && t.entries[i].tool == name {
			t.entries[i].note = note
			t.dirty = true
			return
		}
	}
	t.addNotice(gutter + branch + " " + note)
}

// finishTool attaches a result to the most recent unfinished call of that
// tool. Matching by name from the end is enough: the loop dispatches calls in
// order and reports each result as it lands.
func (t *transcript) finishTool(name, result string, isErr bool) {
	for i := len(t.entries) - 1; i >= 0; i-- {
		if t.entries[i].kind == entryTool && t.entries[i].open && t.entries[i].tool == name {
			t.entries[i].result = result
			t.entries[i].isError = isErr
			t.entries[i].open = false
			t.dirty = true
			return
		}
	}
	// No matching call: show it anyway rather than silently dropping evidence.
	t.entries = append(t.entries, entry{kind: entryTool, tool: name, result: result, isError: isErr})
	t.dirty = true
}

// render produces the full scrollback. Entries are separated by a blank line,
// which is most of what makes a wall of tool output readable.
func (t *transcript) render() string {
	// A theme change repaints history, not just what comes next, so it
	// invalidates both this cache and every entry's own.
	if g := themeGeneration(); g != t.gen {
		t.gen, t.dirty = g, true
		for i := range t.entries {
			t.entries[i].md = ""
		}
	}
	if !t.dirty {
		return t.cache
	}
	var b strings.Builder
	for i := range t.entries {
		if i > 0 {
			b.WriteByte('\n')
		}
		b.WriteString(t.renderEntry(&t.entries[i]))
	}
	t.cache, t.dirty = b.String(), false
	return t.cache
}

// renderEntry draws one entry. It takes a pointer because an assistant entry
// keeps its laid-out Markdown rather than producing it again every frame.
func (t *transcript) renderEntry(e *entry) string {
	width := t.width
	if width < 20 {
		width = 20
	}
	switch e.kind {
	case entryRaw:
		return e.text

	case entryUser:
		return hang(styUserMark.Render(">"), styUserText.Width(width-2).Render(e.text))

	case entryAssistant:
		// The dot and its space take the first two columns, so Markdown is
		// laid out for what is left rather than for the whole terminal.
		if e.md == "" || e.mdWidth != width {
			e.md, e.mdWidth = renderMarkdown(e.text, width-2), width
		}
		return hang(styTextDot.Render(dot), e.md)

	case entryNotice:
		return styDim.Width(width).Render(e.text) + "\n"

	case entryError:
		return hang(styErrDot.Render(dot), styErr.Width(width-2).Render(e.text))

	case entryTool:
		return t.renderTool(e, width)

	case entryLocal:
		return t.renderLocal(e, width)
	}
	return ""
}

// hang renders a marker and a body so the body's continuation lines line up
// under its first line rather than under the marker.
func hang(mark, body string) string {
	var b strings.Builder
	for i, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") {
		if i == 0 {
			b.WriteString(mark + " " + line + "\n")
			continue
		}
		// A blank line between blocks stays blank: indenting it would leave a
		// line of trailing whitespace that only shows up when selected.
		if line == "" {
			b.WriteString("\n")
			continue
		}
		b.WriteString(gutter + line + "\n")
	}
	return b.String()
}

func (t *transcript) renderTool(e *entry, width int) string {
	var b strings.Builder

	name := camel(e.tool)
	args := ""
	if e.input != "" {
		args = "(" + truncate(e.input, maxInt(10, width-lipgloss.Width(name)-6)) + ")"
	}
	b.WriteString(styToolDot.Render(dot) + " " + styToolName.Render(name) + styToolArgs.Render(args) + "\n")

	if e.note != "" {
		b.WriteString(styDim.Render(gutter+branch+" "+e.note) + "\n")
	}
	b.WriteString(t.renderResult(e, width))
	return b.String()
}

// renderLocal draws a command the human ran: the marker and the command in
// the mode's colour, then the same result body a tool call gets. The colour is
// the whole header rather than the marker alone, so a run is scannable in a
// wall of tool calls without reading it.
func (t *transcript) renderLocal(e *entry, width int) string {
	style := styLocalShell
	if e.tool == localSpecs[localPython].marker {
		style = styLocalPython
	}
	head := truncate(e.tool+" "+e.input, maxInt(10, width-2))
	return style.Render(dot+" "+head) + "\n" + t.renderResult(e, width)
}

// renderResult draws what came back from a call — the waiting marker, a diff
// in colour, or the output collapsed to a few lines with ctrl+r to see it all.
func (t *transcript) renderResult(e *entry, width int) string {
	var b strings.Builder

	if e.open {
		b.WriteString(styFaint.Render(gutter+branch+" …") + "\n")
		return b.String()
	}
	if strings.TrimSpace(e.result) == "" && !e.isError {
		return b.String()
	}

	// A diff is the one result worth showing in full and in colour.
	if !e.isError && looksLikeDiff(e.result) {
		lines := strings.Split(strings.TrimRight(e.result, "\n"), "\n")
		b.WriteString(styDim.Render(gutter+diffStat(lines)) + "\n")
		limit := collapsedDiff
		if t.verbose {
			limit = len(lines)
		}
		for _, line := range clip(lines, limit) {
			b.WriteString(gutter + colorDiffLine(line, width-len(gutter)) + "\n")
		}
		if n := len(lines) - limit; n > 0 {
			b.WriteString(styFaint.Render(fmt.Sprintf("%s… +%s of diff (ctrl+r)", gutter, plural(n, "more line"))) + "\n")
		}
		return b.String()
	}

	style, prefix := styResult, gutter+branch+" "
	if e.isError {
		style, prefix = styErr, gutter+branch+" error: "
	}
	lines := strings.Split(strings.TrimRight(e.result, "\n"), "\n")
	limit := collapsedLines
	if t.verbose {
		limit = len(lines)
	}
	cont := strings.Repeat(" ", lipgloss.Width(gutter+branch+" "))
	for i, line := range clip(lines, limit) {
		pad := prefix
		if i > 0 {
			pad = cont
		}
		b.WriteString(style.Render(pad+truncate(line, maxInt(10, width-lipgloss.Width(pad)))) + "\n")
	}
	if n := len(lines) - limit; n > 0 {
		b.WriteString(styFaint.Render(fmt.Sprintf("%s… +%s (ctrl+r)", cont, plural(n, "more line"))) + "\n")
	}
	return b.String()
}

// diffStat counts what a diff did, so the header says how big a change this
// was before the reader has to measure it by eye. The +++/--- file headers are
// not edits and must not be counted as any.
func diffStat(lines []string) string {
	add, del := 0, 0
	for _, line := range lines {
		switch {
		case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"):
		case strings.HasPrefix(line, "+"):
			add++
		case strings.HasPrefix(line, "-"):
			del++
		}
	}
	return fmt.Sprintf("%s, %s", plural(add, "addition"), plural(del, "removal"))
}

func plural(n int, word string) string {
	if n == 1 {
		return fmt.Sprintf("%d %s", n, word)
	}
	return fmt.Sprintf("%d %ss", n, word)
}

func colorDiffLine(line string, width int) string {
	line = truncate(line, maxInt(10, width))
	switch {
	case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"):
		return styDiffHead.Render(line)
	case strings.HasPrefix(line, "@@"):
		return styDiffHunk.Render(line)
	case strings.HasPrefix(line, "+"):
		return styDiffAdd.Render(line)
	case strings.HasPrefix(line, "-"):
		return styDiffDel.Render(line)
	}
	return styResult.Render(line)
}

// looksLikeDiff reports whether s is a unified diff, so a tool result can be
// rendered appropriately without the tool having to say which it is.
func looksLikeDiff(s string) bool {
	return strings.HasPrefix(s, "--- ") && strings.Contains(s, "\n@@ ")
}

func clip(lines []string, n int) []string {
	if n < 0 || n >= len(lines) {
		return lines
	}
	return lines[:n]
}

// truncate cuts to n display cells, counting runes rather than bytes so a
// multi-byte path does not get sliced mid-character. It is for unstyled text.
func truncate(s string, n int) string {
	if n <= 0 {
		return ""
	}
	r := []rune(s)
	if len(r) <= n {
		return s
	}
	return string(r[:n-1]) + "…"
}

// truncateStyled cuts a string that already carries ANSI styling. Counting
// runes there would measure escape sequences as if they were visible and cut
// the line far too early — which is exactly how a status bar loses its last
// field to a colour code.
func truncateStyled(s string, n int) string {
	if n <= 0 {
		return ""
	}
	if lipgloss.Width(s) <= n {
		return s
	}
	return ansi.Truncate(s, n, "…")
}

// summarizeInput renders tool arguments as k=v, sorted so the same call always
// prints the same way.
func summarizeInput(raw json.RawMessage) string {
	var m map[string]any
	if len(raw) == 0 || json.Unmarshal(raw, &m) != nil {
		return string(raw)
	}
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)

	parts := make([]string, 0, len(keys))
	for _, k := range keys {
		v := strings.ReplaceAll(fmt.Sprintf("%v", m[k]), "\n", "⏎")
		parts = append(parts, fmt.Sprintf("%s=%s", k, truncate(v, 60)))
	}
	return strings.Join(parts, ", ")
}