okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
c030244
main
1package tui 2 3import ( 4"cmp" 5"fmt" 6"path" 7"regexp" 8"slices" 9"strings" 10 11"albedo/cli/internal/daemon" 12"github.com/charmbracelet/x/ansi" 13) 14 15// A burst is the work between two pieces of prose: a run of compact entries, 16// tool calls and thinking spells, drawn as one summary row. It renders once 17// something else ends it; until then it is drawn live, so settled rows stay 18// append-only. 19 20// trailingBurst is the run of compact entries that ends entries. 21func trailingBurst (entries []HistoryEntry ,flags DisplayFlags ) []HistoryEntry { 22i := len (entries ) 23for i > 0 && Compact (entries [i - 1 ],flags ) { 24i -- 25 } 26return entries [i :] 27} 28 29// burstNamed is the span of entries holding the burst that key names, or 30// false when none is retained. 31func burstNamed (entries []HistoryEntry ,key string ,flags DisplayFlags ) (start ,end int ,ok bool ) { 32for i ,entry := range entries { 33if ! Compact (entry ,flags )|| i > 0 && Compact (entries [i - 1 ],flags )|| factsOf (entry ).key != key { 34continue 35 } 36end = i + 1 37for end < len (entries )&& Compact (entries [end ],flags ) { 38end ++ 39 } 40return i ,end ,true 41 } 42return 0 ,0 ,false 43} 44 45// tally keeps labels in the order they first came, with how often each did. 46type tally struct { 47counts map [string ]int 48keys []string 49} 50 51func (t * tally )add (key string ) { 52if key == "" { 53return 54 } 55if t .counts == nil { 56t .counts = map [string ]int {} 57 } 58if t .counts [key ]== 0 { 59t .keys = append (t .keys ,key ) 60 } 61t .counts [key ]++ 62} 63 64// dedup keeps first order and drops repeats naming produced. 65func dedup (keys []string ) []string { 66seen := make (map [string ]bool ,len (keys )) 67kept := keys [:0 :0 ] 68for _ ,key := range keys { 69if ! seen [key ] { 70seen [key ]= true 71kept = append (kept ,key ) 72 } 73 } 74return kept 75} 76 77func (t * tally )addAll (keys []string ) { 78for _ ,key := range keys { 79t .add (key ) 80 } 81} 82 83// counted labels each key, marking repeats: `go test ×4`. 84func (t tally )counted () []string { 85labels := make ([]string ,len (t .keys )) 86for i ,key := range t .keys { 87labels [i ]= key 88if n := t .counts [key ];n > 1 { 89labels [i ]+= fmt .Sprintf (" ×%d" ,n ) 90 } 91 } 92return labels 93} 94 95// entryFacts is what one entry contributes to a burst, computed once when the 96// entry settles. Targets stay as the trace recorded them; naming them for the 97// workspace happens at render time, so a workspace change re-names old rows. 98type entryFacts struct { 99// key is the entry's entryKey, which names the burst it starts 100key string 101read []string 102edited []string 103searched []string 104looked []lookup 105ran []string 106py []string 107used []string 108changes []daemon.FileChange 109thoughtMs int64 110added int 111removed int 112untimed bool 113failed bool 114} 115 116// lookup is one web search a call made: what it asked, what it found, and the 117// key its fold opens by. 118type lookup struct {key ,query ,detail string } 119 120// factsOf reads one entry's contribution, using what append already computed 121// when it can. 122func factsOf (entry HistoryEntry )entryFacts { 123if entry .facts != nil { 124return * entry .facts 125 } 126f := entryFacts {key :entryKey (entry )} 127if entry .Kind == EntryThinking { 128f .thoughtMs ,f .untimed = entry .ElapsedMs ,entry .ElapsedMs <= 0 129return f 130 } 131if entry .Kind != EntryTool { 132return f 133 } 134f .failed = toolFailed (entry ) 135if trace := entry .ToolTrace ;trace != nil && (len (trace .Activities )> 0 || len (trace .Changes )> 0 ) { 136for _ ,change := range trace .Changes { 137f .edited = append (f .edited ,prepareTarget (change .Path )) 138f .added += change .Added 139f .removed += change .Removed 140f .changes = append (f .changes ,change ) 141 } 142for _ ,activity := range trace .Activities { 143switch activity .Kind { 144case "read" : 145f .read = append (f .read ,activity .Target ) 146case "list" : 147f .read = append (f .read ,strings .TrimSuffix (activity .Target ,"/" )+ "/" ) 148case "search" : 149f .searched = append (f .searched ,oneLine (activity .Target )) 150case "web" : 151key := fmt .Sprintf ("%x-%x" ,entry .Timestamp ,fnv1a (fnvOffset64 ,f .key + "\x00" + activity .Target )) 152f .looked = append (f .looked ,lookup {key ,activity .Target ,activity .Detail }) 153case "run" : 154f .ran = append (f .ran ,commandName (activity .Target )) 155 } 156 } 157return f 158 } 159arg := func (key string )string { 160s ,_ := entry .ToolArgs [key ].(string ) 161return s 162 } 163switch entry .ToolName { 164case "python" : 165f .py = append (f .py ,cmp .Or (pyLabel (arg ("code" )),"cell" )) 166case "shell" ,"bash" : 167f .ran = append (f .ran ,cmp .Or (commandName (arg ("command" )),entry .ToolName )) 168case "read_file" : 169f .read = append (f .read ,prepareTarget (arg ("path" ))) 170case "write_file" ,"edit_file" : 171f .edited = append (f .edited ,prepareTarget (arg ("path" ))) 172default : 173f .used = append (f .used ,entry .ToolName ) 174 } 175return f 176} 177 178type burst struct { 179read tally 180edited tally 181searched tally 182looked []lookup 183ran tally 184py tally 185used tally 186changes []daemon.FileChange 187failed []HistoryEntry 188thoughtMs int64 189thoughts int 190added int 191removed int 192untimed bool // a thought from before this client watched has no duration 193} 194 195// merge folds one entry's facts into a burst. 196func (b * burst )merge (f entryFacts ,entry HistoryEntry ) { 197if f .thoughtMs != 0 || f .untimed { 198b .thoughts ++ 199b .thoughtMs += f .thoughtMs 200b .untimed = b .untimed || f .untimed 201 } 202b .read .addAll (f .read ) 203b .edited .addAll (f .edited ) 204b .searched .addAll (f .searched ) 205b .looked = append (b .looked ,f .looked ... ) 206b .ran .addAll (f .ran ) 207b .py .addAll (f .py ) 208b .used .addAll (f .used ) 209b .added += f .added 210b .removed += f .removed 211b .changes = append (b .changes ,f .changes ... ) 212if f .failed { 213b .failed = append (b .failed ,entry ) 214 } 215} 216 217// collect merges every entry's facts; entries settled through the chat carry 218// theirs already, so this only pays for tool results once per entry. 219func collect (entries []HistoryEntry )burst { 220var b burst 221for _ ,entry := range entries { 222b .merge (factsOf (entry ),entry ) 223 } 224return b 225} 226 227// pyLabel is what a python cell that ran nothing external was doing: the 228// first line of it, folded and capped. 229func pyLabel (code string )string { 230line := oneLine (firstLine (code )) 231return ansi .Truncate (line ,48 ,"…" ) 232} 233 234// clause is a verb and what it acted on: n things, noun in the plural, 235// listed as items. shown items fit, the rest count; none shown is a count. 236type clause struct { 237verb ,noun string 238items []string 239n int 240shown int 241} 242 243func (c clause )String ()string { 244if c .shown == 0 { 245noun := c .noun 246if c .n == 1 { 247noun = strings .TrimSuffix (noun ,"s" ) 248 } 249return fmt .Sprintf ("%s %d %s" ,c .verb ,c .n ,noun ) 250 } 251text := c .verb + " " + strings .Join (c .items [:c .shown ],", " ) 252if hidden := len (c .items )- c .shown ;hidden > 0 { 253text += fmt .Sprintf (" +%d" ,hidden ) 254 } 255return text 256} 257 258// summary is the burst's one row: what it thought, read, changed, and ran, 259// with the lists shortened until the row fits width. 260func (r TranscriptRenderer )summary (b burst ,width int ,n namer )string { 261var head []string 262if b .thoughts > 0 { 263thought := "thought" 264if ! b .untimed && b .thoughtMs >= 1000 { 265thought += " " + formatElapsed (b .thoughtMs ) 266 } 267head = append (head ,thought ) 268 } 269// naming can make two recorded paths the same ("cli/a.go" and the absolute 270// form), so lists dedupe after naming, and reads defer to edits in that 271// same named space 272edited := dedup (n .all (b .edited .keys )) 273reads := slices .DeleteFunc (dedup (n .all (b .read .keys )),func (p string )bool {return slices .Contains (edited ,p ) }) 274var clauses []* clause 275for _ ,c := range []* clause { 276 {verb :"read" ,noun :"files" ,n :len (reads ),items :pathGroups (reads )}, 277 {verb :"searched" ,noun :"patterns" ,n :len (b .searched .keys ),items :dedup (n .all (b .searched .keys ))}, 278 {verb :"looked up" ,noun :"searches" ,n :len (b .looked ),items :queries (b .looked )}, 279 {verb :"edited" ,noun :"files" ,n :len (edited ),items :pathGroups (edited )}, 280 {verb :"ran" ,noun :"commands" ,n :len (b .ran .keys ),items :b .ran .counted ()}, 281 {verb :"python" ,noun :"cells" ,n :len (b .py .keys ),items :b .py .counted ()}, 282 {verb :"used" ,noun :"tools" ,n :len (b .used .keys ),items :b .used .counted ()}, 283 } { 284if len (c .items )> 0 { 285c .shown = len (c .items ) 286clauses = append (clauses ,c ) 287 } 288 } 289line := func ()string { 290parts := slices .Clone (head ) 291for _ ,c := range clauses { 292parts = append (parts ,c .String ()) 293 } 294return strings .Join (parts ," · " ) 295 } 296tail := "" 297if n := len (b .failed );n > 0 { 298tail += DefaultStyles .Error .Render (fmt .Sprintf (" · %d failed" ,n )) 299 } 300if b .added + b .removed > 0 { 301tail += " " + r .diffCounts (daemon.FileChange {Added :b .added ,Removed :b .removed }) 302 } 303// shorten the longest list first; once each shows one item, the lists 304// become counts, what was read before what was edited 305room := width - ansi .StringWidth (tail ) 306for ansi .StringWidth (line ())> room && shorten (clauses ) { 307 } 308return fit (DefaultStyles .Faint .Render (line ()),tail ,width ) 309} 310 311// queries names each search by its query, quoted, on one line. 312func queries (looked []lookup ) []string { 313names := make ([]string ,len (looked )) 314for i ,look := range looked { 315names [i ]= "“" + oneLine (look .query )+ "”" 316 } 317return dedup (names ) 318} 319 320// A web search's answer shows its first lookupRows rows under the burst and a 321// click target for the rest. 322const lookupRows = 4 323 324// foldLookup is a search's answer under its burst: the first rows, then a 325// row that opens the rest until its key is in Open. 326func (r TranscriptRenderer )foldLookup (look lookup ,width int ) []string { 327if look .detail == "" { 328return nil 329 } 330rows := r .faintMarkdownRows (look .detail ,width - 2 ) 331for i := range rows { 332rows [i ]= markChrome + " " + rows [i ] 333 } 334if len (rows )<= lookupRows + foldSlack { 335return rows 336 } 337if r .Open [look .key ] { 338return append (rows ,r .foldRow (look .key ," " + toggleOpen + "show less" )) 339 } 340label := fmt .Sprintf (" %s%d more lines · click to expand" ,toggleClosed ,len (rows )- lookupRows ) 341return append (rows [:lookupRows :lookupRows ],r .foldRow (look .key ,label )) 342} 343 344// countedFirst ranks which list turns into a count first. 345var countedFirst = map [string ]int {"read" :0 ,"searched" :1 ,"used" :2 ,"ran" :3 ,"python" :4 ,"looked up" :5 ,"edited" :6 } 346 347// shorten hides one more item, and reports whether any was left to hide. 348func shorten (clauses []* clause )bool { 349var pick * clause 350for _ ,c := range clauses { 351if c .shown > 1 && (pick == nil || c .shown > pick .shown ) { 352pick = c 353 } 354 } 355if pick == nil { 356for _ ,c := range clauses { 357if c .shown == 1 && (pick == nil || countedFirst [c .verb ]< countedFirst [pick .verb ]) { 358pick = c 359 } 360 } 361 } 362if pick == nil { 363return false 364 } 365pick .shown -- 366return true 367} 368 369// A burst's summary leads with a triangle when it has calls to list. 370const ( 371toggleClosed = "▸ " 372toggleOpen = "▾ " 373) 374 375// RenderBurst is a burst's summary row, its steps when it is open, then the 376// row of each call that failed, and with diffs shown, each change. 377func (r TranscriptRenderer )RenderBurst (entries []HistoryEntry ,flags DisplayFlags ,width int )string { 378b ,n := collect (entries ),r .namer () 379key := factsOf (entries [0 ]).key 380opens := slices .ContainsFunc (entries ,func (e HistoryEntry )bool {return e .Kind == EntryTool }) 381open := opens && r .Open [key ] 382head ,room := markChrome ,width 383if opens { 384// the triangle says the row opens, and which way it is now 385toggle := toggleClosed 386if open { 387toggle = toggleOpen 388 } 389head += rowAction {verbOpen ,key }.mark ()+ DefaultStyles .Faint .Render (toggle ) 390room -= ansi .StringWidth (toggle ) 391 } 392rows := []string {head + r .summary (b ,room ,n )} 393if open { 394// failed calls are among the steps 395for _ ,step := range r .steps (entries ,width - 2 ) { 396rows = append (rows ," " + step ) 397 } 398 }else { 399for _ ,entry := range b .failed { 400rows = append (rows ,markChrome + DefaultStyles .Error .Render (toolRow (entry ,true ,"" ,width ))) 401 } 402 } 403for _ ,look := range b .looked { 404rows = append (rows ,r .foldLookup (look ,width )... ) 405 } 406if flags .Diffs { 407for _ ,change := range b .changes { 408if change .Kind == "diff" { 409rows = append (rows ,r .renderDiffPath (change .Diff ,change .Path ,width )) 410 }else { 411rows = append (rows ,DefaultStyles .Faint .Render (n .name (prepareTarget (change .Path ))+ ": " + change .Reason )) 412 } 413 } 414 } 415return strings .Join (rows ,"\n" ) 416} 417 418// steps is one row for each thing a burst's calls did, in order: the full 419// command, pattern, or path, which the summary row only counts. 420func (r TranscriptRenderer )steps (entries []HistoryEntry ,width int ) []string { 421var rows []string 422for _ ,entry := range entries { 423if entry .Kind != EntryTool { 424continue 425 } 426trace := entry .ToolTrace 427if trace == nil || len (trace .Activities )+ len (trace .Changes )== 0 { 428failed ,style := toolFailed (entry ),DefaultStyles .Faint 429if failed { 430style = DefaultStyles .Error 431 } 432rows = append (rows ,style .Render (toolRow (entry ,failed ,"" ,width ))) 433continue 434 } 435for _ ,act := range trace .Activities { 436style := DefaultStyles .Faint 437rows = append (rows ,style .Render (r .traceLine (stepLabel (act .Kind ),act .Target ,isPath (act ),width ))) 438 } 439for _ ,change := range trace .Changes { 440counts := "" 441if change .Kind == "diff" { 442counts = " " + r .diffCounts (change ) 443 } 444line := r .traceLine (stepLabel ("edit" ),change .Path ,true ,width - ansi .StringWidth (counts )) 445rows = append (rows ,fit (DefaultStyles .Faint .Render (line ),counts ,width )) 446 } 447 } 448return rows 449} 450 451// stepLabel pads a step's kind so the targets after it line up. 452func stepLabel (kind string )string { 453return fmt .Sprintf ("%-6s" ,kind ) 454} 455 456// pathGroups names paths by their parent and name, and paths that share a 457// parent together: `tui/{chat,transcript}.go`. 458func pathGroups (paths []string ) []string { 459var dirs []string 460names := map [string ][]string {} 461for _ ,p := range paths { 462dir ,name := path .Split (strings .TrimSuffix (p ,"/" )) 463if strings .HasSuffix (p ,"/" ) { 464name += "/" 465 } 466if _ ,seen := names [dir ];! seen { 467dirs = append (dirs ,dir ) 468 } 469names [dir ]= append (names [dir ],name ) 470 } 471labels := make ([]string ,len (dirs )) 472parents := map [string ]int {} 473for _ ,dir := range dirs { 474parents [parentName (dir )]++ 475 } 476for i ,dir := range dirs { 477parent := parentName (dir ) 478if parents [parent ]> 1 { 479parent = dir 480 } 481labels [i ]= parent + braced (names [dir ]) 482 } 483return labels 484} 485 486// parentName is the last directory of dir with its slash, or nothing at the root. 487func parentName (dir string )string { 488if dir = strings .TrimSuffix (dir ,"/" );dir == "" { 489return "" 490 } 491parent := path .Base (dir )+ "/" 492if strings .HasPrefix (dir ,"/" ) { 493parent = "/" + parent 494 } 495return parent 496} 497 498// braced joins names in braces, a shared extension outside them. 499func braced (names []string )string { 500if len (names )== 1 { 501return names [0 ] 502 } 503ext := path .Ext (names [0 ]) 504stems := make ([]string ,len (names )) 505for i ,name := range names { 506if ext == "" || path .Ext (name )!= ext || strings .HasSuffix (name ,"/" ) { 507return "{" + strings .Join (names ,"," )+ "}" 508 } 509stems [i ]= strings .TrimSuffix (name ,ext ) 510 } 511return "{" + strings .Join (stems ,"," )+ "}" + ext 512} 513 514var ( 515commandBreak = regexp .MustCompile (`&&|\|\||[;|\n]` ) 516assignment = regexp .MustCompile (`^[A-Za-z_][A-Za-z0-9_]*=` ) 517subcommand = regexp .MustCompile (`^[a-z][a-z0-9-]*$` ) 518// setup that comes before the command a line is about 519preamble = wordSet ("cd" ,"export" ,"set" ,"source" ,"." ) 520// programs whose first word names what they do; for the rest it is data, 521// like grep's pattern 522subcommanded = wordSet ("git" ,"jj" ,"go" ,"cargo" ,"gleam" ,"npm" ,"pnpm" ,"yarn" ,"bun" ,"uv" , 523"pip" ,"nix" ,"brew" ,"docker" ,"kubectl" ,"systemctl" ,"tg" ,"gh" ,"make" ,"just" ) 524) 525 526func wordSet (names ... string )map [string ]bool { 527m := make (map [string ]bool ,len (names )) 528for _ ,name := range names { 529m [name ]= true 530 } 531return m 532} 533 534// commandName is what a command line runs, the program and its subcommand 535// when it has one: `cd cli && go test ./...` ran `go test`. 536func commandName (command string )string { 537for _ ,part := range commandBreak .Split (strings .ReplaceAll (command ,"\\\n" ,"" ),- 1 ) { 538fields := strings .Fields (part ) 539for len (fields )> 0 && (assignment .MatchString (fields [0 ])|| fields [0 ]== "exec" || fields [0 ]== "time" ) { 540fields = fields [1 :] 541 } 542if len (fields )== 0 || preamble [fields [0 ]] { 543continue 544 } 545name := fields [0 ] 546if strings .HasPrefix (name ,"/" ) { 547name = path .Base (name ) 548 } 549if len (fields )> 1 && subcommanded [name ]&& subcommand .MatchString (fields [1 ]) { 550name += " " + fields [1 ] 551 } 552return name 553 } 554return oneLine (command ) 555} 556 557// thinkingLine follows the newest nonempty line, even when thinking arrives 558// in fragments or ends in a newline; one-line summaries still show their header. 559func thinkingLine (text string )string { 560for text != "" { 561at := strings .LastIndexByte (text ,'\n' ) 562line := strings .TrimLeft (strings .TrimSpace (text [at + 1 :]),"#> " ) 563if line = oneLine (strings .NewReplacer ("**" ,"" ,"__" ,"" ).Replace (line ));line != "" { 564return line 565 } 566if at < 0 { 567break 568 } 569text = text [:at ] 570 } 571return "" 572}