okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
4e62e9e
main
1package tui 2 3import ( 4"fmt" 5"maps" 6"regexp" 7"slices" 8"strings" 9"time" 10 11 tea"charm.land/bubbletea/v2" 12) 13 14// A row action is a click target. Like the copy marks in selection.go, its 15// mark is a zero-width APC string, so the row itself says what a click on it 16// does and survives wrapping, trimming, and rebuilds. AppModel.View removes 17// it before rendering to the terminal. 18const ( 19// verbCopy copies a turn's replies as the markdown they were written in. 20verbCopy = "copy" 21// verbOpen lists or hides the steps of a burst. 22verbOpen = "open" 23// verbMore shows or folds the rest of a long message of yours, of a 24// note, or of a web search's answer. 25verbMore = "more" 26) 27 28var actionMark = regexp .MustCompile (`\x1b_albedo:(copy|open|more):([0-9a-f]+-[0-9a-f]+)\x1b\\` ) 29 30type rowAction struct {verb ,key string } 31 32func (a rowAction )mark ()string {return "\x1b_albedo:" + a .verb + ":" + a .key + "\x1b\\" } 33 34func actionOf (row string ) (rowAction ,bool ) { 35hit := actionMark .FindStringSubmatch (row ) 36if hit == nil { 37return rowAction {},false 38 } 39return rowAction {verb :hit [1 ],key :hit [2 ]},true 40} 41 42// entryKey names an entry by what it says, so the mark on its rows still 43// finds it after the transcript is rebuilt. 44func entryKey (entry HistoryEntry )string { 45h := fnv1a (fnvOffset64 ,string (entry .Kind )+ "\x00" + entry .ToolName + "\x00" + entry .Text + "\x00" + entry .ToolResult + "\x00" + fmt .Sprint (entry .ToolArgs )) 46return fmt .Sprintf ("%x-%x" ,entry .Timestamp ,h ) 47} 48 49// actAt runs the action of the transcript row at absolute index row. 50func (m * ChatModel )actAt (row int ) tea.Cmd { 51if row < 0 || row >= len (m .frameLines ) { 52return nil 53 } 54act ,ok := actionOf (m .frameLines [row ]) 55if ! ok { 56return nil 57 } 58switch act .verb { 59case verbCopy : 60return m .copyReply (act .key ) 61case verbOpen : 62m .toggleBurst (act .key ,row ) 63case verbMore : 64if ! m .toggleFold (act .key ,row ) { 65m .toggleLookup (act .key ,row ) 66 } 67 } 68return nil 69} 70 71// toggleFold shows or folds the rest of the message whose fold row is at row, 72// the last row of its block. Only that block renders again and is spliced into 73// the settled rows. The clicked row keeps its place on screen: expanding opens 74// the text below it, and folding brings the new fold row to where it was. 75// It reports false when key names no message of yours and no note. 76func (m * ChatModel )toggleFold (key string ,row int )bool { 77entries := m .History .Entries () 78at := slices .IndexFunc (entries ,func (e HistoryEntry )bool { 79return (e .Kind == EntryUser || e .Kind == EntryNote )&& entryKey (e )== key 80 }) 81if at < 0 { 82return false 83 } 84was := m .Renderer 85was .Open = map [string ]bool {key :m .Renderer .Open [key ]} 86old ,_ := was .Block (entries [:at ],entries [at ],m .Flags ) 87if m .Renderer .Open == nil { 88m .Renderer .Open = map [string ]bool {} 89 } 90m .Renderer .Open [key ]= ! m .Renderer .Open [key ] 91m .settled = nil // kept blocks were drawn with the fold as it was 92fresh ,_ := m .Renderer .Block (entries [:at ],entries [at ],m .Flags ) 93from := row - m .settledOffset - (len (old )- 1 ) 94if from < 0 || from + len (old )> len (m .settledLines )|| m .settledLines [from + len (old )- 1 ]!= old [len (old )- 1 ] { 95m .rebuildSettledLines () 96m .refreshViewportContent () 97return true 98 } 99screen := row - m .scrollOffset 100if ! m .Renderer .Open [key ] { 101row += len (fresh )- len (old ) 102 } 103row -= m .replaceSettled (from ,len (old ),fresh ) 104m .Follow ,m .scrollOffset = false ,max (0 ,row - screen ) 105m .Follow = m .scrollOffset >= m .refreshViewportContent () 106return true 107} 108 109// toggleLookup shows or folds the rest of the web search answer whose fold 110// row is at row. Its burst renders again; the clicked row keeps its place on 111// screen, as toggleFold keeps a message's. 112func (m * ChatModel )toggleLookup (key string ,row int ) { 113entries := m .History .Entries () 114at := slices .IndexFunc (entries ,func (e HistoryEntry )bool { 115return slices .ContainsFunc (factsOf (e ).looked ,func (look lookup )bool {return look .key == key }) 116 }) 117if at < 0 { 118return 119 } 120start := at 121for start > 0 && Compact (entries [start - 1 ],m .Flags ) { 122start -- 123 } 124end := at + 1 125for end < len (entries )&& Compact (entries [end ],m .Flags ) { 126end ++ 127 } 128before ,burst := entries [:start ],entries [start :end ] 129fold := func (lines []string )int { 130return slices .IndexFunc (lines ,func (line string )bool { 131act ,ok := actionOf (line ) 132return ok && act == rowAction {verbMore ,key } 133 }) 134 } 135was := m .Renderer 136was .Open = maps .Clone (m .Renderer .Open ) 137old := was .BurstBlock (before ,burst ,m .Flags ) 138if m .Renderer .Open == nil { 139m .Renderer .Open = map [string ]bool {} 140 } 141m .Renderer .Open [key ]= ! m .Renderer .Open [key ] 142m .settled = nil // kept blocks were drawn with the fold as it was 143fresh := m .Renderer .BurstBlock (before ,burst ,m .Flags ) 144clicked ,screen := fold (old ),row - m .scrollOffset 145if end < len (entries ) { 146from := row - m .settledOffset - clicked 147if clicked < 0 || from < 0 || from + len (old )> len (m .settledLines )|| m .settledLines [row - m .settledOffset ]!= old [clicked ] { 148m .rebuildSettledLines () 149m .refreshViewportContent () 150return 151 } 152row -= m .replaceSettled (from ,len (old ),fresh ) 153 } 154m .burstEpoch ++ 155if ! m .Renderer .Open [key ] { 156row += fold (fresh )- clicked 157 } 158m .Follow ,m .scrollOffset = false ,max (0 ,row - screen ) 159m .Follow = m .scrollOffset >= m .refreshViewportContent () 160} 161 162// toggleBurst opens or closes the burst whose summary is at row, keeping that 163// row where it was on screen; an opened list that would end below the screen 164// scrolls up into view. Only the burst renders again: a settled one is 165// spliced into the settled rows, and the live one redraws on its own. 166func (m * ChatModel )toggleBurst (key string ,row int ) { 167entries := m .History .Entries () 168start ,end ,ok := burstNamed (entries ,key ,m .Flags ) 169if ! ok { 170return 171 } 172before ,burst := entries [:start ],entries [start :end ] 173was := m .Renderer 174was .Open = map [string ]bool {key :m .Renderer .Open [key ]} 175old := was .BurstBlock (before ,burst ,m .Flags ) 176if m .Renderer .Open == nil { 177m .Renderer .Open = map [string ]bool {} 178 } 179m .Renderer .Open [key ]= ! m .Renderer .Open [key ] 180m .settled = nil // kept blocks were drawn with the fold as it was 181fresh := m .Renderer .BurstBlock (before ,burst ,m .Flags ) 182// the summary sits after the same separator in both 183head := slices .IndexFunc (fresh ,func (line string )bool { 184_ ,ok := actionOf (line ) 185return ok 186 }) 187screen := row - m .scrollOffset 188if end < len (entries ) { 189from := row - m .settledOffset - head 190if from < 0 || from + len (old )> len (m .settledLines )|| m .settledLines [from + head ]!= old [head ] { 191m .rebuildSettledLines () 192m .refreshViewportContent () 193return 194 } 195row -= m .replaceSettled (from ,len (old ),fresh ) 196 } 197m .burstEpoch ++ 198offset ,last := row - screen ,row - head + len (fresh ) 199if last - offset > m .Viewport .Height () { 200offset = min (row ,last - m .Viewport .Height ()) 201 } 202m .Follow ,m .scrollOffset = false ,max (0 ,offset ) 203m .Follow = m .scrollOffset >= m .refreshViewportContent () 204} 205 206// copyReply copies a reply's markdown. 207func (m * ChatModel )copyReply (key string ) tea.Cmd { 208if text ,ok := m .replyMarkdown (key );ok { 209return m .copied (text ) 210 } 211return nil 212} 213 214// replyMarkdown is a finished turn's prose as written: every reply since 215// your message, which the turn's signoff names. 216func (m * ChatModel )replyMarkdown (key string ) (string ,bool ) { 217entries := m .History .Entries () 218end := slices .IndexFunc (entries ,func (e HistoryEntry )bool {return e .Kind == EntryTurnEnd && entryKey (e )== key }) 219if end < 0 { 220return "" ,false 221 } 222start := end 223for start > 0 && entries [start - 1 ].Kind != EntryUser { 224start -- 225 } 226var parts []string 227for _ ,e := range entries [start :end ] { 228if e .Kind == EntryAssistant { 229parts = append (parts ,e .Text ) 230 } 231 } 232return strings .Join (parts ,"\n\n" ),len (parts )> 0 233} 234 235func (m * ChatModel )copied (text string ) tea.Cmd { 236m .CopyStatus = "copied" 237m .copyStatusRevision ++ 238return tea .Batch (CopyText (text ),m .clearCopyStatusCmd ()) 239} 240 241// Dragging a selection past the top or bottom edge of the transcript 242// scrolls it: after a short delay, then a row at a time while the pointer 243// stays there. The selection is in transcript rows, so it keeps growing. 244const ( 245dragScrollDelay = 150 * time .Millisecond 246dragScrollEvery = 50 * time .Millisecond 247) 248 249type dragScrollMsg struct {gen int } 250 251func dragScrollTick (gen int ,after time.Duration ) tea.Cmd { 252return tea .Tick (after ,func (time.Time ) tea.Msg {return dragScrollMsg {gen } }) 253} 254 255// steerDragScroll starts or stops the edge scroll for a pointer on screen 256// row of the transcript. 257func (m * ChatModel )steerDragScroll (row int ) tea.Cmd { 258dir := 0 259switch { 260case row <= 0 && m .scrollOffset > 0 : 261dir = - 1 262case row >= m .Viewport .Height ()- 1 && ! m .Follow : 263dir = 1 264 } 265if dir == m .dragDir { 266return nil 267 } 268m .dragDir = dir 269m .dragGen ++ 270if dir == 0 { 271return nil 272 } 273return dragScrollTick (m .dragGen ,dragScrollDelay ) 274} 275 276// dragScrolled moves one row toward the edge and pulls the selection's head 277// along, or stops when the transcript has no more to show that way. 278func (m * ChatModel )dragScrolled (msg dragScrollMsg ) tea.Cmd { 279if m .dragAnchor == nil || msg .gen != m .dragGen || m .dragDir == 0 { 280return nil 281 } 282before := m .scrollOffset 283m .scrollBy (m .dragDir ) 284if m .scrollOffset == before { 285m .dragDir = 0 286return nil 287 } 288m .dragHead .Row = m .scrollOffset 289if m .dragDir >= 0 { 290m .dragHead .Row += m .Viewport .Height ()- 1 291 } 292return dragScrollTick (m .dragGen ,dragScrollEvery ) 293}