okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
0a3eddd
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"cmp" 6"fmt" 7"path/filepath" 8"strconv" 9"strings" 10 11"charm.land/lipgloss/v2" 12"github.com/charmbracelet/x/ansi" 13) 14 15func formatUsage (u * daemon.Usage )string { 16if u == nil { 17return "—" 18 } 19total := 0 20if u .TotalTokens != nil { 21total = * u .TotalTokens 22 } 23rate := "" 24if u .TokensPerSecond != nil && * u .TokensPerSecond > 0 { 25rate = " · " + formatRate (* u .TokensPerSecond ) 26 } 27return formatTokens (total )+ " tokens" + rate 28} 29 30func formatRate (tokensPerSecond float64 )string { 31return fmt .Sprintf ("%.1f tok/s" ,tokensPerSecond ) 32} 33 34func formatTokens (val int )string { 35switch { 36case val >= 1_000_000 : 37return fmt .Sprintf ("%.1fm" ,float64 (val )/ 1_000_000.0 ) 38case val >= 1_000 : 39return fmt .Sprintf ("%.1fk" ,float64 (val )/ 1_000.0 ) 40default : 41return strconv .Itoa (val ) 42 } 43} 44 45func (m ChatModel )padding ()int { 46if m .Width >= 50 { 47return 2 48 } 49return 1 50} 51 52func (m ChatModel )chatWidth ()int {return max (1 ,m .Width - 2 * m .padding ()) } 53 54// sidebarWidth is 0 without glances or room for them. The transcript keeps at 55// least 72 columns beside a sidebar of 20 to 32. 56func (m ChatModel )sidebarWidth ()int { 57room := m .chatWidth ()- 72 - 2 58if room < 20 || len (m .activeGlances ())== 0 { 59return 0 60 } 61return min (32 ,room ) 62} 63 64// actionLabel is the action row for a call, live from its progress and then 65// from its result, so the row holds the last action until the next begins. 66func actionLabel (progress * daemon.ToolProgress ,result * HistoryEntry )string { 67switch { 68case result != nil : 69head ,tail := toolRowParts (* result ,toolFailed (* result ),"" ) 70return head + tail 71case progress .Phase == "generating" : 72return "generating " + progress .Name 73case progress .Name == "python" : 74if line := pythonSourceLine (progress .Code );line != "" { 75return "running " + line 76 } 77 } 78return "running " + progress .Name 79} 80 81// pythonSourceLine returns the first useful source line from a cell preview. 82func pythonSourceLine (preview * daemon.ToolCodePreview )string { 83if preview == nil { 84return "" 85 } 86for line := range strings .SplitSeq (preview .Text ,"\n" ) { 87line = strings .TrimSpace (line ) 88if line != "" && ! strings .HasPrefix (line ,"#" ) { 89return line 90 } 91 } 92return "" 93} 94 95// renderProgress holds the last tool action in one row; a call still being 96// generated also shows the newest end of its code. 97func (m ChatModel )renderProgress ()string { 98width := max (1 ,m .Renderer .BodyWidth - railWidth ) 99row := oneLine (m .toolLabel ()) 100if progress := m .latestProgress ();progress != nil && progress .Phase == "generating" && progress .Code != nil { 101code := progress .Code 102if text := oneLine (code .Text );text != "" { 103row += " · " 104if room := width - ansi .StringWidth (row );room >= 12 && ansi .StringWidth (text )> room { 105text = ansi .TruncateLeft (text ,ansi .StringWidth (text )- room + 1 ,"…" ) 106 } 107row += text 108 } 109 } 110return markChrome + DefaultStyles .Faint .Render (ansi .Truncate (row ,width ,"…" )) 111} 112 113// renderThought follows the newest line while the thought streams. 114func (m ChatModel )renderThought ()string { 115header := cmp .Or (thinkingLine (m .transcript .activeText ),"thinking" ) 116width := max (1 ,m .Renderer .BodyWidth - railWidth ) 117return markChrome + DefaultStyles .Faint .Render (ansi .Truncate (header + "…" ,width ,"…" )) 118} 119 120func (m ChatModel )statusLine ()string { 121if m .TurnFailed || m .Notices .HasError () { 122return "Reply failed · see error above" 123 } 124if m .Stopping { 125return "stopping…" 126 } 127// an idle session says nothing unless verbose: the transcript already 128// ends in the turn's signoff. 129if m .Stopped { 130if m .Flags .Tools { 131return "stopped" 132 } 133return "" 134 } 135if host := m .reaching ();host != "" { 136return reachingText (host ,m .Status .KernelStep ) 137 } 138// the port owner gave up and forgot the kernel, so nothing reattaches 139if host ,_ := daemon .SplitLocation (m .Workspace );host != "" && m .Status .KernelLink == "lost" { 140return "kernel on " + cmp .Or (m .Host ,host )+ " lost · the next turn starts a fresh one" 141 } 142if m .isSending || m .pendingSendCount ()> 0 && ! m .Status .Running { 143return "preparing" 144 } 145if progress := m .latestProgress ();progress != nil { 146if progress .Phase == "generating" { 147return "generating call" 148 } 149// the action row above already names what runs 150return "running" 151 } 152if m .Status .Running && ! m .Status .Idle { 153if m .transcript .activeKind == StreamKindText { 154return "responding" 155 } 156if m .Status .Phase != nil { 157switch * m .Status .Phase { 158case daemon .PhaseTool : 159return "running tool" 160case daemon .PhaseCompacting : 161return "compacting context" 162case daemon .PhasePreparing : 163return "preparing" 164 } 165 } 166return "thinking" 167 } 168if m .connecting () { 169return "connecting…" 170 } 171if count := m .backgroundJobCount ();count > 0 { 172label := "1 background job running" 173if count > 1 { 174label = fmt .Sprintf ("%d background jobs running" ,count ) 175 } 176if command := m .agedJobCommand ();command != "" { 177label += " · " + command 178 } 179return label 180 } 181if m .Flags .Tools { 182return "ready" 183 } 184return "" 185} 186 187// Phase is optional display metadata, not evidence of an unanswered status. 188func (m ChatModel )connecting ()bool { 189return m .Status .Phase == nil && ! m .Status .Running && ! m .Status .Idle 190} 191 192// phaseMood is the face class for the phase statusLine names. 193func (m ChatModel )phaseMood ()mood { 194switch { 195case m .Stopping : 196return moodStopping 197case m .reaching ()!= "" : 198return moodConnecting 199case m .isSending || m .pendingSendCount ()> 0 && ! m .Status .Running : 200return moodPreparing 201case m .latestProgress ()!= nil || m .Status .Idle && m .backgroundJobCount ()> 0 : 202return moodWorking 203case m .transcript .activeKind == StreamKindText : 204return moodResponding 205case m .Status .Phase != nil : 206switch * m .Status .Phase { 207case daemon .PhaseTool : 208return moodWorking 209case daemon .PhaseCompacting : 210return moodCompacting 211case daemon .PhasePreparing : 212return moodPreparing 213 } 214 } 215return moodThinking 216} 217 218// header fits the workspace and the model on one rule without cutting either 219// short, shedding the least useful parts first: the middle of the path, the 220// brand, the glance counts from the last, then the path down to its name, 221// which alone may squeeze the rule to one cell. 222func (m ChatModel )header (width int )string { 223host ,workspace := daemon .SplitLocation (cmp .Or (m .Workspace ,"chat" )) 224// a remote workspace's host stays whole in every layout 225lead := "" 226if host == "" { 227workspace = homePath (workspace ) 228 }else { 229host = cmp .Or (m .Host ,host ) 230lead = host + ":" 231workspace = scpRelative (underHome (workspace ,m .hostHome )) 232 } 233model := m .Model 234if m .Effort != "" { 235model += ":" + m .Effort 236 } 237counts := m .glanceCounts () 238folds := pathFolds (workspace ) 239type layout struct { 240place string 241counts int 242rule int 243brand bool 244 } 245var layouts []layout 246for _ ,place := range folds { 247layouts = append (layouts ,layout {brand :true ,place :place ,counts :len (counts ),rule :3 }) 248 } 249for n := len (counts );n >= 0 ;n -- { 250layouts = append (layouts ,layout {brand :false ,place :folds [len (folds )- 1 ],counts :n ,rule :3 }) 251 } 252layouts = append (layouts ,layout {brand :false ,place :filepath .Base (workspace ),counts :0 ,rule :1 }) 253for _ ,l := range layouts { 254right := strings .Join (append (counts [:l .counts :l .counts ],model )," " ) 255left := lead + l .place 256if l .brand { 257left = "✦ " + m .AgentName + " on " + left 258 } 259if lipgloss .Width (left )+ lipgloss .Width (right )+ l .rule + 2 > width { 260continue 261 } 262place := DefaultStyles .Muted .Render (l .place ) 263if host != "" { 264place = m .hostSegment (host )+ DefaultStyles .Muted .Render (":" + l .place ) 265 } 266if l .brand { 267return titleRule (width ,brand (m .AgentName )+ DefaultStyles .Faint .Render (" on " )+ place ,DefaultStyles .Faint .Render (right )) 268 } 269return titleRule (width ,place ,DefaultStyles .Faint .Render (right )) 270 } 271return DefaultStyles .Faint .Render (ansi .Truncate (model ,width ,"…" )) 272} 273 274// hostSegment is the header's host in its own color once the kernel there is 275// attached: faint while it boots or reattaches, the error color once lost. 276func (m ChatModel )hostSegment (host string )string { 277switch m .Status .KernelLink { 278case "booting" ,"reattaching" : 279return DefaultStyles .Faint .Render (host ) 280case "lost" : 281return DefaultStyles .Error .Render (host ) 282 } 283return hostStyle (host ).Render (host ) 284} 285 286// reaching is the remote host a booting or reattaching kernel is on. 287func (m ChatModel )reaching ()string { 288host ,_ := daemon .SplitLocation (m .Workspace ) 289if link := m .Status .KernelLink ;host == "" || link != "booting" && link != "reattaching" { 290return "" 291 } 292return cmp .Or (m .Host ,host ) 293} 294 295func (m ChatModel )View ()string { 296width := m .chatWidth () 297pad := strings .Repeat (" " ,m .padding ()) 298var rows []string 299rows = append (rows ,m .header (width ),"" ) 300for _ ,n := range m .Notices { 301if n .Error { 302rows = append (rows ,m .Renderer .errorRow (n .Message )) 303 }else { 304rows = append (rows ,DefaultStyles .Faint .Render (n .Message )) 305 } 306 } 307if len (m .Notices )> 0 { 308rows = append (rows ,"" ) 309 } 310 311shown := m .Viewport 312if m .History .Len ()== 0 && len (m .pendingUsers )== 0 && m .transcript .activeText == "" && m .toolLabel ()== "" { 313textWidth := max (1 ,min (m .Renderer .BodyWidth ,m .Viewport .Width ())- railWidth ) 314shown .rows = nil 315for _ ,line := range wrapOrChunkLine ("What would you like to work on?" ,textWidth ) { 316shown .rows = append (shown .rows ,m .Renderer .rail (laneNone )+ DefaultStyles .Faint .Render (line )) 317 } 318 } 319content := shown .lines () 320if m .sidebarWidth ()> 0 { 321// the transcript keeps its full width, so the sidebar holds its column 322for i ,line := range content { 323content [i ]= line + strings .Repeat (" " ,max (0 ,shown .width - ansi .StringWidth (line ))) 324 } 325joined := lipgloss .JoinHorizontal (lipgloss .Top ,strings .Join (content ,"\n" )," " ,m .renderGlances ()) 326content = pane {rows :strings .Split (joined ,"\n" ),height :shown .height }.lines () 327 } 328if m .dragAnchor != nil { 329// the selection is in transcript rows; the viewport shows from scrollOffset 330anchor ,head := * m .dragAnchor ,m .dragHead 331anchor .Row ,head .Row = anchor .Row - m .scrollOffset ,head .Row - m .scrollOffset 332content = HighlightSelection (content ,Selection {Anchor :anchor ,Head :head ,Gutter :railWidth }) 333 } 334rows = append (rows ,content ... ) 335// one row: a job's command may span lines 336status := oneLine (m .statusLine ()) 337// the face trails the text, so its frames never move anything 338if m .animating ()&& ! m .TurnFailed && ! m .Notices .HasError () { 339face := m .phaseMood ().frame (m .moodSeed ,m .ProgressFrame ) 340if host := m .reaching ();host != "" { 341face = connectingFace (host ,m .ProgressFrame )// the picker's face for this host 342 } 343if m .Width > 0 { 344status = ansi .Truncate (status ,max (1 ,m .chatWidth ()- ansi .StringWidth (face )- 1 ),"…" ) 345 } 346status = DefaultStyles .Faint .Render (status )+ " " + DefaultStyles .Agent .Render (face ) 347 } 348if ! m .Follow { 349status = fmt .Sprintf ("history · %d rows below · pgdn" ,max (0 ,m .scrollLimit - m .scrollOffset )) 350 } 351if m .CopyStatus != "" { 352status = m .CopyStatus 353 } 354statusStyle := DefaultStyles .Faint 355if m .TurnFailed || m .Notices .HasError () { 356statusStyle = DefaultStyles .Error 357 } 358rows = append (rows ,statusStyle .Render (status )) 359if len (m .effortOptions )== 0 { 360rows = append (rows ,m .chipStrip (width )... ) 361 } 362rows = append (rows ,DefaultStyles .Decor .Render (strings .Repeat ("─" ,width ))) 363if len (m .effortOptions )> 0 { 364rows = append (rows ,"" ,DefaultStyles .Bold .Render (ansi .Truncate ("Reasoning effort" ,width ,"" )),m .effortSelectorView (),DefaultStyles .Faint .Render (ansi .Truncate ("← → choose · enter apply · esc cancel" ,width ,"" ))) 365 }else { 366rows = append (rows ,strings .Split (strings .TrimSuffix (m .composerView (),"\n" ),"\n" )... ) 367if menu := m .CommandMenu .View (m .TextArea .Value ());menu != "" { 368rows = append (rows ,strings .Split (strings .TrimSuffix (menu ,"\n" ),"\n" )... ) 369 } 370 } 371rows = append (rows ,DefaultStyles .Decor .Render (strings .Repeat ("─" ,width ))) 372rows = append (rows ,m .renderFooter ()) 373for i ,row := range rows { 374rows [i ]= pad + row 375 } 376return strings .Join (rows ,"\n" ) 377} 378 379func (m ChatModel )composerView ()string { 380ta := m .TextArea 381if m .waitingForInput ()&& ta .Value ()== "" { 382ta .Placeholder = "ctrl+g editor" 383 }else { 384ta .Placeholder = "" 385 } 386lines := strings .Split (ta .View (),"\n" ) 387h := m .promptHeight () 388if h < len (lines ) { 389lines = lines [:h ] 390 } 391return strings .Join (lines ,"\n" ) 392} 393 394// waitingForInput reports an opened session with no turn in flight, so an 395// empty composer reads as the agent's cue rather than a stalled turn. 396func (m ChatModel )waitingForInput ()bool { 397return ! m .connecting ()&& ! m .animating () 398}