okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
60cce3a
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"iter" 6"slices" 7"strings" 8"unicode/utf8" 9) 10 11const ( 12agentPreviewBytes = 64 << 10 13agentPreviewLines = 120 14) 15 16type agentTailSpan struct { 17start ,end uint64 18kind tailKind 19} 20 21// agentTail owns its bytes. Absolute offsets let a long line lose its oldest 22// bytes without shifting the window or retaining the original stream chunks. 23type agentTail struct { 24data []byte 25lines [agentPreviewLines ]agentTailSpan 26end uint64 27head int 28count int 29open bool 30omitted bool 31} 32 33func (tail * agentTail )dropOldest () { 34tail .lines [tail .head ]= agentTailSpan {} 35tail .head = (tail .head + 1 )% agentPreviewLines 36tail .count -- 37tail .omitted = true 38} 39 40func (tail * agentTail )begin (kind tailKind ) { 41if tail .count == agentPreviewLines { 42tail .dropOldest () 43 } 44index := (tail .head + tail .count )% agentPreviewLines 45tail .lines [index ]= agentTailSpan {start :tail .end ,end :tail .end ,kind :kind } 46tail .count ++ 47tail .open = true 48} 49 50func (tail * agentTail )grow (extra int ) { 51start := tail .lines [tail .head ].start 52retained := int (tail .end - start ) 53required := min (agentPreviewBytes ,retained + extra ) 54if required <= len (tail .data ) { 55return 56 } 57data := make ([]byte ,min (agentPreviewBytes ,max (64 ,required ,len (tail .data )* 2 ))) 58if retained > 0 { 59index := int (start % uint64 (len (tail .data ))) 60copied := copy (data [:retained ],tail .data [index :]) 61copy (data [copied :retained ],tail .data [:retained - copied ]) 62 } 63for offset := range tail .count { 64span := & tail .lines [(tail .head + offset )% agentPreviewLines ] 65span .start -= start 66span .end -= start 67 } 68tail .end -= start 69tail .data = data 70} 71 72func (tail * agentTail )appendText (text string ) { 73if len (text )== 0 { 74return 75 } 76tail .grow (len (text )) 77capacity := uint64 (len (tail .data )) 78next := tail .end + uint64 (len (text )) 79if len (text )> len (tail .data ) { 80text = text [len (text )- len (tail .data ):] 81 } 82index := int ((next - uint64 (len (text )))% capacity ) 83copied := copy (tail .data [index :],text ) 84copy (tail .data ,text [copied :]) 85tail .end = next 86tail .lines [(tail .head + tail .count - 1 )% agentPreviewLines ].end = next 87if next <= capacity { 88return 89 } 90oldest := next - capacity 91for tail .count > 1 && tail .lines [tail .head ].end <= oldest { 92tail .dropOldest () 93 } 94first := & tail .lines [tail .head ] 95if first .start < oldest { 96first .start = oldest 97for first .start < first .end && ! utf8 .RuneStart (tail .data [first .start % capacity ]) { 98first .start ++ 99 } 100tail .omitted = true 101 } 102} 103 104func (tail * agentTail )write (text string ,kind tailKind ) { 105for { 106line ,rest ,newline := strings .Cut (text ,"\n" ) 107if ! tail .open { 108tail .begin (kind ) 109 } 110tail .appendText (line ) 111if ! newline { 112return 113 } 114tail .open = false 115text = rest 116 } 117} 118 119func (tail * agentTail )push (line tailLine ) { 120tail .open = false 121tail .write (strings .TrimRight (line .text ," " ),line .kind ) 122tail .open = false 123} 124 125func (tail * agentTail )text (span agentTailSpan )string { 126if span .start == span .end { 127return "" 128 } 129var text strings.Builder 130text .Grow (int (span .end - span .start )) 131start := int (span .start % uint64 (len (tail .data ))) 132length := int (span .end - span .start ) 133first := min (length ,len (tail .data )- start ) 134text .Write (tail .data [start :start + first ]) 135text .Write (tail .data [:length - first ]) 136return text .String () 137} 138 139// newest yields owned strings, so callers may retain them after a reset. 140func (tail * agentTail )newest (trimNewlines bool ) iter.Seq [tailLine ] { 141return func (yield func (tailLine )bool ) { 142trimming := trimNewlines 143for offset := tail .count - 1 ;offset >= 0 ;offset -- { 144span := tail .lines [(tail .head + offset )% agentPreviewLines ] 145if trimming && span .start == span .end && offset > 0 { 146continue 147 } 148trimming = false 149if ! yield (tailLine {kind :span .kind ,text :tail .text (span )}) { 150return 151 } 152 } 153 } 154} 155 156// setCode replaces the preview window supplied by the daemon. 157func (tail * agentTail )setCode (code * daemon.ToolCodePreview ) { 158* tail = agentTail {} 159if code != nil { 160tail .write (strings .ReplaceAll (code .Text ,"\t" ," " ),tailCode ) 161 } 162} 163 164type agentTailCache struct { 165node * agentNode 166ink ink 167rows []string 168revision uint64 169width int 170height int 171} 172 173// refreshTail runs after updates, never from View. Only the selected node 174// needs wrapped rows; animation-only frames reuse the previous result. 175func (m * AgentsViewModel )refreshTail () { 176node := m .nodes [m .selected ] 177width ,height := m .paneWidth ()- 1 ,max (0 ,m .Height ) 178cache := & m .tailCache 179if node == nil || width <= 0 || height == 0 { 180* cache = agentTailCache {} 181return 182 } 183if cache .node == node && cache .revision == node .revision && cache .width == width && cache .height == height && cache .ink == transcriptInk { 184return 185 } 186rows := make ([]string ,0 ,height ) 187appendLine := func (line tailLine )bool { 188wrapped := drawTail (line ,width ,height - len (rows )) 189for _ ,row := range slices .Backward (wrapped ) { 190rows = append (rows ,row ) 191 } 192return len (rows )< height 193 } 194for line := range node .preview .newest (node .lineKind == tailCode ) { 195if line .text == "" && node .lineKind != tailCode { 196continue 197 } 198if ! appendLine (line ) { 199break 200 } 201 } 202if len (rows )< height { 203for line := range node .tail .newest (false ) { 204if ! appendLine (line ) { 205break 206 } 207 } 208 } 209slices .Reverse (rows ) 210* cache = agentTailCache {node :node ,rows :rows ,ink :transcriptInk ,revision :node .revision ,width :width ,height :height } 211}