package tui import ( "cmp" "fmt" "path" "regexp" "slices" "strings" "albedo/cli/internal/daemon" "github.com/charmbracelet/x/ansi" ) // A burst is the work between two pieces of prose: a run of compact entries, // tool calls and thinking spells, drawn as one summary row. It renders once // something else ends it; until then it is drawn live, so settled rows stay // append-only. // trailingBurst is the run of compact entries that ends entries. func trailingBurst(entries []HistoryEntry, flags DisplayFlags) []HistoryEntry { i := len(entries) for i > 0 && Compact(entries[i-1], flags) { i-- } return entries[i:] } // burstNamed is the span of entries holding the burst that key names, or // false when none is retained. func burstNamed(entries []HistoryEntry, key string, flags DisplayFlags) (start, end int, ok bool) { for i, entry := range entries { if !Compact(entry, flags) || i > 0 && Compact(entries[i-1], flags) || factsOf(entry).key != key { continue } end = i + 1 for end < len(entries) && Compact(entries[end], flags) { end++ } return i, end, true } return 0, 0, false } // tally keeps labels in the order they first came, with how often each did. type tally struct { counts map[string]int keys []string } func (t *tally) add(key string) { if key == "" { return } if t.counts == nil { t.counts = map[string]int{} } if t.counts[key] == 0 { t.keys = append(t.keys, key) } t.counts[key]++ } // dedup keeps first order and drops repeats naming produced. func dedup(keys []string) []string { seen := make(map[string]bool, len(keys)) kept := keys[:0:0] for _, key := range keys { if !seen[key] { seen[key] = true kept = append(kept, key) } } return kept } func (t *tally) addAll(keys []string) { for _, key := range keys { t.add(key) } } // counted labels each key, marking repeats: `go test ×4`. func (t tally) counted() []string { labels := make([]string, len(t.keys)) for i, key := range t.keys { labels[i] = key if n := t.counts[key]; n > 1 { labels[i] += fmt.Sprintf(" ×%d", n) } } return labels } // entryFacts is what one entry contributes to a burst, computed once when the // entry settles. Targets stay as the trace recorded them; naming them for the // workspace happens at render time, so a workspace change re-names old rows. type entryFacts struct { // key is the entry's entryKey, which names the burst it starts key string read []string edited []string searched []string looked []lookup ran []string py []string used []string changes []daemon.FileChange thoughtMs int64 added int removed int untimed bool failed bool } // lookup is one web search a call made: what it asked, what it found, and the // key its fold opens by. type lookup struct{ key, query, detail string } // factsOf reads one entry's contribution, using what append already computed // when it can. func factsOf(entry HistoryEntry) entryFacts { if entry.facts != nil { return *entry.facts } f := entryFacts{key: entryKey(entry)} if entry.Kind == EntryThinking { f.thoughtMs, f.untimed = entry.ElapsedMs, entry.ElapsedMs <= 0 return f } if entry.Kind != EntryTool { return f } f.failed = toolFailed(entry) if trace := entry.ToolTrace; trace != nil && (len(trace.Activities) > 0 || len(trace.Changes) > 0) { for _, change := range trace.Changes { f.edited = append(f.edited, prepareTarget(change.Path)) f.added += change.Added f.removed += change.Removed f.changes = append(f.changes, change) } for _, activity := range trace.Activities { switch activity.Kind { case "read": f.read = append(f.read, activity.Target) case "list": f.read = append(f.read, strings.TrimSuffix(activity.Target, "/")+"/") case "search": f.searched = append(f.searched, oneLine(activity.Target)) case "web": key := fmt.Sprintf("%x-%x", entry.Timestamp, fnv1a(fnvOffset64, f.key+"\x00"+activity.Target)) f.looked = append(f.looked, lookup{key, activity.Target, activity.Detail}) case "run": f.ran = append(f.ran, commandName(activity.Target)) } } return f } arg := func(key string) string { s, _ := entry.ToolArgs[key].(string) return s } switch entry.ToolName { case "python": f.py = append(f.py, cmp.Or(pyLabel(arg("code")), "cell")) case "shell", "bash": f.ran = append(f.ran, cmp.Or(commandName(arg("command")), entry.ToolName)) case "read_file": f.read = append(f.read, prepareTarget(arg("path"))) case "write_file", "edit_file": f.edited = append(f.edited, prepareTarget(arg("path"))) default: f.used = append(f.used, entry.ToolName) } return f } type burst struct { read tally edited tally searched tally looked []lookup ran tally py tally used tally changes []daemon.FileChange failed []HistoryEntry thoughtMs int64 thoughts int added int removed int untimed bool // a thought from before this client watched has no duration } // merge folds one entry's facts into a burst. func (b *burst) merge(f entryFacts, entry HistoryEntry) { if f.thoughtMs != 0 || f.untimed { b.thoughts++ b.thoughtMs += f.thoughtMs b.untimed = b.untimed || f.untimed } b.read.addAll(f.read) b.edited.addAll(f.edited) b.searched.addAll(f.searched) b.looked = append(b.looked, f.looked...) b.ran.addAll(f.ran) b.py.addAll(f.py) b.used.addAll(f.used) b.added += f.added b.removed += f.removed b.changes = append(b.changes, f.changes...) if f.failed { b.failed = append(b.failed, entry) } } // collect merges every entry's facts; entries settled through the chat carry // theirs already, so this only pays for tool results once per entry. func collect(entries []HistoryEntry) burst { var b burst for _, entry := range entries { b.merge(factsOf(entry), entry) } return b } // pyLabel is what a python cell that ran nothing external was doing: the // first line of it, folded and capped. func pyLabel(code string) string { line := oneLine(firstLine(code)) return ansi.Truncate(line, 48, "…") } // clause is a verb and what it acted on: n things, noun in the plural, // listed as items. shown items fit, the rest count; none shown is a count. type clause struct { verb, noun string items []string n int shown int } func (c clause) String() string { if c.shown == 0 { noun := c.noun if c.n == 1 { noun = strings.TrimSuffix(noun, "s") } return fmt.Sprintf("%s %d %s", c.verb, c.n, noun) } text := c.verb + " " + strings.Join(c.items[:c.shown], ", ") if hidden := len(c.items) - c.shown; hidden > 0 { text += fmt.Sprintf(" +%d", hidden) } return text } // summary is the burst's one row: what it thought, read, changed, and ran, // with the lists shortened until the row fits width. func (r TranscriptRenderer) summary(b burst, width int, n namer) string { var head []string if b.thoughts > 0 { thought := "thought" if !b.untimed && b.thoughtMs >= 1000 { thought += " " + formatElapsed(b.thoughtMs) } head = append(head, thought) } // naming can make two recorded paths the same ("cli/a.go" and the absolute // form), so lists dedupe after naming, and reads defer to edits in that // same named space edited := dedup(n.all(b.edited.keys)) reads := slices.DeleteFunc(dedup(n.all(b.read.keys)), func(p string) bool { return slices.Contains(edited, p) }) var clauses []*clause for _, c := range []*clause{ {verb: "read", noun: "files", n: len(reads), items: pathGroups(reads)}, {verb: "searched", noun: "patterns", n: len(b.searched.keys), items: dedup(n.all(b.searched.keys))}, {verb: "looked up", noun: "searches", n: len(b.looked), items: queries(b.looked)}, {verb: "edited", noun: "files", n: len(edited), items: pathGroups(edited)}, {verb: "ran", noun: "commands", n: len(b.ran.keys), items: b.ran.counted()}, {verb: "python", noun: "cells", n: len(b.py.keys), items: b.py.counted()}, {verb: "used", noun: "tools", n: len(b.used.keys), items: b.used.counted()}, } { if len(c.items) > 0 { c.shown = len(c.items) clauses = append(clauses, c) } } line := func() string { parts := slices.Clone(head) for _, c := range clauses { parts = append(parts, c.String()) } return strings.Join(parts, " · ") } tail := "" if n := len(b.failed); n > 0 { tail += DefaultStyles.Error.Render(fmt.Sprintf(" · %d failed", n)) } if b.added+b.removed > 0 { tail += " " + r.diffCounts(daemon.FileChange{Added: b.added, Removed: b.removed}) } // shorten the longest list first; once each shows one item, the lists // become counts, what was read before what was edited room := width - ansi.StringWidth(tail) for ansi.StringWidth(line()) > room && shorten(clauses) { } return fit(DefaultStyles.Faint.Render(line()), tail, width) } // queries names each search by its query, quoted, on one line. func queries(looked []lookup) []string { names := make([]string, len(looked)) for i, look := range looked { names[i] = "“" + oneLine(look.query) + "”" } return dedup(names) } // A web search's answer shows its first lookupRows rows under the burst and a // click target for the rest. const lookupRows = 4 // foldLookup is a search's answer under its burst: the first rows, then a // row that opens the rest until its key is in Open. func (r TranscriptRenderer) foldLookup(look lookup, width int) []string { if look.detail == "" { return nil } rows := r.faintMarkdownRows(look.detail, width-2) for i := range rows { rows[i] = markChrome + " " + rows[i] } if len(rows) <= lookupRows+foldSlack { return rows } if r.Open[look.key] { return append(rows, r.foldRow(look.key, " "+toggleOpen+"show less")) } label := fmt.Sprintf(" %s%d more lines · click to expand", toggleClosed, len(rows)-lookupRows) return append(rows[:lookupRows:lookupRows], r.foldRow(look.key, label)) } // countedFirst ranks which list turns into a count first. var countedFirst = map[string]int{"read": 0, "searched": 1, "used": 2, "ran": 3, "python": 4, "looked up": 5, "edited": 6} // shorten hides one more item, and reports whether any was left to hide. func shorten(clauses []*clause) bool { var pick *clause for _, c := range clauses { if c.shown > 1 && (pick == nil || c.shown > pick.shown) { pick = c } } if pick == nil { for _, c := range clauses { if c.shown == 1 && (pick == nil || countedFirst[c.verb] < countedFirst[pick.verb]) { pick = c } } } if pick == nil { return false } pick.shown-- return true } // A burst's summary leads with a triangle when it has calls to list. const ( toggleClosed = "▸ " toggleOpen = "▾ " ) // RenderBurst is a burst's summary row, its steps when it is open, then the // row of each call that failed, and with diffs shown, each change. func (r TranscriptRenderer) RenderBurst(entries []HistoryEntry, flags DisplayFlags, width int) string { b, n := collect(entries), r.namer() key := factsOf(entries[0]).key opens := slices.ContainsFunc(entries, func(e HistoryEntry) bool { return e.Kind == EntryTool }) open := opens && r.Open[key] head, room := markChrome, width if opens { // the triangle says the row opens, and which way it is now toggle := toggleClosed if open { toggle = toggleOpen } head += rowAction{verbOpen, key}.mark() + DefaultStyles.Faint.Render(toggle) room -= ansi.StringWidth(toggle) } rows := []string{head + r.summary(b, room, n)} if open { // failed calls are among the steps for _, step := range r.steps(entries, width-2) { rows = append(rows, " "+step) } } else { for _, entry := range b.failed { rows = append(rows, markChrome+DefaultStyles.Error.Render(toolRow(entry, true, "", width))) } } for _, look := range b.looked { rows = append(rows, r.foldLookup(look, width)...) } if flags.Diffs { for _, change := range b.changes { if change.Kind == "diff" { rows = append(rows, r.renderDiffPath(change.Diff, change.Path, width)) } else { rows = append(rows, DefaultStyles.Faint.Render(n.name(prepareTarget(change.Path))+": "+change.Reason)) } } } return strings.Join(rows, "\n") } // steps is one row for each thing a burst's calls did, in order: the full // command, pattern, or path, which the summary row only counts. func (r TranscriptRenderer) steps(entries []HistoryEntry, width int) []string { var rows []string for _, entry := range entries { if entry.Kind != EntryTool { continue } trace := entry.ToolTrace if trace == nil || len(trace.Activities)+len(trace.Changes) == 0 { failed, style := toolFailed(entry), DefaultStyles.Faint if failed { style = DefaultStyles.Error } rows = append(rows, style.Render(toolRow(entry, failed, "", width))) continue } for _, act := range trace.Activities { style := DefaultStyles.Faint rows = append(rows, style.Render(r.traceLine(stepLabel(act.Kind), act.Target, isPath(act), width))) } for _, change := range trace.Changes { counts := "" if change.Kind == "diff" { counts = " " + r.diffCounts(change) } line := r.traceLine(stepLabel("edit"), change.Path, true, width-ansi.StringWidth(counts)) rows = append(rows, fit(DefaultStyles.Faint.Render(line), counts, width)) } } return rows } // stepLabel pads a step's kind so the targets after it line up. func stepLabel(kind string) string { return fmt.Sprintf("%-6s", kind) } // pathGroups names paths by their parent and name, and paths that share a // parent together: `tui/{chat,transcript}.go`. func pathGroups(paths []string) []string { var dirs []string names := map[string][]string{} for _, p := range paths { dir, name := path.Split(strings.TrimSuffix(p, "/")) if strings.HasSuffix(p, "/") { name += "/" } if _, seen := names[dir]; !seen { dirs = append(dirs, dir) } names[dir] = append(names[dir], name) } labels := make([]string, len(dirs)) parents := map[string]int{} for _, dir := range dirs { parents[parentName(dir)]++ } for i, dir := range dirs { parent := parentName(dir) if parents[parent] > 1 { parent = dir } labels[i] = parent + braced(names[dir]) } return labels } // parentName is the last directory of dir with its slash, or nothing at the root. func parentName(dir string) string { if dir = strings.TrimSuffix(dir, "/"); dir == "" { return "" } parent := path.Base(dir) + "/" if strings.HasPrefix(dir, "/") { parent = "/" + parent } return parent } // braced joins names in braces, a shared extension outside them. func braced(names []string) string { if len(names) == 1 { return names[0] } ext := path.Ext(names[0]) stems := make([]string, len(names)) for i, name := range names { if ext == "" || path.Ext(name) != ext || strings.HasSuffix(name, "/") { return "{" + strings.Join(names, ",") + "}" } stems[i] = strings.TrimSuffix(name, ext) } return "{" + strings.Join(stems, ",") + "}" + ext } var ( commandBreak = regexp.MustCompile(`&&|\|\||[;|\n]`) assignment = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=`) subcommand = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) // setup that comes before the command a line is about preamble = wordSet("cd", "export", "set", "source", ".") // programs whose first word names what they do; for the rest it is data, // like grep's pattern subcommanded = wordSet("git", "jj", "go", "cargo", "gleam", "npm", "pnpm", "yarn", "bun", "uv", "pip", "nix", "brew", "docker", "kubectl", "systemctl", "tg", "gh", "make", "just") ) func wordSet(names ...string) map[string]bool { m := make(map[string]bool, len(names)) for _, name := range names { m[name] = true } return m } // commandName is what a command line runs, the program and its subcommand // when it has one: `cd cli && go test ./...` ran `go test`. func commandName(command string) string { for _, part := range commandBreak.Split(strings.ReplaceAll(command, "\\\n", ""), -1) { fields := strings.Fields(part) for len(fields) > 0 && (assignment.MatchString(fields[0]) || fields[0] == "exec" || fields[0] == "time") { fields = fields[1:] } if len(fields) == 0 || preamble[fields[0]] { continue } name := fields[0] if strings.HasPrefix(name, "/") { name = path.Base(name) } if len(fields) > 1 && subcommanded[name] && subcommand.MatchString(fields[1]) { name += " " + fields[1] } return name } return oneLine(command) } // thinkingLine follows the newest nonempty line, even when thinking arrives // in fragments or ends in a newline; one-line summaries still show their header. func thinkingLine(text string) string { for text != "" { at := strings.LastIndexByte(text, '\n') line := strings.TrimLeft(strings.TrimSpace(text[at+1:]), "#> ") if line = oneLine(strings.NewReplacer("**", "", "__", "").Replace(line)); line != "" { return line } if at < 0 { break } text = text[:at] } return "" }