Vectrum
Sign In
380 lines · 10.6 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
)

// 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
}

// 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
}

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.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.dirty = true
		return
	}
	t.entries = append(t.entries, entry{kind: entryAssistant, text: s, open: true})
	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 {
	if !t.dirty {
		return t.cache
	}
	var b strings.Builder
	for i, e := range t.entries {
		if i > 0 {
			b.WriteByte('\n')
		}
		b.WriteString(t.renderEntry(e))
	}
	t.cache, t.dirty = b.String(), false
	return t.cache
}

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:
		return hang(styTextDot.Render(dot), styText.Width(width-2).Render(strings.TrimRight(e.text, "\n")))

	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)
	}
	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
		}
		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")
	}
	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, ", ")
}