Something went wrong. Try again.
small gleam coding and (not yet) persistent agent daemon with a detachable cli
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797package tui
import ( "albedo/cli/internal/daemon" "charm.land/lipgloss/v2" "cmp" "encoding/json" "fmt" "github.com/alecthomas/chroma/v2" "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 // highlights keeps the chat's colored diff lines; nil keeps none. highlights *highlightCache}
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)) } if entry.CacheMissTokens > 0 { meta = append(meta, formatCount(entry.CacheMissTokens)+" miss") } if entry.CacheDriftTokens > 0 { meta = append(meta, formatCount(entry.CacheDriftTokens)+" drift") } 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 "…" } clean := func(text string) string { return strings.Map(func(ch rune) rune { if ch == '\t' || ch < 32 || ch == 127 { return ' ' } return ch }, text) } type diffRow struct { gutter, content, bg string syntax bool } 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))) } parsed := []diffRow{{" ", clean(r.named(path)), panel, false}} oldLine, newLine := 0, 0 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]) parsed = append(parsed, diffRow{" ⋮ ", clean(line), panel, false}) continue } switch line[0] { case '+': parsed = append(parsed, diffRow{gutter(DefaultStyles.Success, newLine, "+"), clean(line[1:]), add, true}) newLine++ case '-': parsed = append(parsed, diffRow{gutter(DefaultStyles.Error, oldLine, "-"), clean(line[1:]), remove, true}) oldLine++ case ' ': parsed = append(parsed, diffRow{gutter(DefaultStyles.Decor, newLine, " "), clean(line[1:]), panel, true}) newLine++ oldLine++ default: parsed = append(parsed, diffRow{" ⋮ ", clean(line), panel, false}) } } // One highlighting pass for the whole diff: chroma rebuilds its theme // table on every call. var code []string for _, row := range parsed { if row.syntax { code = append(code, row.content) } } code = HighlightLines(code, lexerFor(path), r.highlights) var rows []string for _, row := range parsed { body := keepBackground(DefaultStyles.Faint.Render(row.content)) if row.syntax { body, code = keepBackground(code[0]), code[1:] } for i, part := range strings.Split(ansi.Hardwrap(body, max(1, width-ansi.StringWidth(row.gutter)), true), "\n") { prefix := row.gutter if i > 0 { prefix = strings.Repeat(" ", ansi.StringWidth(row.gutter)) } cell := prefix + part rows = append(rows, row.bg+cell+strings.Repeat(" ", max(0, width-ansi.StringWidth(cell)))+ansiReset) } } return strings.Join(rows, "\n")}
// lexersByName holds the lexer chroma matched to each file name: matching tries// every lexer's patterns, and a transcript names the same files many times.var lexersByName sync.Map
func lexerFor(path string) chroma.Lexer { name := filepath.Base(path) if cached, ok := lexersByName.Load(name); ok { lexer, _ := cached.(chroma.Lexer) // nil when no lexer claims the name return lexer } lexer := lexers.Match(name) lexersByName.Store(name, lexer) return lexer}
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))}
// RenderToolTrace is a call's glance in normal mode: what it read, ran and// searched, then what it changed, with the read of an edited file folded// into its edit row. Verbose mode draws the call as a card instead.func (r TranscriptRenderer) RenderToolTrace(trace *daemon.ToolTrace, flags DisplayFlags, width int) string { if trace == nil { return "" } 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 for _, act := range trace.Activities { if act.Kind == "read" && edits(act.Target) { continue } rows = append(rows, DefaultStyles.Faint.Render(r.traceLine(act.Kind, act.Target, isPath(act), width))) if act.Kind == "display" && act.Detail != "" { rows = append(rows, r.renderDisplayRows(act.Target, act.Detail, width)...) } } for _, change := range trace.Changes { label := "edited" if 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(DefaultStyles.Faint.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 { rows = append(rows, DefaultStyles.Faint.Render("Some activity was not captured · /v shows what is available")) } 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: if flags.Tools { rows = r.toolCard(entry, flags, width) break } // a glance: the trace when the harness recorded one, else the call trace := entry.ToolTrace hasTrace := trace != nil && (len(trace.Activities) > 0 || len(trace.Changes) > 0) failed := toolFailed(entry) if !hasTrace || failed { style := DefaultStyles.Faint if failed { style = DefaultStyles.Error } rows = append(rows, markChrome+style.Render(toolRow(entry, failed, "", width))) } if hasTrace { rows = append(rows, chrome(r.RenderToolTrace(trace, flags, width))) } 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)}