okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.pettui: name diff paths and real-path edits from the workspace, so macOS /var and /tmp workspaces read and open50b6cce

main
24.1 KiB787 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"charm.land/lipgloss/v2"
6	"cmp"
7	"encoding/json"
8	"fmt"
9	"github.com/alecthomas/chroma/v2/lexers"
10	"github.com/charmbracelet/x/ansi"
11	"os"
12	"path/filepath"
13	"regexp"
14	"slices"
15	"strconv"
16	"strings"
17	"sync"
18	"time"
19	"unicode"
20)
21
22type DisplayFlags struct {
23	Thinking   bool
24	Tools      bool
25	Diffs      bool
26	Compaction bool
27}
28
29type TranscriptRenderer struct {
30	// Open holds the keys of expanded bursts, messages, and notes.
31	Open map[string]bool
32	// Workspace roots the paths trace rows name.
33	Workspace string
34	BodyWidth int
35}
36
37func NewTranscriptRenderer() TranscriptRenderer {
38	return TranscriptRenderer{}
39}
40
41func formatClock(timestamp int64) string {
42	if timestamp <= 0 {
43		return ""
44	}
45	return time.UnixMilli(timestamp).Format("15:04:05")
46}
47
48// speaker names who wrote a chat entry, or "" for tool and status rows.
49func speaker(entry HistoryEntry) string {
50	switch entry.Kind {
51	case EntryUser:
52		return cmp.Or(entry.Speaker, "You")
53	case EntryAssistant:
54		return cmp.Or(entry.Speaker, "albedo")
55	default:
56		return ""
57	}
58}
59
60// prior is what a new entry follows: the newest chat author, and when the
61// conversation last moved, which a turn's end also marks.
62type prior struct {
63	speaker string
64	at      int64
65}
66
67func priorOf(entries []HistoryEntry) prior {
68	var p prior
69	for i := len(entries) - 1; i >= 0 && p.speaker == ""; i-- {
70		e := entries[i]
71		if p.at == 0 && (e.Kind == EntryTurnEnd || speaker(e) != "") {
72			p.at = e.Timestamp
73		}
74		p.speaker = speaker(e)
75	}
76	return p
77}
78
79// recent is as much of the end of entries as priorOf and a separator read:
80// back to the newest entry with a speaker.
81func recent(entries []HistoryEntry) []HistoryEntry {
82	for i, entry := range slices.Backward(entries) {
83		if speaker(entry) != "" {
84			return entries[i:]
85		}
86	}
87	return entries
88}
89
90// awayMs is the pause before your message that earns a "later" note.
91const awayMs = 30 * 60_000
92
93// nameplate labels a change of speaker, so a turn that resumes after tool
94// rows keeps its author's name once. Verbose mode labels every message.
95func (r TranscriptRenderer) nameplate(entry HistoryEntry, flags DisplayFlags, before prior) string {
96	who := speaker(entry)
97	if !flags.Tools && who == before.speaker && entry.MailKind == "" {
98		return ""
99	}
100	style := DefaultStyles.Agent
101	if entry.Kind == EntryUser && (entry.Source == "" || entry.Source == "chat") {
102		style = DefaultStyles.You
103	}
104	var meta []string
105	if entry.MailKind != "" {
106		meta = append(meta, entry.MailKind)
107	}
108	if clock := formatClock(entry.Timestamp); flags.Tools && clock != "" {
109		meta = append(meta, clock)
110	}
111	if gap := entry.Timestamp - before.at; entry.Kind == EntryUser && before.at > 0 && gap >= awayMs {
112		meta = append(meta, formatGap(gap))
113	}
114	if entry.Pending == queued {
115		meta = append(meta, "queued")
116	}
117	if entry.Pending == unresolved {
118		meta = append(meta, "unresolved")
119	}
120	plate := markChrome + style.Render(strings.ToLower(who))
121	if len(meta) > 0 {
122		plate += DefaultStyles.Faint.Render(" · " + strings.Join(meta, " · "))
123	}
124	return plate
125}
126
127// errorRow marks only the label red. Long red text tires the eye, and the
128// message reads best in the prose color.
129func (r TranscriptRenderer) errorRow(text string) string {
130	return DefaultStyles.Error.Render("error:") + " " + text
131}
132
133// signoff closes a turn: a face for how it ended, then how long it took.
134func (r TranscriptRenderer) signoff(entry HistoryEntry) string {
135	style := DefaultStyles.Agent
136	switch entry.Mood {
137	case moodFailed:
138		style = DefaultStyles.Error
139	case moodStopped:
140		style = DefaultStyles.Warning
141	}
142	var meta []string
143	if entry.Mood == moodStopped {
144		meta = append(meta, "stopped by you")
145	}
146	if entry.ElapsedMs > 0 {
147		meta = append(meta, formatElapsed(entry.ElapsedMs))
148	}
149	if entry.Tools > 0 {
150		unit := "tools"
151		if entry.Tools == 1 {
152			unit = "tool"
153		}
154		meta = append(meta, fmt.Sprintf("%d %s", entry.Tools, unit))
155	}
156	if entry.TokensPerSecond > 0 {
157		meta = append(meta, formatRate(entry.TokensPerSecond))
158	}
159	row := markChrome + rowAction{verbCopy, entryKey(entry)}.mark() + style.Render(entry.Mood.face(entry.Timestamp))
160	if len(meta) > 0 {
161		row += " " + DefaultStyles.Faint.Render(strings.Join(meta, " · "))
162	}
163	return row + DefaultStyles.Faint.Render(" · ") + DefaultStyles.Muted.Render("⧉")
164}
165
166var diffHunk = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
167
168func (r TranscriptRenderer) renderDiffPath(diff, path string, width int) string {
169	if width < 8 {
170		return "…"
171	}
172	var rows []string
173	oldLine, newLine := 0, 0
174	lexer := lexers.Match(filepath.Base(path))
175	clean := func(text string) string {
176		return strings.Map(func(ch rune) rune {
177			if ch == '\t' || ch < 32 || ch == 127 {
178				return ' '
179			}
180			return ch
181		}, text)
182	}
183	row := func(gutter, content, bg string, syntax bool) {
184		body := keepBackground(DefaultStyles.Faint.Render(content))
185		if syntax {
186			body = keepBackground(HighlightCode(content, lexer))
187		}
188		for i, part := range strings.Split(ansi.Hardwrap(body, max(1, width-ansi.StringWidth(gutter)), true), "\n") {
189			prefix := gutter
190			if i > 0 {
191				prefix = strings.Repeat(" ", ansi.StringWidth(gutter))
192			}
193			cell := prefix + part
194			rows = append(rows, bg+cell+strings.Repeat(" ", max(0, width-ansi.StringWidth(cell)))+ansiReset)
195		}
196	}
197	panel, add, remove := diffPanel(), diffAdded(), diffRemoved()
198	gutter := func(style lipgloss.Style, n int, sign string) string {
199		return keepBackground(style.Render(fmt.Sprintf("%4d %s ", n, sign)))
200	}
201	row(" ", clean(r.named(path)), panel, false)
202	for line := range strings.SplitSeq(diff, "\n") {
203		if line == "" || strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") {
204			continue
205		}
206		if hunk := diffHunk.FindStringSubmatch(line); hunk != nil {
207			oldLine, _ = strconv.Atoi(hunk[1])
208			newLine, _ = strconv.Atoi(hunk[2])
209			row(" ⋮ ", clean(line), panel, false)
210			continue
211		}
212		switch line[0] {
213		case '+':
214			row(gutter(DefaultStyles.Success, newLine, "+"), clean(line[1:]), add, true)
215			newLine++
216		case '-':
217			row(gutter(DefaultStyles.Error, oldLine, "-"), clean(line[1:]), remove, true)
218			oldLine++
219		case ' ':
220			row(gutter(DefaultStyles.Decor, newLine, " "), clean(line[1:]), panel, true)
221			newLine++
222			oldLine++
223		default:
224			row(" ⋮ ", clean(line), panel, false)
225		}
226	}
227	return strings.Join(rows, "\n")
228}
229
230func lineUnit(n int) string {
231	if n == 1 {
232		return "line"
233	}
234	return "lines"
235}
236
237func firstLine(text string) string {
238	for line := range strings.SplitSeq(text, "\n") {
239		if line = strings.TrimSpace(line); line != "" {
240			return line
241		}
242	}
243	return ""
244}
245
246func toolSummary(entry HistoryEntry) string {
247	arg := func(key string) string {
248		if entry.ToolArgs == nil {
249			return ""
250		}
251		return fmt.Sprint(entry.ToolArgs[key])
252	}
253	switch entry.ToolName {
254	case "python":
255		return "python · " + firstLine(arg("code"))
256	case "shell":
257		return "$ " + firstLine(arg("command"))
258	case "read_file", "write_file", "edit_file":
259		return strings.TrimSuffix(entry.ToolName, "_file") + " " + arg("path")
260	default:
261		return entry.ToolName
262	}
263}
264
265// toolOutput is what a tool printed. Python cells answer with a JSON
266// envelope, and counting the envelope reports one line for every cell.
267func toolOutput(entry HistoryEntry) string {
268	if entry.ToolName == "python" {
269		var cell struct{ Output, Value, Error string }
270		if json.Unmarshal([]byte(entry.ToolResult), &cell) == nil {
271			var parts []string
272			for _, part := range []string{cell.Output, cell.Value, cell.Error} {
273				if part = strings.TrimSpace(part); part != "" {
274					parts = append(parts, part)
275				}
276			}
277			return strings.Join(parts, "\n")
278		}
279	}
280	return strings.TrimSpace(entry.ToolResult)
281}
282
283// oneLine folds text onto one row: control characters and runs of
284// whitespace become single spaces.
285func oneLine(text string) string {
286	return strings.Join(strings.Fields(strings.Map(func(r rune) rune {
287		if unicode.IsControl(r) {
288			return ' '
289		}
290		return r
291	}, text)), " ")
292}
293
294// fit truncates head so the row fits width, keeping tail whole when there is
295// room, so a long target never hides the counts after it.
296func fit(head, tail string, width int) string {
297	if room := width - ansi.StringWidth(tail); room >= 12 {
298		return ansi.Truncate(head, room, "…") + tail
299	}
300	return ansi.Truncate(head+tail, max(1, width), "…")
301}
302
303var storeHash = regexp.MustCompile(`/nix/store/[0-9a-z]{32}-`)
304
305// shortTarget fits what a trace row names into room cells. Paths in the
306// workspace are named from it, the rest of home from ~, and store hashes are
307// elided, in commands too; a path that still overflows loses its middle, so
308// the file name stays visible.
309func (r TranscriptRenderer) shortTarget(target string, path bool, room int) string {
310	target = r.named(target)
311	width := ansi.StringWidth(target)
312	if !path || width <= room || room < 12 {
313		return ansi.Truncate(target, max(1, room), "…")
314	}
315	head := room / 3
316	return ansi.Truncate(target, head, "") + "…" + ansi.TruncateLeft(target, width-(room-head-1), "")
317}
318
319// namer names targets the way a person reads them: from the workspace, from ~,
320// and without store hashes. One replacer serves a whole row.
321type namer struct {
322	replacer *strings.Replacer
323}
324
325func (r TranscriptRenderer) namer() namer {
326	home, _ := os.UserHomeDir()
327	var roots []string
328	if workspace := expandHome(r.Workspace, home); workspace != "/" && workspace != "." {
329		if real := realPath(workspace); real != workspace {
330			roots = append(roots, real+"/", "")
331		}
332		roots = append(roots, workspace+"/", "")
333	}
334	if home != "" {
335		roots = append(roots, home+"/", "~/")
336	}
337	// the workspace comes first, so it wins where it sits inside home
338	return namer{replacer: strings.NewReplacer(roots...)}
339}
340
341// prepareTarget strips what naming always strips, workspace-independently:
342// control runs, store hashes. Facts store prepared targets so a render only
343// applies the workspace replacer.
344func prepareTarget(target string) string {
345	return storeHash.ReplaceAllString(oneLine(target), "/nix/store/…-")
346}
347
348func (n namer) name(target string) string {
349	return n.replacer.Replace(target)
350}
351
352func (n namer) all(targets []string) []string {
353	named := make([]string, len(targets))
354	for i, target := range targets {
355		named[i] = n.name(target)
356	}
357	return named
358}
359
360// named is target as a person reads it; see namer.
361func (r TranscriptRenderer) named(target string) string {
362	return r.namer().name(prepareTarget(target))
363}
364
365// realPaths caches realPath: a few workspaces, named on every row drawn.
366var realPaths sync.Map
367
368// realPath is path with its symlinks resolved, or path when it cannot be.
369// The daemon names files by their real path, so on macOS a workspace under
370// /var or /tmp reaches the transcript as /private/var or /private/tmp.
371func realPath(path string) string {
372	if real, ok := realPaths.Load(path); ok {
373		return real.(string)
374	}
375	real, err := filepath.EvalSymlinks(path)
376	if err != nil {
377		real = path
378	}
379	realPaths.Store(path, real)
380	return real
381}
382
383func expandHome(path, home string) string {
384	if rest, ok := strings.CutPrefix(path, "~/"); ok && home != "" {
385		path = filepath.Join(home, rest)
386	}
387	return filepath.Clean(path)
388}
389
390// traceLine is a verb then its target, the target shortened to fit width.
391func (r TranscriptRenderer) traceLine(verb, target string, path bool, width int) string {
392	return fit(verb+" "+r.shortTarget(target, path, width-ansi.StringWidth(verb)-1), "", width)
393}
394
395func isPath(activity daemon.ToolActivity) bool {
396	return activity.Kind == "read" || activity.Kind == "list"
397}
398
399func countLines(text string) string {
400	n := strings.Count(text, "\n") + 1
401	return fmt.Sprintf("%d %s", n, lineUnit(n))
402}
403
404// toolRow is the collapsed tool line: what ran, then how much.
405func toolRow(entry HistoryEntry, failed bool, clock string, width int) string {
406	head, tail := toolRowParts(entry, failed, clock)
407	return fit(head, tail, width)
408}
409
410// toolRowParts is toolRow unfitted: what ran, and the " · " tail of how much.
411func toolRowParts(entry HistoryEntry, failed bool, clock string) (string, string) {
412	var tail []string
413	if code, ok := entry.ToolArgs["code"].(string); ok && entry.ToolName == "python" {
414		if n := strings.Count(strings.TrimSpace(code), "\n") + 1; n > 1 {
415			tail = append(tail, countLines(strings.TrimSpace(code)))
416		}
417	}
418	if output := toolOutput(entry); output != "" {
419		tail = append(tail, countLines(output)+" out")
420	}
421	if failed {
422		tail = append(tail, "failed")
423	}
424	if clock != "" {
425		tail = append(tail, clock)
426	}
427	suffix := ""
428	if len(tail) > 0 {
429		suffix = " · " + strings.Join(tail, " · ")
430	}
431	return oneLine(toolSummary(entry)), suffix
432}
433
434func (r TranscriptRenderer) diffCounts(change daemon.FileChange) string {
435	return DefaultStyles.Success.Render(fmt.Sprintf("+%d", change.Added)) + " " + DefaultStyles.Error.Render(fmt.Sprintf("−%d", change.Removed))
436}
437
438func (r TranscriptRenderer) RenderToolTrace(trace *daemon.ToolTrace, flags DisplayFlags, width int) string {
439	if trace == nil {
440		return ""
441	}
442	// Normal mode folds the read of an edited file into its edit row.
443	reads := func(path string) bool {
444		return slices.ContainsFunc(trace.Activities, func(a daemon.ToolActivity) bool { return a.Kind == "read" && a.Target == path })
445	}
446	edits := func(path string) bool {
447		return slices.ContainsFunc(trace.Changes, func(c daemon.FileChange) bool { return c.Path == path })
448	}
449	var rows []string
450	if flags.Tools && len(trace.Activities) > 0 {
451		verb := "explored"
452		if slices.ContainsFunc(trace.Activities, func(a daemon.ToolActivity) bool { return a.Kind == "run" }) {
453			verb = "executed"
454		}
455		rows = append(rows, DefaultStyles.Bold.Render(verb))
456	}
457	for _, act := range trace.Activities {
458		if !flags.Tools && act.Kind == "read" && edits(act.Target) {
459			continue
460		}
461		label, style := act.Kind, DefaultStyles.Faint
462		if flags.Tools {
463			rows = append(rows, "  "+r.traceLine(style.Render(label), act.Target, isPath(act), width-2))
464		} else {
465			rows = append(rows, style.Render(r.traceLine(label, act.Target, isPath(act), width)))
466		}
467	}
468	changeStyle := DefaultStyles.Faint
469	if flags.Tools {
470		changeStyle = DefaultStyles.Bold
471	}
472	for _, change := range trace.Changes {
473		label := "edited"
474		if !flags.Tools && reads(change.Path) {
475			label = "read + edited"
476		}
477		counts := ""
478		if change.Kind == "diff" {
479			counts = "  " + r.diffCounts(change)
480		}
481		line := r.traceLine(label, change.Path, true, width-ansi.StringWidth(counts))
482		rows = append(rows, fit(changeStyle.Render(line), counts, width))
483		if flags.Diffs {
484			if change.Kind == "diff" {
485				rows = append(rows, r.renderDiffPath(change.Diff, change.Path, width))
486			} else {
487				rows = append(rows, DefaultStyles.Faint.Render(change.Reason))
488			}
489		}
490	}
491	if trace.Truncated {
492		notice := "Some activity was not captured · /v shows what is available"
493		if flags.Tools {
494			notice = "Some activity was not captured; this list is incomplete"
495		}
496		rows = append(rows, DefaultStyles.Faint.Render(notice))
497	}
498	return strings.Join(rows, "\n")
499}
500
501func toolFailed(entry HistoryEntry) bool {
502	if entry.ToolName == "python" {
503		var result struct {
504			Status string `json:"status"`
505			Error  string `json:"error"`
506		}
507		if json.Unmarshal([]byte(entry.ToolResult), &result) == nil {
508			if result.Status != "" {
509				return result.Status != "ok"
510			}
511			return result.Error != ""
512		}
513	}
514	return toolErrorLine.MatchString(entry.ToolResult)
515}
516
517var toolErrorLine = regexp.MustCompile(`(?im)^(?:error:|cancelled:|traceback \(most recent call last\):)`)
518
519func (r TranscriptRenderer) faintMarkdownRows(text string, width int) []string {
520	return r.faintRows(RenderMarkdownAnsi(text, width), width)
521}
522
523// faintRows wraps text to width and greys out each row.
524func (r TranscriptRenderer) faintRows(rendered string, width int) []string {
525	var rows []string
526	for line := range strings.SplitSeq(rendered, "\n") {
527		for _, wrapped := range wrapOrChunkLine(line, width) {
528			rows = append(rows, DefaultStyles.Faint.Render(ansi.Strip(wrapped)))
529		}
530	}
531	return rows
532}
533
534// RenderEntry renders an entry on its own, so a chat entry always names its author.
535func (r TranscriptRenderer) RenderEntry(entry HistoryEntry, flags DisplayFlags, width int) string {
536	return r.RenderAfter(prior{}, entry, flags, width)
537}
538
539// RenderAfter renders an entry in the context of what came before it.
540func (r TranscriptRenderer) RenderAfter(before prior, entry HistoryEntry, flags DisplayFlags, width int) string {
541	var rows []string
542	switch entry.Kind {
543	case EntryUser, EntryAssistant:
544		if plate := r.nameplate(entry, flags, before); plate != "" {
545			rows = append(rows, plate)
546		}
547		if entry.Pending != settled {
548			// greyed out until the daemon echoes it back
549			rows = append(rows, r.faintMarkdownRows(entry.Text, width)...)
550			break
551		}
552		render := renderCopyable
553		if entry.Live {
554			render = renderGrowing
555		}
556		body := render(entry.Text, width)
557		if entry.Kind == EntryUser && (entry.Source == "" || entry.Source == "chat") {
558			body = r.foldTall(entry, body)
559		}
560		rows = append(rows, body)
561	case EntryThinking:
562		rows = []string{markChrome + DefaultStyles.Faint.Render("thinking")}
563		if flags.Thinking {
564			render := RenderMarkdownAnsi
565			if entry.Live {
566				render = renderGrowing
567			}
568			rows = append(rows, r.faintRows(render(entry.Text, width), width)...)
569		}
570	case EntryTool:
571		trace := entry.ToolTrace
572		hasTrace := trace != nil && (len(trace.Activities) > 0 || len(trace.Changes) > 0)
573		failed := toolFailed(entry)
574		if !hasTrace || flags.Tools || failed {
575			// a glance in normal mode, read in full in verbose mode
576			style := DefaultStyles.Faint
577			clock := ""
578			if flags.Tools {
579				style = lipgloss.NewStyle()
580				clock = formatClock(entry.Timestamp)
581			}
582			if failed {
583				style = DefaultStyles.Error
584			}
585			rows = append(rows, markChrome+style.Render(toolRow(entry, failed, clock, width)))
586		}
587		if hasTrace {
588			rows = append(rows, chrome(r.RenderToolTrace(trace, flags, width)))
589			if !flags.Tools && !failed {
590				break
591			}
592		}
593		if flags.Tools {
594			if entry.ToolName == "python" {
595				if code, ok := entry.ToolArgs["code"].(string); ok {
596					rows = append(rows, renderCopyable("```python\n"+code+"\n```", width))
597				}
598			}
599			rows = append(rows, strings.Split(cmp.Or(toolOutput(entry), "(no output)"), "\n")...)
600		}
601	case EntryTurnEnd:
602		rows = []string{r.signoff(entry)}
603	case EntryNote:
604		// Styling the whole note pads short rows to its longest line.
605		rows = []string{r.foldTall(entry, strings.Join(r.faintRows(entry.Text, width), "\n"))}
606	case EntryError:
607		rows = []string{r.errorRow(entry.Text)}
608	case EntryCompacted:
609		action := "view"
610		if flags.Compaction {
611			action = "hide"
612		}
613		verb, noun := compactionWords(entry.Strategy)
614		rows = []string{markChrome + DefaultStyles.Faint.Render(fmt.Sprintf("compaction done · %d items %s · ctrl+k %s %s", entry.Evicted, verb, action, noun))}
615		if flags.Compaction {
616			rows = append(rows, r.faintMarkdownRows(entry.Text, width)...)
617		}
618	}
619	return strings.Join(rows, "\n")
620}
621
622// A message of yours, or a note, taller than foldRows plus foldSlack shows
623// its first foldRows rows and a click target for the rest; the slack keeps a
624// fold from hiding only a line or two.
625const (
626	foldRows  = 10
627	foldSlack = 2
628)
629
630// foldTall is body, a settled entry's rendered rows, cut to its first rows
631// until the entry's key is in Open.
632func (r TranscriptRenderer) foldTall(entry HistoryEntry, body string) string {
633	rows := strings.Split(body, "\n")
634	if len(rows) <= foldRows+foldSlack {
635		return body
636	}
637	key := entryKey(entry)
638	if r.Open[key] {
639		return body + "\n" + r.foldRow(key, toggleOpen+"show less")
640	}
641	label := fmt.Sprintf("%s%d more lines · click to expand", toggleClosed, len(rows)-foldRows)
642	return strings.Join(rows[:foldRows], "\n") + "\n" + r.foldRow(key, label)
643}
644
645func (r TranscriptRenderer) foldRow(key, label string) string {
646	return markChrome + rowAction{verbMore, key}.mark() + DefaultStyles.Faint.Render(label)
647}
648
649// compactionWords names what a strategy did with the evicted items and what
650// ctrl+k shows: snapcompact archives them as rendered frames, lcm folds them
651// into summary nodes, and rolling (or an unnamed strategy) summarizes them.
652func compactionWords(strategy string) (verb, noun string) {
653	switch strategy {
654	case "snapcompact":
655		return "archived as frames", "archive"
656	case "lcm":
657		return "folded", "folds"
658	default:
659		return "summarized", "summary"
660	}
661}
662
663// Compact entries are one-line summaries. A run of them stacks without blank
664// rows so a burst of tool calls reads as one block.
665func Compact(entry HistoryEntry, flags DisplayFlags) bool {
666	switch entry.Kind {
667	case EntryTool:
668		return !flags.Tools
669	case EntryThinking:
670		return !flags.Thinking
671	}
672	return false
673}
674
675// Separated reports whether a blank row belongs between two adjacent entries.
676// A turn's signoff hangs directly under the turn it closes.
677func Separated(prev *HistoryEntry, next HistoryEntry, flags DisplayFlags) bool {
678	return prev != nil && next.Kind != EntryTurnEnd && (!Compact(*prev, flags) || !Compact(next, flags))
679}
680
681// railWidth is the rail and the space after it, at the start of every row.
682const railWidth = 2
683
684// lane is the rail an entry hangs on. A turn is one unbroken rail: yours in
685// your color, albedo's in its color, dimmer while it works in tools.
686type lane int
687
688const (
689	laneNone lane = iota
690	laneYou
691	laneAgent
692	laneBusy
693)
694
695func laneOf(entry HistoryEntry) lane {
696	switch entry.Kind {
697	case EntryUser:
698		return laneYou
699	case EntryAssistant, EntryTurnEnd, EntryError:
700		return laneAgent
701	case EntryTool, EntryThinking:
702		return laneBusy
703	}
704	return laneNone
705}
706
707// joint is the lane of the blank row between two entries, so the rail
708// carries through a turn and breaks between turns.
709func joint(above, below lane) lane {
710	switch {
711	case above == laneNone || below == laneNone || (above == laneYou) != (below == laneYou):
712		return laneNone
713	case above == laneBusy || below == laneBusy:
714		return laneBusy
715	}
716	return above
717}
718
719func (r TranscriptRenderer) rail(l lane) string {
720	switch l {
721	case laneYou:
722		return DefaultStyles.You.Render("│") + " "
723	case laneAgent:
724		return DefaultStyles.Agent.Render("│") + " "
725	case laneBusy:
726		return DefaultStyles.Busy.Render("│") + " "
727	}
728	return strings.Repeat(" ", railWidth)
729}
730
731// Block is an entry as finished transcript rows: the blank row that
732// separates it from the entry before, when one belongs, then its rows
733// wrapped beside its rail. head indexes the entry's first row.
734func (r TranscriptRenderer) Block(before []HistoryEntry, entry HistoryEntry, flags DisplayFlags) (rows []string, head int) {
735	return r.frame(before, entry, flags, func(width int) string {
736		return r.RenderAfter(priorOf(before), entry, flags, width)
737	})
738}
739
740// BurstBlock is a burst as finished transcript rows, framed like a Block.
741func (r TranscriptRenderer) BurstBlock(before, burst []HistoryEntry, flags DisplayFlags) []string {
742	rows, _ := r.frame(before, burst[0], flags, func(width int) string {
743		return r.RenderBurst(burst, flags, width)
744	})
745	return rows
746}
747
748// frame hangs body beside first's rail, after the blank row that separates
749// it from before when one belongs. head indexes body's first row.
750func (r TranscriptRenderer) frame(before []HistoryEntry, first HistoryEntry, flags DisplayFlags, body func(width int) string) (rows []string, head int) {
751	own := laneOf(first)
752	if n := len(before); n > 0 && Separated(&before[n-1], first, flags) {
753		rows = append(rows, strings.TrimRight(r.rail(joint(laneOf(before[n-1]), own)), " "))
754	}
755	head = len(rows)
756	width := max(1, r.BodyWidth-railWidth)
757	gutter := r.rail(own)
758	for line := range strings.SplitSeq(body(width), "\n") {
759		for _, chunk := range markChunks(line, wrapOrChunkLine(line, width)) {
760			rows = append(rows, gutter+chunk)
761		}
762	}
763	return rows, head
764}
765
766// Settle is the rows entry settles after before: none for a compact entry,
767// whose burst waits for what ends it, else the burst it ends, then itself.
768// head indexes the entry's first row.
769func (r TranscriptRenderer) Settle(before []HistoryEntry, entry HistoryEntry, flags DisplayFlags) (rows []string, head int) {
770	if Compact(entry, flags) {
771		return nil, 0
772	}
773	if burst := trailingBurst(before, flags); len(burst) > 0 {
774		rows = r.BurstBlock(before[:len(before)-len(burst)], burst, flags)
775	}
776	block, at := r.Block(before, entry, flags)
777	return append(rows, block...), len(rows) + at
778}
779
780// OpenBurst is the burst that ends entries, drawn live until it settles.
781func (r TranscriptRenderer) OpenBurst(entries []HistoryEntry, flags DisplayFlags) []string {
782	burst := trailingBurst(entries, flags)
783	if len(burst) == 0 {
784		return nil
785	}
786	return r.BurstBlock(entries[:len(entries)-len(burst)], burst, flags)
787}