Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701package 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 displayed []lookup ran []string made []string updated []string artifacts []daemon.ToolActivity skills []string py []string used []string changes []daemon.FileChange thoughtMs int64 added int removed int untimed bool failed bool}
// lookup is one web search or displayed block a call made: its label, its// detail, 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) arg := func(key string) string { s, _ := entry.ToolArgs[key].(string) return s } 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 "display": key := fmt.Sprintf("%x-%x", entry.Timestamp, fnv1a(fnvOffset64, fmt.Sprintf("%s\x00display:%d\x00%s", f.key, len(f.displayed), activity.Target))) f.displayed = append(f.displayed, lookup{key, activity.Target, activity.Detail}) case "run": f.ran = append(f.ran, commandName(activity.Target)) case "artifact": if activity.Artifact == nil { break } f.artifacts = append(f.artifacts, activity) if activity.Artifact.Version > 1 { f.updated = append(f.updated, oneLine(activity.Target)) } else { f.made = append(f.made, oneLine(activity.Target)) } case "skill": f.skills = append(f.skills, oneLine(activity.Target)) } } // a skill shows as a row under the burst; a cell that did nothing // else reads in the summary as the cell it was if len(f.skills) == len(trace.Activities) && len(trace.Changes) == 0 { f.py = append(f.py, cmp.Or(pyLabel(entry.ToolTitle, arg("code")), "cell")) } return f } switch entry.ToolName { case "python": f.py = append(f.py, cmp.Or(pyLabel(entry.ToolTitle, 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 displayed []lookup ran tally made tally updated tally artifacts []daemon.ToolActivity skills 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.displayed = append(b.displayed, f.displayed...) b.ran.addAll(f.ran) b.py.addAll(f.py) b.used.addAll(f.used) b.made.addAll(f.made) b.updated.addAll(f.updated) b.artifacts = append(b.artifacts, f.artifacts...) b.skills.addAll(f.skills) 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(title, code string) string { line := title if line == "" { line = firstLine(code) } return ansi.Truncate(oneLine(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: "displayed", noun: "items", n: len(b.displayed), items: queries(b.displayed)}, {verb: "edited", noun: "files", n: len(edited), items: pathGroups(edited)}, {verb: "ran", noun: "commands", n: len(b.ran.keys), items: b.ran.counted()}, {verb: "made", noun: "artifacts", n: len(b.made.keys), items: b.made.keys}, {verb: "updated", noun: "artifacts", n: len(b.updated.keys), items: b.updated.keys}, {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] } return r.foldIndented(look.key, rows, lookupRows)}
// renderDisplayRows renders a displayed block with diff or markdown styling,// indented by two columns.func (r TranscriptRenderer) renderDisplayRows(target, detail string, width int) []string { if detail == "" { return nil } bodyWidth := max(1, width-2) var rendered string if header, diff, ok := splitDisplayDiff(detail); ok { var parts []string if header != "" { parts = append(parts, renderCopyable(header, bodyWidth)) } parts = append(parts, r.renderDiffPath(diff, target, bodyWidth)) rendered = strings.Join(parts, "\n") } else if lexer := lexerFor(target); lexer != nil && lexer.Config().Name != "Markdown" { // display() prefixes a title as markdown, not as part of the file. header := "**" + target + "**\n\n" if strings.HasPrefix(detail, header) { rendered = renderCopyable(strings.TrimSuffix(header, "\n\n"), bodyWidth) + "\n\n" detail = strings.TrimPrefix(detail, header) } rendered += strings.Join(HighlightLines(plainLines(detail), lexer, r.highlights), "\n") } else { rendered = renderCopyable(detail, bodyWidth) } var rows []string for line := range strings.SplitSeq(rendered, "\n") { for _, chunk := range markChunks(line, wrapOrChunkLine(line, bodyWidth)) { rows = append(rows, " "+chunk) } } return rows}
// foldDisplay is a displayed block under its burst: rendered with diff or// markdown styling and folded past foldRows until its key is in Open.func (r TranscriptRenderer) foldDisplay(disp lookup, width int) []string { rows := r.renderDisplayRows(disp.query, disp.detail, width) if len(rows) == 0 { return nil } return r.foldIndented(disp.key, rows, foldRows)}
func (r TranscriptRenderer) foldIndented(key string, rows []string, limit int) []string { if len(rows) <= limit+foldSlack { return rows } if r.Open[key] { return append(rows, r.foldRow(key, " "+toggleOpen+"show less")) } label := fmt.Sprintf(" %s%d more lines · click to expand", toggleClosed, len(rows)-limit) return append(rows[:limit:limit], r.foldRow(key, label))}
func splitDisplayDiff(detail string) (header, diff string, ok bool) { lines := strings.Split(detail, "\n") for i, line := range lines { if diffHunk.MatchString(line) || strings.HasPrefix(line, "diff --git ") || (strings.HasPrefix(line, "--- ") && i+1 < len(lines) && strings.HasPrefix(lines[i+1], "+++ ")) { return strings.TrimSpace(strings.Join(lines[:i], "\n")), strings.Join(lines[i:], "\n"), true } } return "", "", false}
// 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, "displayed": 6, "edited": 7}
// 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)...) } for _, disp := range b.displayed { rows = append(rows, r.foldDisplay(disp, width)...) } // what the burst made stays one press away, open or not for _, act := range newestArtifacts(b.artifacts) { rows = append(rows, markChrome+artifactRow(act, width)) } for _, name := range b.skills.keys { rows = append(rows, markChrome+DefaultStyles.Faint.Render(skillRow(name, 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")}
// newestArtifacts keeps each artifact once, at the newest version the burst// made, in the order the burst first touched them.func newestArtifacts(activities []daemon.ToolActivity) []daemon.ToolActivity { var kept []daemon.ToolActivity at := map[string]int{} for _, act := range activities { if i, seen := at[act.Artifact.ID]; seen { kept[i] = act continue } at[act.Artifact.ID] = len(kept) kept = append(kept, act) } return kept}
// 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 ""}