Something went wrong. Try again.
small gleam coding and (not yet) persistent agent daemon with a detachable cli
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624package tui
import ( "albedo/cli/internal/daemon" "encoding/json" "fmt" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "regexp" "strconv" "strings" "time" "unicode")
type DisplayFlags struct { Thinking bool Tools bool Diffs bool Compaction bool}
type TranscriptRenderer struct { Styles Styles BodyWidth int}
func NewTranscriptRenderer() TranscriptRenderer { return TranscriptRenderer{Styles: DefaultStyles}}
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 { case entry.Kind == EntryUser && entry.Speaker == "": return "You" case entry.Kind == EntryAssistant && entry.Speaker == "": return "albedo" case entry.Kind == EntryUser || entry.Kind == EntryAssistant: return entry.Speaker } 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}
// 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 { return "" } style := r.Styles.Agent if entry.Kind == EntryUser { style = r.Styles.You } var meta []string 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") } plate := style.Render(strings.ToLower(who)) if len(meta) > 0 { plate += r.Styles.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 r.Styles.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 := r.Styles.Agent switch entry.Mood { case moodFailed: style = r.Styles.Error case moodStopped: style = r.Styles.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 { meta = append(meta, fmt.Sprintf("%d %s", entry.Tools, map[bool]string{true: "tool", false: "tools"}[entry.Tools == 1])) } row := style.Render(entry.Mood.face(entry.Timestamp)) if len(meta) > 0 { row += " " + r.Styles.Faint.Render(strings.Join(meta, " · ")) } return row}
var diffHunk = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
func (r TranscriptRenderer) RenderDiff(diff string, width int) string { return r.renderDiffPath(diff, "", width)}
func (r TranscriptRenderer) renderDiffPath(diff, path string, width int) string { if width < 8 { return "…" } var rows []string oldLine, newLine := 0, 0 lang := "text" switch path[strings.LastIndex(path, ".")+1:] { case "ts", "tsx": lang = "typescript" case "js", "jsx": lang = "javascript" case "py": lang = "python" case "rs": lang = "rust" case "sh": lang = "bash" } clean := func(text string) string { return strings.Map(func(ch rune) rune { if ch == '\t' { return ' ' } if ch < 32 || ch == 127 { return ' ' } return ch }, text) } row := func(gutter, content, bg string, syntax bool) { body := keepBackground(r.Styles.Faint.Render(content)) if syntax { body = keepBackground(HighlightCode(content, lang)) } 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(path), panel, false) for _, line := range strings.Split(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(r.Styles.Success, newLine, "+"), clean(line[1:]), add, true) newLine++ case '-': row(gutter(r.Styles.Error, oldLine, "-"), clean(line[1:]), remove, true) oldLine++ case ' ': row(gutter(r.Styles.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.Split(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": return "read " + arg("path") case "write_file": return "write " + arg("path") case "edit_file": return "edit " + 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), "…")}
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 { 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 fit(oneLine(toolSummary(entry)), suffix, width)}
func (r TranscriptRenderer) diffCounts(change daemon.FileChange) string { return r.Styles.Success.Render(fmt.Sprintf("+%d", change.Added)) + " " + r.Styles.Error.Render(fmt.Sprintf("−%d", change.Removed))}
func (r TranscriptRenderer) RenderToolTrace(trace *daemon.ToolTrace, flags DisplayFlags, width int) string { if trace == nil { return "" } var rows []string if !flags.Tools { edited := map[string]bool{} for _, change := range trace.Changes { edited[change.Path] = true } for _, activity := range trace.Activities { if activity.Kind == "read" && edited[activity.Target] { continue } label := activity.Kind if activity.Failed { label += " failed" } style := r.Styles.Faint if activity.Failed { style = r.Styles.Error } rows = append(rows, style.Render(fit(label+" "+oneLine(activity.Target), "", width))) } for _, change := range trace.Changes { label := "edited " + change.Path for _, activity := range trace.Activities { if activity.Kind == "read" && activity.Target == change.Path { label = "read + " + label break } } counts := "" if change.Kind == "diff" { counts = " " + r.diffCounts(change) } rows = append(rows, fit(r.Styles.Faint.Render(oneLine(label)), counts, width)) if flags.Diffs { if change.Kind == "diff" { rows = append(rows, r.renderDiffPath(change.Diff, change.Path, width)) } else { rows = append(rows, r.Styles.Faint.Render(change.Reason)) } } } if trace.Truncated { rows = append(rows, r.Styles.Faint.Render("activity capture limited · /v expand")) } return strings.Join(rows, "\n") } if len(trace.Activities) > 0 { verb := "explored" for _, act := range trace.Activities { if act.Kind == "run" { verb = "executed" break } } rows = append(rows, r.Styles.Bold.Render(verb)) for _, act := range trace.Activities { style := r.Styles.Faint label := act.Kind if act.Failed { style = r.Styles.Error label += " failed" } rows = append(rows, fit(" "+style.Render(label)+" "+oneLine(act.Target), "", width)) } } for _, change := range trace.Changes { counts := "" if change.Kind == "diff" { counts = " " + r.diffCounts(change) } rows = append(rows, fit(r.Styles.Bold.Render(oneLine("edited "+change.Path)), counts, width)) if flags.Diffs { if change.Kind == "diff" { rows = append(rows, r.renderDiffPath(change.Diff, change.Path, width)) } else { rows = append(rows, r.Styles.Faint.Render(change.Reason)) } } } if trace.Truncated { rows = append(rows, r.Styles.Faint.Render("activity capture limited; some operations are not shown")) } 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 { var rows []string for _, line := range strings.Split(RenderMarkdownAnsi(text, width), "\n") { for _, wrapped := range wrapOrChunkLine(line, width) { rows = append(rows, r.Styles.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 } rows = append(rows, RenderMarkdownAnsi(entry.Text, width)) case EntryThinking: if flags.Thinking { rows = append([]string{r.Styles.Faint.Render("thinking")}, r.faintMarkdownRows(entry.Text, width)...) } else { rows = []string{r.Styles.Faint.Render("thinking")} } 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 := r.Styles.Faint if flags.Tools { style = lipgloss.NewStyle() } if failed { style = r.Styles.Error } clock := "" if flags.Tools { clock = formatClock(entry.Timestamp) } rows = append(rows, style.Render(toolRow(entry, failed, clock, width))) } if hasTrace { rows = append(rows, r.RenderToolTrace(trace, flags, width)) if !flags.Tools && !failed { break } } if flags.Tools && entry.ToolName == "python" { if code, ok := entry.ToolArgs["code"].(string); ok { rows = append(rows, RenderMarkdownAnsi("```python\n"+code+"\n```", width)) } } if flags.Tools { output := toolOutput(entry) if output == "" { output = "(no output)" } rows = append(rows, strings.Split(output, "\n")...) } case EntryTurnEnd: rows = []string{r.signoff(entry)} case EntryNote: rows = []string{r.Styles.Faint.Render(entry.Text)} case EntryError: rows = []string{r.errorRow(entry.Text)} case EntryCompacted: action := "view" if flags.Compaction { action = "hide" } rows = []string{r.Styles.Faint.Render(fmt.Sprintf("compaction done · %d items summarized · ctrl+k %s summary", entry.Evicted, action))} if flags.Compaction { rows = append(rows, r.faintMarkdownRows(entry.Text, width)...) } } return strings.Join(rows, "\n")}
// 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 r.Styles.You.Render("│") + " " case laneAgent: return r.Styles.Agent.Render("│") + " " case laneBusy: return r.Styles.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) { own := laneOf(entry) if n := len(before); n > 0 && Separated(&before[n-1], entry, 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.Split(r.RenderAfter(priorOf(before), entry, flags, width), "\n") { for _, chunk := range wrapOrChunkLine(line, width) { rows = append(rows, gutter+chunk) } } return rows, head}
func (r TranscriptRenderer) RenderHistory(history *BoundedHistory, flags DisplayFlags) string { var rows []string if notice := history.TruncationNotice(); notice != "" { rows = append(rows, r.rail(laneNone)+r.Styles.Warning.Render(notice), "") } entries := history.Entries() for i, entry := range entries { block, _ := r.Block(entries[:i], entry, flags) rows = append(rows, block...) } return strings.Join(rows, "\n")}