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)
}
if s := summarizeQuestions(m); s != "" {
return s
}
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, ", ")
}
// summarizeQuestions is ask_user's arguments, which are a list of maps and
// render as unreadable rubbish through the k=v path. What the line wants is
// the question. It keys off the shape rather than the tool name because that
// is all summarizeInput is given, and nothing else here sends a "questions"
// list of objects with a "question" in them.
func summarizeQuestions(m map[string]any) string {
qs, ok := m["questions"].([]any)
if !ok || len(qs) == 0 {
return ""
}
first, ok := qs[0].(map[string]any)
if !ok {
return ""
}
text, ok := first["question"].(string)
if !ok || text == "" {
return ""
}
if n := len(qs); n > 1 {
return fmt.Sprintf("%s (+%d more)", truncate(text, 60), n-1)
}
return truncate(text, 70)
}