okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
50b6cce
main
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 { 23Thinking bool 24Tools bool 25Diffs bool 26Compaction bool 27} 28 29type TranscriptRenderer struct { 30// Open holds the keys of expanded bursts, messages, and notes. 31Open map [string ]bool 32// Workspace roots the paths trace rows name. 33Workspace string 34BodyWidth int 35} 36 37func NewTranscriptRenderer ()TranscriptRenderer { 38return TranscriptRenderer {} 39} 40 41func formatClock (timestamp int64 )string { 42if timestamp <= 0 { 43return "" 44 } 45return 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 { 50switch entry .Kind { 51case EntryUser : 52return cmp .Or (entry .Speaker ,"You" ) 53case EntryAssistant : 54return cmp .Or (entry .Speaker ,"albedo" ) 55default : 56return "" 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 { 63speaker string 64at int64 65} 66 67func priorOf (entries []HistoryEntry )prior { 68var p prior 69for i := len (entries )- 1 ;i >= 0 && p .speaker == "" ;i -- { 70e := entries [i ] 71if p .at == 0 && (e .Kind == EntryTurnEnd || speaker (e )!= "" ) { 72p .at = e .Timestamp 73 } 74p .speaker = speaker (e ) 75 } 76return 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 { 82for i ,entry := range slices .Backward (entries ) { 83if speaker (entry )!= "" { 84return entries [i :] 85 } 86 } 87return 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 { 96who := speaker (entry ) 97if ! flags .Tools && who == before .speaker && entry .MailKind == "" { 98return "" 99 } 100style := DefaultStyles .Agent 101if entry .Kind == EntryUser && (entry .Source == "" || entry .Source == "chat" ) { 102style = DefaultStyles .You 103 } 104var meta []string 105if entry .MailKind != "" { 106meta = append (meta ,entry .MailKind ) 107 } 108if clock := formatClock (entry .Timestamp );flags .Tools && clock != "" { 109meta = append (meta ,clock ) 110 } 111if gap := entry .Timestamp - before .at ;entry .Kind == EntryUser && before .at > 0 && gap >= awayMs { 112meta = append (meta ,formatGap (gap )) 113 } 114if entry .Pending == queued { 115meta = append (meta ,"queued" ) 116 } 117if entry .Pending == unresolved { 118meta = append (meta ,"unresolved" ) 119 } 120plate := markChrome + style .Render (strings .ToLower (who )) 121if len (meta )> 0 { 122plate += DefaultStyles .Faint .Render (" · " + strings .Join (meta ," · " )) 123 } 124return 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 { 130return 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 { 135style := DefaultStyles .Agent 136switch entry .Mood { 137case moodFailed : 138style = DefaultStyles .Error 139case moodStopped : 140style = DefaultStyles .Warning 141 } 142var meta []string 143if entry .Mood == moodStopped { 144meta = append (meta ,"stopped by you" ) 145 } 146if entry .ElapsedMs > 0 { 147meta = append (meta ,formatElapsed (entry .ElapsedMs )) 148 } 149if entry .Tools > 0 { 150unit := "tools" 151if entry .Tools == 1 { 152unit = "tool" 153 } 154meta = append (meta ,fmt .Sprintf ("%d %s" ,entry .Tools ,unit )) 155 } 156if entry .TokensPerSecond > 0 { 157meta = append (meta ,formatRate (entry .TokensPerSecond )) 158 } 159row := markChrome + rowAction {verbCopy ,entryKey (entry )}.mark ()+ style .Render (entry .Mood .face (entry .Timestamp )) 160if len (meta )> 0 { 161row += " " + DefaultStyles .Faint .Render (strings .Join (meta ," · " )) 162 } 163return 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 { 169if width < 8 { 170return "…" 171 } 172var rows []string 173oldLine ,newLine := 0 ,0 174lexer := lexers .Match (filepath .Base (path )) 175clean := func (text string )string { 176return strings .Map (func (ch rune )rune { 177if ch == '\t' || ch < 32 || ch == 127 { 178return ' ' 179 } 180return ch 181 },text ) 182 } 183row := func (gutter ,content ,bg string ,syntax bool ) { 184body := keepBackground (DefaultStyles .Faint .Render (content )) 185if syntax { 186body = keepBackground (HighlightCode (content ,lexer )) 187 } 188for i ,part := range strings .Split (ansi .Hardwrap (body ,max (1 ,width - ansi .StringWidth (gutter )),true ),"\n" ) { 189prefix := gutter 190if i > 0 { 191prefix = strings .Repeat (" " ,ansi .StringWidth (gutter )) 192 } 193cell := prefix + part 194rows = append (rows ,bg + cell + strings .Repeat (" " ,max (0 ,width - ansi .StringWidth (cell )))+ ansiReset ) 195 } 196 } 197panel ,add ,remove := diffPanel (),diffAdded (),diffRemoved () 198gutter := func (style lipgloss.Style ,n int ,sign string )string { 199return keepBackground (style .Render (fmt .Sprintf ("%4d %s " ,n ,sign ))) 200 } 201row (" " ,clean (r .named (path )),panel ,false ) 202for line := range strings .SplitSeq (diff ,"\n" ) { 203if line == "" || strings .HasPrefix (line ,"--- " )|| strings .HasPrefix (line ,"+++ " ) { 204continue 205 } 206if hunk := diffHunk .FindStringSubmatch (line );hunk != nil { 207oldLine ,_ = strconv .Atoi (hunk [1 ]) 208newLine ,_ = strconv .Atoi (hunk [2 ]) 209row (" ⋮ " ,clean (line ),panel ,false ) 210continue 211 } 212switch line [0 ] { 213case '+' : 214row (gutter (DefaultStyles .Success ,newLine ,"+" ),clean (line [1 :]),add ,true ) 215newLine ++ 216case '-' : 217row (gutter (DefaultStyles .Error ,oldLine ,"-" ),clean (line [1 :]),remove ,true ) 218oldLine ++ 219case ' ' : 220row (gutter (DefaultStyles .Decor ,newLine ," " ),clean (line [1 :]),panel ,true ) 221newLine ++ 222oldLine ++ 223default : 224row (" ⋮ " ,clean (line ),panel ,false ) 225 } 226 } 227return strings .Join (rows ,"\n" ) 228} 229 230func lineUnit (n int )string { 231if n == 1 { 232return "line" 233 } 234return "lines" 235} 236 237func firstLine (text string )string { 238for line := range strings .SplitSeq (text ,"\n" ) { 239if line = strings .TrimSpace (line );line != "" { 240return line 241 } 242 } 243return "" 244} 245 246func toolSummary (entry HistoryEntry )string { 247arg := func (key string )string { 248if entry .ToolArgs == nil { 249return "" 250 } 251return fmt .Sprint (entry .ToolArgs [key ]) 252 } 253switch entry .ToolName { 254case "python" : 255return "python · " + firstLine (arg ("code" )) 256case "shell" : 257return "$ " + firstLine (arg ("command" )) 258case "read_file" ,"write_file" ,"edit_file" : 259return strings .TrimSuffix (entry .ToolName ,"_file" )+ " " + arg ("path" ) 260default : 261return 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 { 268if entry .ToolName == "python" { 269var cell struct {Output ,Value ,Error string } 270if json .Unmarshal ([]byte (entry .ToolResult ),& cell )== nil { 271var parts []string 272for _ ,part := range []string {cell .Output ,cell .Value ,cell .Error } { 273if part = strings .TrimSpace (part );part != "" { 274parts = append (parts ,part ) 275 } 276 } 277return strings .Join (parts ,"\n" ) 278 } 279 } 280return 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 { 286return strings .Join (strings .Fields (strings .Map (func (r rune )rune { 287if unicode .IsControl (r ) { 288return ' ' 289 } 290return 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 { 297if room := width - ansi .StringWidth (tail );room >= 12 { 298return ansi .Truncate (head ,room ,"…" )+ tail 299 } 300return 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 { 310target = r .named (target ) 311width := ansi .StringWidth (target ) 312if ! path || width <= room || room < 12 { 313return ansi .Truncate (target ,max (1 ,room ),"…" ) 314 } 315head := room / 3 316return 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 { 322replacer * strings.Replacer 323} 324 325func (r TranscriptRenderer )namer ()namer { 326home ,_ := os .UserHomeDir () 327var roots []string 328if workspace := expandHome (r .Workspace ,home );workspace != "/" && workspace != "." { 329if real := realPath (workspace );real != workspace { 330roots = append (roots ,real + "/" ,"" ) 331 } 332roots = append (roots ,workspace + "/" ,"" ) 333 } 334if home != "" { 335roots = append (roots ,home + "/" ,"~/" ) 336 } 337// the workspace comes first, so it wins where it sits inside home 338return 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 { 345return storeHash .ReplaceAllString (oneLine (target ),"/nix/store/…-" ) 346} 347 348func (n namer )name (target string )string { 349return n .replacer .Replace (target ) 350} 351 352func (n namer )all (targets []string ) []string { 353named := make ([]string ,len (targets )) 354for i ,target := range targets { 355named [i ]= n .name (target ) 356 } 357return named 358} 359 360// named is target as a person reads it; see namer. 361func (r TranscriptRenderer )named (target string )string { 362return 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 { 372if real ,ok := realPaths .Load (path );ok { 373return real .(string ) 374 } 375real ,err := filepath .EvalSymlinks (path ) 376if err != nil { 377real = path 378 } 379realPaths .Store (path ,real ) 380return real 381} 382 383func expandHome (path ,home string )string { 384if rest ,ok := strings .CutPrefix (path ,"~/" );ok && home != "" { 385path = filepath .Join (home ,rest ) 386 } 387return 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 { 392return fit (verb + " " + r .shortTarget (target ,path ,width - ansi .StringWidth (verb )- 1 ),"" ,width ) 393} 394 395func isPath (activity daemon.ToolActivity )bool { 396return activity .Kind == "read" || activity .Kind == "list" 397} 398 399func countLines (text string )string { 400n := strings .Count (text ,"\n" )+ 1 401return 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 { 406head ,tail := toolRowParts (entry ,failed ,clock ) 407return 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 ) { 412var tail []string 413if code ,ok := entry .ToolArgs ["code" ].(string );ok && entry .ToolName == "python" { 414if n := strings .Count (strings .TrimSpace (code ),"\n" )+ 1 ;n > 1 { 415tail = append (tail ,countLines (strings .TrimSpace (code ))) 416 } 417 } 418if output := toolOutput (entry );output != "" { 419tail = append (tail ,countLines (output )+ " out" ) 420 } 421if failed { 422tail = append (tail ,"failed" ) 423 } 424if clock != "" { 425tail = append (tail ,clock ) 426 } 427suffix := "" 428if len (tail )> 0 { 429suffix = " · " + strings .Join (tail ," · " ) 430 } 431return oneLine (toolSummary (entry )),suffix 432} 433 434func (r TranscriptRenderer )diffCounts (change daemon.FileChange )string { 435return 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 { 439if trace == nil { 440return "" 441 } 442// Normal mode folds the read of an edited file into its edit row. 443reads := func (path string )bool { 444return slices .ContainsFunc (trace .Activities ,func (a daemon.ToolActivity )bool {return a .Kind == "read" && a .Target == path }) 445 } 446edits := func (path string )bool { 447return slices .ContainsFunc (trace .Changes ,func (c daemon.FileChange )bool {return c .Path == path }) 448 } 449var rows []string 450if flags .Tools && len (trace .Activities )> 0 { 451verb := "explored" 452if slices .ContainsFunc (trace .Activities ,func (a daemon.ToolActivity )bool {return a .Kind == "run" }) { 453verb = "executed" 454 } 455rows = append (rows ,DefaultStyles .Bold .Render (verb )) 456 } 457for _ ,act := range trace .Activities { 458if ! flags .Tools && act .Kind == "read" && edits (act .Target ) { 459continue 460 } 461label ,style := act .Kind ,DefaultStyles .Faint 462if flags .Tools { 463rows = append (rows ," " + r .traceLine (style .Render (label ),act .Target ,isPath (act ),width - 2 )) 464 }else { 465rows = append (rows ,style .Render (r .traceLine (label ,act .Target ,isPath (act ),width ))) 466 } 467 } 468changeStyle := DefaultStyles .Faint 469if flags .Tools { 470changeStyle = DefaultStyles .Bold 471 } 472for _ ,change := range trace .Changes { 473label := "edited" 474if ! flags .Tools && reads (change .Path ) { 475label = "read + edited" 476 } 477counts := "" 478if change .Kind == "diff" { 479counts = " " + r .diffCounts (change ) 480 } 481line := r .traceLine (label ,change .Path ,true ,width - ansi .StringWidth (counts )) 482rows = append (rows ,fit (changeStyle .Render (line ),counts ,width )) 483if flags .Diffs { 484if change .Kind == "diff" { 485rows = append (rows ,r .renderDiffPath (change .Diff ,change .Path ,width )) 486 }else { 487rows = append (rows ,DefaultStyles .Faint .Render (change .Reason )) 488 } 489 } 490 } 491if trace .Truncated { 492notice := "Some activity was not captured · /v shows what is available" 493if flags .Tools { 494notice = "Some activity was not captured; this list is incomplete" 495 } 496rows = append (rows ,DefaultStyles .Faint .Render (notice )) 497 } 498return strings .Join (rows ,"\n" ) 499} 500 501func toolFailed (entry HistoryEntry )bool { 502if entry .ToolName == "python" { 503var result struct { 504Status string `json:"status"` 505Error string `json:"error"` 506 } 507if json .Unmarshal ([]byte (entry .ToolResult ),& result )== nil { 508if result .Status != "" { 509return result .Status != "ok" 510 } 511return result .Error != "" 512 } 513 } 514return 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 { 520return 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 { 525var rows []string 526for line := range strings .SplitSeq (rendered ,"\n" ) { 527for _ ,wrapped := range wrapOrChunkLine (line ,width ) { 528rows = append (rows ,DefaultStyles .Faint .Render (ansi .Strip (wrapped ))) 529 } 530 } 531return 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 { 536return 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 { 541var rows []string 542switch entry .Kind { 543case EntryUser ,EntryAssistant : 544if plate := r .nameplate (entry ,flags ,before );plate != "" { 545rows = append (rows ,plate ) 546 } 547if entry .Pending != settled { 548// greyed out until the daemon echoes it back 549rows = append (rows ,r .faintMarkdownRows (entry .Text ,width )... ) 550break 551 } 552render := renderCopyable 553if entry .Live { 554render = renderGrowing 555 } 556body := render (entry .Text ,width ) 557if entry .Kind == EntryUser && (entry .Source == "" || entry .Source == "chat" ) { 558body = r .foldTall (entry ,body ) 559 } 560rows = append (rows ,body ) 561case EntryThinking : 562rows = []string {markChrome + DefaultStyles .Faint .Render ("thinking" )} 563if flags .Thinking { 564render := RenderMarkdownAnsi 565if entry .Live { 566render = renderGrowing 567 } 568rows = append (rows ,r .faintRows (render (entry .Text ,width ),width )... ) 569 } 570case EntryTool : 571trace := entry .ToolTrace 572hasTrace := trace != nil && (len (trace .Activities )> 0 || len (trace .Changes )> 0 ) 573failed := toolFailed (entry ) 574if ! hasTrace || flags .Tools || failed { 575// a glance in normal mode, read in full in verbose mode 576style := DefaultStyles .Faint 577clock := "" 578if flags .Tools { 579style = lipgloss .NewStyle () 580clock = formatClock (entry .Timestamp ) 581 } 582if failed { 583style = DefaultStyles .Error 584 } 585rows = append (rows ,markChrome + style .Render (toolRow (entry ,failed ,clock ,width ))) 586 } 587if hasTrace { 588rows = append (rows ,chrome (r .RenderToolTrace (trace ,flags ,width ))) 589if ! flags .Tools && ! failed { 590break 591 } 592 } 593if flags .Tools { 594if entry .ToolName == "python" { 595if code ,ok := entry .ToolArgs ["code" ].(string );ok { 596rows = append (rows ,renderCopyable ("```python\n" + code + "\n```" ,width )) 597 } 598 } 599rows = append (rows ,strings .Split (cmp .Or (toolOutput (entry ),"(no output)" ),"\n" )... ) 600 } 601case EntryTurnEnd : 602rows = []string {r .signoff (entry )} 603case EntryNote : 604// Styling the whole note pads short rows to its longest line. 605rows = []string {r .foldTall (entry ,strings .Join (r .faintRows (entry .Text ,width ),"\n" ))} 606case EntryError : 607rows = []string {r .errorRow (entry .Text )} 608case EntryCompacted : 609action := "view" 610if flags .Compaction { 611action = "hide" 612 } 613verb ,noun := compactionWords (entry .Strategy ) 614rows = []string {markChrome + DefaultStyles .Faint .Render (fmt .Sprintf ("compaction done · %d items %s · ctrl+k %s %s" ,entry .Evicted ,verb ,action ,noun ))} 615if flags .Compaction { 616rows = append (rows ,r .faintMarkdownRows (entry .Text ,width )... ) 617 } 618 } 619return 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 ( 626foldRows = 10 627foldSlack = 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 { 633rows := strings .Split (body ,"\n" ) 634if len (rows )<= foldRows + foldSlack { 635return body 636 } 637key := entryKey (entry ) 638if r .Open [key ] { 639return body + "\n" + r .foldRow (key ,toggleOpen + "show less" ) 640 } 641label := fmt .Sprintf ("%s%d more lines · click to expand" ,toggleClosed ,len (rows )- foldRows ) 642return strings .Join (rows [:foldRows ],"\n" )+ "\n" + r .foldRow (key ,label ) 643} 644 645func (r TranscriptRenderer )foldRow (key ,label string )string { 646return 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 ) { 653switch strategy { 654case "snapcompact" : 655return "archived as frames" ,"archive" 656case "lcm" : 657return "folded" ,"folds" 658default : 659return "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 { 666switch entry .Kind { 667case EntryTool : 668return ! flags .Tools 669case EntryThinking : 670return ! flags .Thinking 671 } 672return 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 { 678return 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 ( 689laneNone lane = iota 690laneYou 691laneAgent 692laneBusy 693) 694 695func laneOf (entry HistoryEntry )lane { 696switch entry .Kind { 697case EntryUser : 698return laneYou 699case EntryAssistant ,EntryTurnEnd ,EntryError : 700return laneAgent 701case EntryTool ,EntryThinking : 702return laneBusy 703 } 704return 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 { 710switch { 711case above == laneNone || below == laneNone || (above == laneYou )!= (below == laneYou ): 712return laneNone 713case above == laneBusy || below == laneBusy : 714return laneBusy 715 } 716return above 717} 718 719func (r TranscriptRenderer )rail (l lane )string { 720switch l { 721case laneYou : 722return DefaultStyles .You .Render ("│" )+ " " 723case laneAgent : 724return DefaultStyles .Agent .Render ("│" )+ " " 725case laneBusy : 726return DefaultStyles .Busy .Render ("│" )+ " " 727 } 728return 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 ) { 735return r .frame (before ,entry ,flags ,func (width int )string { 736return 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 { 742rows ,_ := r .frame (before ,burst [0 ],flags ,func (width int )string { 743return r .RenderBurst (burst ,flags ,width ) 744 }) 745return 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 ) { 751own := laneOf (first ) 752if n := len (before );n > 0 && Separated (& before [n - 1 ],first ,flags ) { 753rows = append (rows ,strings .TrimRight (r .rail (joint (laneOf (before [n - 1 ]),own ))," " )) 754 } 755head = len (rows ) 756width := max (1 ,r .BodyWidth - railWidth ) 757gutter := r .rail (own ) 758for line := range strings .SplitSeq (body (width ),"\n" ) { 759for _ ,chunk := range markChunks (line ,wrapOrChunkLine (line ,width )) { 760rows = append (rows ,gutter + chunk ) 761 } 762 } 763return 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 ) { 770if Compact (entry ,flags ) { 771return nil ,0 772 } 773if burst := trailingBurst (before ,flags );len (burst )> 0 { 774rows = r .BurstBlock (before [:len (before )- len (burst )],burst ,flags ) 775 } 776block ,at := r .Block (before ,entry ,flags ) 777return 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 { 782burst := trailingBurst (entries ,flags ) 783if len (burst )== 0 { 784return nil 785 } 786return r .BurstBlock (entries [:len (entries )- len (burst )],burst ,flags ) 787}