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