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 // cacheLines is the same text split into rows, built only when something // asks — which is the mouse, and only while it is being used. A streaming // turn re-renders on every token and must not pay for a split nobody is // looking at. cacheLines []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.cacheLines, t.dirty = b.String(), nil, false return t.cache } // lines is the scrollback as the viewport holds it: one string per row. It is // the coordinate system a mouse selection is made in — a row here is a row on // screen once the viewport's scroll offset is taken off. func (t *transcript) lines() []string { s := t.render() if t.cacheLines == nil { t.cacheLines = strings.Split(s, "\n") } return t.cacheLines } // 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, ", ") }