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