package tui import ( "albedo/cli/internal/daemon" "charm.land/lipgloss/v2" "cmp" "encoding/json" "fmt" "github.com/alecthomas/chroma/v2/lexers" "github.com/charmbracelet/x/ansi" "os" "path/filepath" "regexp" "slices" "strconv" "strings" "sync" "time" "unicode" ) type DisplayFlags struct { Thinking bool Tools bool Diffs bool Compaction bool } type TranscriptRenderer struct { // Open holds the keys of expanded bursts, messages, and notes. Open map[string]bool // Workspace roots the paths trace rows name. Workspace string BodyWidth int } func NewTranscriptRenderer() TranscriptRenderer { return TranscriptRenderer{} } func formatClock(timestamp int64) string { if timestamp <= 0 { return "" } return time.UnixMilli(timestamp).Format("15:04:05") } // speaker names who wrote a chat entry, or "" for tool and status rows. func speaker(entry HistoryEntry) string { switch entry.Kind { case EntryUser: return cmp.Or(entry.Speaker, "You") case EntryAssistant: return cmp.Or(entry.Speaker, "albedo") default: return "" } } // prior is what a new entry follows: the newest chat author, and when the // conversation last moved, which a turn's end also marks. type prior struct { speaker string at int64 } func priorOf(entries []HistoryEntry) prior { var p prior for i := len(entries) - 1; i >= 0 && p.speaker == ""; i-- { e := entries[i] if p.at == 0 && (e.Kind == EntryTurnEnd || speaker(e) != "") { p.at = e.Timestamp } p.speaker = speaker(e) } return p } // recent is as much of the end of entries as priorOf and a separator read: // back to the newest entry with a speaker. func recent(entries []HistoryEntry) []HistoryEntry { for i, entry := range slices.Backward(entries) { if speaker(entry) != "" { return entries[i:] } } return entries } // awayMs is the pause before your message that earns a "later" note. const awayMs = 30 * 60_000 // nameplate labels a change of speaker, so a turn that resumes after tool // rows keeps its author's name once. Verbose mode labels every message. func (r TranscriptRenderer) nameplate(entry HistoryEntry, flags DisplayFlags, before prior) string { who := speaker(entry) if !flags.Tools && who == before.speaker && entry.MailKind == "" { return "" } style := DefaultStyles.Agent if entry.Kind == EntryUser && (entry.Source == "" || entry.Source == "chat") { style = DefaultStyles.You } var meta []string if entry.MailKind != "" { meta = append(meta, entry.MailKind) } if clock := formatClock(entry.Timestamp); flags.Tools && clock != "" { meta = append(meta, clock) } if gap := entry.Timestamp - before.at; entry.Kind == EntryUser && before.at > 0 && gap >= awayMs { meta = append(meta, formatGap(gap)) } if entry.Pending == queued { meta = append(meta, "queued") } if entry.Pending == unresolved { meta = append(meta, "unresolved") } plate := markChrome + style.Render(strings.ToLower(who)) if len(meta) > 0 { plate += DefaultStyles.Faint.Render(" · " + strings.Join(meta, " · ")) } return plate } // errorRow marks only the label red. Long red text tires the eye, and the // message reads best in the prose color. func (r TranscriptRenderer) errorRow(text string) string { return DefaultStyles.Error.Render("error:") + " " + text } // signoff closes a turn: a face for how it ended, then how long it took. func (r TranscriptRenderer) signoff(entry HistoryEntry) string { style := DefaultStyles.Agent switch entry.Mood { case moodFailed: style = DefaultStyles.Error case moodStopped: style = DefaultStyles.Warning } var meta []string if entry.Mood == moodStopped { meta = append(meta, "stopped by you") } if entry.ElapsedMs > 0 { meta = append(meta, formatElapsed(entry.ElapsedMs)) } if entry.Tools > 0 { unit := "tools" if entry.Tools == 1 { unit = "tool" } meta = append(meta, fmt.Sprintf("%d %s", entry.Tools, unit)) } if entry.TokensPerSecond > 0 { meta = append(meta, formatRate(entry.TokensPerSecond)) } row := markChrome + rowAction{verbCopy, entryKey(entry)}.mark() + style.Render(entry.Mood.face(entry.Timestamp)) if len(meta) > 0 { row += " " + DefaultStyles.Faint.Render(strings.Join(meta, " · ")) } return row + DefaultStyles.Faint.Render(" · ") + DefaultStyles.Muted.Render("⧉") } var diffHunk = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`) func (r TranscriptRenderer) renderDiffPath(diff, path string, width int) string { if width < 8 { return "…" } var rows []string oldLine, newLine := 0, 0 lexer := lexers.Match(filepath.Base(path)) clean := func(text string) string { return strings.Map(func(ch rune) rune { if ch == '\t' || ch < 32 || ch == 127 { return ' ' } return ch }, text) } row := func(gutter, content, bg string, syntax bool) { body := keepBackground(DefaultStyles.Faint.Render(content)) if syntax { body = keepBackground(HighlightCode(content, lexer)) } for i, part := range strings.Split(ansi.Hardwrap(body, max(1, width-ansi.StringWidth(gutter)), true), "\n") { prefix := gutter if i > 0 { prefix = strings.Repeat(" ", ansi.StringWidth(gutter)) } cell := prefix + part rows = append(rows, bg+cell+strings.Repeat(" ", max(0, width-ansi.StringWidth(cell)))+ansiReset) } } panel, add, remove := diffPanel(), diffAdded(), diffRemoved() gutter := func(style lipgloss.Style, n int, sign string) string { return keepBackground(style.Render(fmt.Sprintf("%4d %s ", n, sign))) } row(" ", clean(r.named(path)), panel, false) for line := range strings.SplitSeq(diff, "\n") { if line == "" || strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") { continue } if hunk := diffHunk.FindStringSubmatch(line); hunk != nil { oldLine, _ = strconv.Atoi(hunk[1]) newLine, _ = strconv.Atoi(hunk[2]) row(" ⋮ ", clean(line), panel, false) continue } switch line[0] { case '+': row(gutter(DefaultStyles.Success, newLine, "+"), clean(line[1:]), add, true) newLine++ case '-': row(gutter(DefaultStyles.Error, oldLine, "-"), clean(line[1:]), remove, true) oldLine++ case ' ': row(gutter(DefaultStyles.Decor, newLine, " "), clean(line[1:]), panel, true) newLine++ oldLine++ default: row(" ⋮ ", clean(line), panel, false) } } return strings.Join(rows, "\n") } func lineUnit(n int) string { if n == 1 { return "line" } return "lines" } func firstLine(text string) string { for line := range strings.SplitSeq(text, "\n") { if line = strings.TrimSpace(line); line != "" { return line } } return "" } func toolSummary(entry HistoryEntry) string { arg := func(key string) string { if entry.ToolArgs == nil { return "" } return fmt.Sprint(entry.ToolArgs[key]) } switch entry.ToolName { case "python": return "python · " + firstLine(arg("code")) case "shell": return "$ " + firstLine(arg("command")) case "read_file", "write_file", "edit_file": return strings.TrimSuffix(entry.ToolName, "_file") + " " + arg("path") default: return entry.ToolName } } // toolOutput is what a tool printed. Python cells answer with a JSON // envelope, and counting the envelope reports one line for every cell. func toolOutput(entry HistoryEntry) string { if entry.ToolName == "python" { var cell struct{ Output, Value, Error string } if json.Unmarshal([]byte(entry.ToolResult), &cell) == nil { var parts []string for _, part := range []string{cell.Output, cell.Value, cell.Error} { if part = strings.TrimSpace(part); part != "" { parts = append(parts, part) } } return strings.Join(parts, "\n") } } return strings.TrimSpace(entry.ToolResult) } // oneLine folds text onto one row: control characters and runs of // whitespace become single spaces. func oneLine(text string) string { return strings.Join(strings.Fields(strings.Map(func(r rune) rune { if unicode.IsControl(r) { return ' ' } return r }, text)), " ") } // fit truncates head so the row fits width, keeping tail whole when there is // room, so a long target never hides the counts after it. func fit(head, tail string, width int) string { if room := width - ansi.StringWidth(tail); room >= 12 { return ansi.Truncate(head, room, "…") + tail } return ansi.Truncate(head+tail, max(1, width), "…") } var storeHash = regexp.MustCompile(`/nix/store/[0-9a-z]{32}-`) // shortTarget fits what a trace row names into room cells. Paths in the // workspace are named from it, the rest of home from ~, and store hashes are // elided, in commands too; a path that still overflows loses its middle, so // the file name stays visible. func (r TranscriptRenderer) shortTarget(target string, path bool, room int) string { target = r.named(target) width := ansi.StringWidth(target) if !path || width <= room || room < 12 { return ansi.Truncate(target, max(1, room), "…") } head := room / 3 return ansi.Truncate(target, head, "") + "…" + ansi.TruncateLeft(target, width-(room-head-1), "") } // namer names targets the way a person reads them: from the workspace, from ~, // and without store hashes. One replacer serves a whole row. type namer struct { replacer *strings.Replacer } func (r TranscriptRenderer) namer() namer { home, _ := os.UserHomeDir() var roots []string if workspace := expandHome(r.Workspace, home); workspace != "/" && workspace != "." { if real := realPath(workspace); real != workspace { roots = append(roots, real+"/", "") } roots = append(roots, workspace+"/", "") } if home != "" { roots = append(roots, home+"/", "~/") } // the workspace comes first, so it wins where it sits inside home return namer{replacer: strings.NewReplacer(roots...)} } // prepareTarget strips what naming always strips, workspace-independently: // control runs, store hashes. Facts store prepared targets so a render only // applies the workspace replacer. func prepareTarget(target string) string { return storeHash.ReplaceAllString(oneLine(target), "/nix/store/…-") } func (n namer) name(target string) string { return n.replacer.Replace(target) } func (n namer) all(targets []string) []string { named := make([]string, len(targets)) for i, target := range targets { named[i] = n.name(target) } return named } // named is target as a person reads it; see namer. func (r TranscriptRenderer) named(target string) string { return r.namer().name(prepareTarget(target)) } // realPaths caches realPath: a few workspaces, named on every row drawn. var realPaths sync.Map // realPath is path with its symlinks resolved, or path when it cannot be. // The daemon names files by their real path, so on macOS a workspace under // /var or /tmp reaches the transcript as /private/var or /private/tmp. func realPath(path string) string { if real, ok := realPaths.Load(path); ok { return real.(string) } real, err := filepath.EvalSymlinks(path) if err != nil { real = path } realPaths.Store(path, real) return real } func expandHome(path, home string) string { if rest, ok := strings.CutPrefix(path, "~/"); ok && home != "" { path = filepath.Join(home, rest) } return filepath.Clean(path) } // traceLine is a verb then its target, the target shortened to fit width. func (r TranscriptRenderer) traceLine(verb, target string, path bool, width int) string { return fit(verb+" "+r.shortTarget(target, path, width-ansi.StringWidth(verb)-1), "", width) } func isPath(activity daemon.ToolActivity) bool { return activity.Kind == "read" || activity.Kind == "list" } func countLines(text string) string { n := strings.Count(text, "\n") + 1 return fmt.Sprintf("%d %s", n, lineUnit(n)) } // toolRow is the collapsed tool line: what ran, then how much. func toolRow(entry HistoryEntry, failed bool, clock string, width int) string { head, tail := toolRowParts(entry, failed, clock) return fit(head, tail, width) } // toolRowParts is toolRow unfitted: what ran, and the " · " tail of how much. func toolRowParts(entry HistoryEntry, failed bool, clock string) (string, string) { var tail []string if code, ok := entry.ToolArgs["code"].(string); ok && entry.ToolName == "python" { if n := strings.Count(strings.TrimSpace(code), "\n") + 1; n > 1 { tail = append(tail, countLines(strings.TrimSpace(code))) } } if output := toolOutput(entry); output != "" { tail = append(tail, countLines(output)+" out") } if failed { tail = append(tail, "failed") } if clock != "" { tail = append(tail, clock) } suffix := "" if len(tail) > 0 { suffix = " · " + strings.Join(tail, " · ") } return oneLine(toolSummary(entry)), suffix } func (r TranscriptRenderer) diffCounts(change daemon.FileChange) string { return DefaultStyles.Success.Render(fmt.Sprintf("+%d", change.Added)) + " " + DefaultStyles.Error.Render(fmt.Sprintf("−%d", change.Removed)) } func (r TranscriptRenderer) RenderToolTrace(trace *daemon.ToolTrace, flags DisplayFlags, width int) string { if trace == nil { return "" } // Normal mode folds the read of an edited file into its edit row. reads := func(path string) bool { return slices.ContainsFunc(trace.Activities, func(a daemon.ToolActivity) bool { return a.Kind == "read" && a.Target == path }) } edits := func(path string) bool { return slices.ContainsFunc(trace.Changes, func(c daemon.FileChange) bool { return c.Path == path }) } var rows []string if flags.Tools && len(trace.Activities) > 0 { verb := "explored" if slices.ContainsFunc(trace.Activities, func(a daemon.ToolActivity) bool { return a.Kind == "run" }) { verb = "executed" } rows = append(rows, DefaultStyles.Bold.Render(verb)) } for _, act := range trace.Activities { if !flags.Tools && act.Kind == "read" && edits(act.Target) { continue } label, style := act.Kind, DefaultStyles.Faint if flags.Tools { rows = append(rows, " "+r.traceLine(style.Render(label), act.Target, isPath(act), width-2)) } else { rows = append(rows, style.Render(r.traceLine(label, act.Target, isPath(act), width))) } } changeStyle := DefaultStyles.Faint if flags.Tools { changeStyle = DefaultStyles.Bold } for _, change := range trace.Changes { label := "edited" if !flags.Tools && reads(change.Path) { label = "read + edited" } counts := "" if change.Kind == "diff" { counts = " " + r.diffCounts(change) } line := r.traceLine(label, change.Path, true, width-ansi.StringWidth(counts)) rows = append(rows, fit(changeStyle.Render(line), counts, width)) if flags.Diffs { if change.Kind == "diff" { rows = append(rows, r.renderDiffPath(change.Diff, change.Path, width)) } else { rows = append(rows, DefaultStyles.Faint.Render(change.Reason)) } } } if trace.Truncated { notice := "Some activity was not captured · /v shows what is available" if flags.Tools { notice = "Some activity was not captured; this list is incomplete" } rows = append(rows, DefaultStyles.Faint.Render(notice)) } return strings.Join(rows, "\n") } func toolFailed(entry HistoryEntry) bool { if entry.ToolName == "python" { var result struct { Status string `json:"status"` Error string `json:"error"` } if json.Unmarshal([]byte(entry.ToolResult), &result) == nil { if result.Status != "" { return result.Status != "ok" } return result.Error != "" } } return toolErrorLine.MatchString(entry.ToolResult) } var toolErrorLine = regexp.MustCompile(`(?im)^(?:error:|cancelled:|traceback \(most recent call last\):)`) func (r TranscriptRenderer) faintMarkdownRows(text string, width int) []string { return r.faintRows(RenderMarkdownAnsi(text, width), width) } // faintRows wraps text to width and greys out each row. func (r TranscriptRenderer) faintRows(rendered string, width int) []string { var rows []string for line := range strings.SplitSeq(rendered, "\n") { for _, wrapped := range wrapOrChunkLine(line, width) { rows = append(rows, DefaultStyles.Faint.Render(ansi.Strip(wrapped))) } } return rows } // RenderEntry renders an entry on its own, so a chat entry always names its author. func (r TranscriptRenderer) RenderEntry(entry HistoryEntry, flags DisplayFlags, width int) string { return r.RenderAfter(prior{}, entry, flags, width) } // RenderAfter renders an entry in the context of what came before it. func (r TranscriptRenderer) RenderAfter(before prior, entry HistoryEntry, flags DisplayFlags, width int) string { var rows []string switch entry.Kind { case EntryUser, EntryAssistant: if plate := r.nameplate(entry, flags, before); plate != "" { rows = append(rows, plate) } if entry.Pending != settled { // greyed out until the daemon echoes it back rows = append(rows, r.faintMarkdownRows(entry.Text, width)...) break } render := renderCopyable if entry.Live { render = renderGrowing } body := render(entry.Text, width) if entry.Kind == EntryUser && (entry.Source == "" || entry.Source == "chat") { body = r.foldTall(entry, body) } rows = append(rows, body) case EntryThinking: rows = []string{markChrome + DefaultStyles.Faint.Render("thinking")} if flags.Thinking { render := RenderMarkdownAnsi if entry.Live { render = renderGrowing } rows = append(rows, r.faintRows(render(entry.Text, width), width)...) } case EntryTool: trace := entry.ToolTrace hasTrace := trace != nil && (len(trace.Activities) > 0 || len(trace.Changes) > 0) failed := toolFailed(entry) if !hasTrace || flags.Tools || failed { // a glance in normal mode, read in full in verbose mode style := DefaultStyles.Faint clock := "" if flags.Tools { style = lipgloss.NewStyle() clock = formatClock(entry.Timestamp) } if failed { style = DefaultStyles.Error } rows = append(rows, markChrome+style.Render(toolRow(entry, failed, clock, width))) } if hasTrace { rows = append(rows, chrome(r.RenderToolTrace(trace, flags, width))) if !flags.Tools && !failed { break } } if flags.Tools { if entry.ToolName == "python" { if code, ok := entry.ToolArgs["code"].(string); ok { rows = append(rows, renderCopyable("```python\n"+code+"\n```", width)) } } rows = append(rows, strings.Split(cmp.Or(toolOutput(entry), "(no output)"), "\n")...) } case EntryTurnEnd: rows = []string{r.signoff(entry)} case EntryNote: // Styling the whole note pads short rows to its longest line. rows = []string{r.foldTall(entry, strings.Join(r.faintRows(entry.Text, width), "\n"))} case EntryError: rows = []string{r.errorRow(entry.Text)} case EntryCompacted: action := "view" if flags.Compaction { action = "hide" } verb, noun := compactionWords(entry.Strategy) rows = []string{markChrome + DefaultStyles.Faint.Render(fmt.Sprintf("compaction done · %d items %s · ctrl+k %s %s", entry.Evicted, verb, action, noun))} if flags.Compaction { rows = append(rows, r.faintMarkdownRows(entry.Text, width)...) } } return strings.Join(rows, "\n") } // A message of yours, or a note, taller than foldRows plus foldSlack shows // its first foldRows rows and a click target for the rest; the slack keeps a // fold from hiding only a line or two. const ( foldRows = 10 foldSlack = 2 ) // foldTall is body, a settled entry's rendered rows, cut to its first rows // until the entry's key is in Open. func (r TranscriptRenderer) foldTall(entry HistoryEntry, body string) string { rows := strings.Split(body, "\n") if len(rows) <= foldRows+foldSlack { return body } key := entryKey(entry) if r.Open[key] { return body + "\n" + r.foldRow(key, toggleOpen+"show less") } label := fmt.Sprintf("%s%d more lines · click to expand", toggleClosed, len(rows)-foldRows) return strings.Join(rows[:foldRows], "\n") + "\n" + r.foldRow(key, label) } func (r TranscriptRenderer) foldRow(key, label string) string { return markChrome + rowAction{verbMore, key}.mark() + DefaultStyles.Faint.Render(label) } // compactionWords names what a strategy did with the evicted items and what // ctrl+k shows: snapcompact archives them as rendered frames, lcm folds them // into summary nodes, and rolling (or an unnamed strategy) summarizes them. func compactionWords(strategy string) (verb, noun string) { switch strategy { case "snapcompact": return "archived as frames", "archive" case "lcm": return "folded", "folds" default: return "summarized", "summary" } } // Compact entries are one-line summaries. A run of them stacks without blank // rows so a burst of tool calls reads as one block. func Compact(entry HistoryEntry, flags DisplayFlags) bool { switch entry.Kind { case EntryTool: return !flags.Tools case EntryThinking: return !flags.Thinking } return false } // Separated reports whether a blank row belongs between two adjacent entries. // A turn's signoff hangs directly under the turn it closes. func Separated(prev *HistoryEntry, next HistoryEntry, flags DisplayFlags) bool { return prev != nil && next.Kind != EntryTurnEnd && (!Compact(*prev, flags) || !Compact(next, flags)) } // railWidth is the rail and the space after it, at the start of every row. const railWidth = 2 // lane is the rail an entry hangs on. A turn is one unbroken rail: yours in // your color, albedo's in its color, dimmer while it works in tools. type lane int const ( laneNone lane = iota laneYou laneAgent laneBusy ) func laneOf(entry HistoryEntry) lane { switch entry.Kind { case EntryUser: return laneYou case EntryAssistant, EntryTurnEnd, EntryError: return laneAgent case EntryTool, EntryThinking: return laneBusy } return laneNone } // joint is the lane of the blank row between two entries, so the rail // carries through a turn and breaks between turns. func joint(above, below lane) lane { switch { case above == laneNone || below == laneNone || (above == laneYou) != (below == laneYou): return laneNone case above == laneBusy || below == laneBusy: return laneBusy } return above } func (r TranscriptRenderer) rail(l lane) string { switch l { case laneYou: return DefaultStyles.You.Render("│") + " " case laneAgent: return DefaultStyles.Agent.Render("│") + " " case laneBusy: return DefaultStyles.Busy.Render("│") + " " } return strings.Repeat(" ", railWidth) } // Block is an entry as finished transcript rows: the blank row that // separates it from the entry before, when one belongs, then its rows // wrapped beside its rail. head indexes the entry's first row. func (r TranscriptRenderer) Block(before []HistoryEntry, entry HistoryEntry, flags DisplayFlags) (rows []string, head int) { return r.frame(before, entry, flags, func(width int) string { return r.RenderAfter(priorOf(before), entry, flags, width) }) } // BurstBlock is a burst as finished transcript rows, framed like a Block. func (r TranscriptRenderer) BurstBlock(before, burst []HistoryEntry, flags DisplayFlags) []string { rows, _ := r.frame(before, burst[0], flags, func(width int) string { return r.RenderBurst(burst, flags, width) }) return rows } // frame hangs body beside first's rail, after the blank row that separates // it from before when one belongs. head indexes body's first row. func (r TranscriptRenderer) frame(before []HistoryEntry, first HistoryEntry, flags DisplayFlags, body func(width int) string) (rows []string, head int) { own := laneOf(first) if n := len(before); n > 0 && Separated(&before[n-1], first, flags) { rows = append(rows, strings.TrimRight(r.rail(joint(laneOf(before[n-1]), own)), " ")) } head = len(rows) width := max(1, r.BodyWidth-railWidth) gutter := r.rail(own) for line := range strings.SplitSeq(body(width), "\n") { for _, chunk := range markChunks(line, wrapOrChunkLine(line, width)) { rows = append(rows, gutter+chunk) } } return rows, head } // Settle is the rows entry settles after before: none for a compact entry, // whose burst waits for what ends it, else the burst it ends, then itself. // head indexes the entry's first row. func (r TranscriptRenderer) Settle(before []HistoryEntry, entry HistoryEntry, flags DisplayFlags) (rows []string, head int) { if Compact(entry, flags) { return nil, 0 } if burst := trailingBurst(before, flags); len(burst) > 0 { rows = r.BurstBlock(before[:len(before)-len(burst)], burst, flags) } block, at := r.Block(before, entry, flags) return append(rows, block...), len(rows) + at } // OpenBurst is the burst that ends entries, drawn live until it settles. func (r TranscriptRenderer) OpenBurst(entries []HistoryEntry, flags DisplayFlags) []string { burst := trailingBurst(entries, flags) if len(burst) == 0 { return nil } return r.BurstBlock(entries[:len(entries)-len(burst)], burst, flags) }