okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
d7df0e6
main
1package tui 2 3import ( 4"cmp" 5"fmt" 6"hash/fnv" 7"image/color" 8"os" 9"path/filepath" 10"strings" 11"sync" 12 13"charm.land/bubbles/v2/textinput" 14 tea"charm.land/bubbletea/v2" 15"charm.land/lipgloss/v2" 16"github.com/charmbracelet/x/ansi" 17) 18 19// Styles defines the shared terminal palette. Screens compose these 20// styles and components, so the transcript, the session screen, and the 21// pickers cannot drift into their own palettes. 22// 23// Tokens come in three kinds: 24// - ink, a brightness ramp mixed from the terminal's own colors: prose 25// keeps the terminal foreground, then Muted, Faint and Decor step down 26// - identity: You and Agent, and Busy for albedo working in tools 27// - state: Error, Warning and Success 28// 29// Without reported terminal colors every token falls back to a palette slot. 30type Styles struct { 31// Muted is text worth reading that steps back from prose: keys, 32// metadata, section labels. 33Muted lipgloss.Style 34// Faint is text read at a glance: tool rows, timings, hints. 35Faint lipgloss.Style 36// Decor is structure seen, not read: rules, separators, markers. 37Decor lipgloss.Style 38Bold lipgloss.Style 39 40You lipgloss.Style 41Agent lipgloss.Style 42Busy lipgloss.Style 43 44Error lipgloss.Style 45Warning lipgloss.Style 46Success lipgloss.Style 47 48// Prompt marks where you type. 49Prompt lipgloss.Style 50// Selected is the surface under a selected row: a tint where the 51// terminal reported its colors, reverse video where it did not. 52Selected lipgloss.Style 53// Effort tiers form a cool-to-warm scale; max is violet. 54EffortLow lipgloss.Style 55EffortMedium lipgloss.Style 56EffortHigh lipgloss.Style 57EffortXHigh lipgloss.Style 58EffortMax lipgloss.Style 59// Cursor is the text cursor and a drag selection. 60Cursor lipgloss.Style 61} 62 63var DefaultStyles = Styles { 64Muted :lipgloss .NewStyle ().Foreground (lipgloss .Color ("7" )), 65Faint :lipgloss .NewStyle ().Foreground (lipgloss .Color ("8" )), 66Decor :lipgloss .NewStyle ().Foreground (lipgloss .Color ("8" )), 67Bold :lipgloss .NewStyle ().Bold (true ), 68You :lipgloss .NewStyle ().Foreground (lipgloss .Color ("6" )), 69Agent :lipgloss .NewStyle ().Foreground (lipgloss .Color ("5" )), 70Busy :lipgloss .NewStyle ().Foreground (lipgloss .Color ("5" )).Faint (true ), 71Error :lipgloss .NewStyle ().Foreground (lipgloss .Color ("1" )), 72Warning :lipgloss .NewStyle ().Foreground (lipgloss .Color ("3" )), 73Success :lipgloss .NewStyle ().Foreground (lipgloss .Color ("2" )), 74Prompt :lipgloss .NewStyle ().Foreground (lipgloss .Color ("6" )), 75Selected :lipgloss .NewStyle ().Reverse (true ), 76EffortLow :lipgloss .NewStyle ().Foreground (lipgloss .Color ("#8DCFF5" )), 77EffortMedium :lipgloss .NewStyle ().Foreground (lipgloss .Color ("#78CFC3" )), 78EffortHigh :lipgloss .NewStyle ().Foreground (lipgloss .Color ("#F3AD68" )), 79EffortXHigh :lipgloss .NewStyle ().Foreground (lipgloss .Color ("#F27979" )), 80EffortMax :lipgloss .NewStyle ().Foreground (lipgloss .Color ("#C39AF5" )), 81Cursor :lipgloss .NewStyle ().Reverse (true ), 82} 83 84func useInk (detected ink ) { 85transcriptInk = detected 86fg := func (hex string ) lipgloss.Style {return lipgloss .NewStyle ().Foreground (lipgloss .Color (hex )) } 87DefaultStyles .Muted = fg (detected .code ) 88DefaultStyles .Faint = fg (detected .secondary ) 89DefaultStyles .Decor = fg (detected .decor ) 90if detected .busy != "" { 91DefaultStyles .Busy = fg (detected .busy ) 92 } 93DefaultStyles .Selected = lipgloss .NewStyle ().Background (lipgloss .Color (detected .surface )) 94} 95 96// newTextInput is a text input in the theme: plain text and prompt whether 97// focused or not, and a reverse-video cursor that holds still, so an idle 98// input never wakes the program to blink. 99func newTextInput () textinput.Model { 100ti := textinput .New () 101s := ti .Styles () 102s .Focused .Prompt ,s .Focused .Text = lipgloss .NewStyle (),lipgloss .NewStyle () 103s .Blurred = s .Focused 104s .Cursor = textinput.CursorStyle {Shape :tea .CursorBlock } 105ti .SetStyles (s ) 106return ti 107} 108 109const ( 110ansiReset = "\x1b[0m" 111ansiItalic = "\x1b[3m" 112ansiGray = "\x1b[90m" 113) 114 115// sgr is a foreground color as an escape sequence, or fallback when the 116// ramp is unknown. Bubble Tea downsamples it to the terminal's profile. 117func sgr (hex ,fallback string )string {return sgrLayer (hex ,fallback ,false ) } 118 119func sgrLayer (hex ,fallback string ,background bool )string { 120if parseHex (hex )== nil { 121return fallback 122 } 123if background { 124return ansi .NewStyle ().BackgroundColor (lipgloss .Color (hex )).String () 125 } 126return ansi .NewStyle ().ForegroundColor (lipgloss .Color (hex )).String () 127} 128 129// codeInk steps code down from prose so a reply that decorates every 130// identifier stays quiet. 131func codeInk ()string {return sgr (transcriptInk .code ,"\x1b[37m" ) } 132 133// faintInk is the Faint token as a raw sequence, for markdown's own spans. 134func faintInk ()string {return sgr (transcriptInk .secondary ,ansiGray ) } 135 136// decorInk is the Decor token as a raw sequence. 137func decorInk ()string {return sgr (transcriptInk .decor ,ansiGray ) } 138 139// Diff rows sit on tints mixed toward the palette's red and green. The 140// fallbacks are dark tints, the only case without reported colors. 141func diffPanel ()string {return sgrLayer (transcriptInk .surface ,"\x1b[48;2;37;40;50m" ,true ) } 142func diffAdded ()string {return sgrLayer (transcriptInk .added ,"\x1b[48;2;24;53;39m" ,true ) } 143func diffRemoved ()string {return sgrLayer (transcriptInk .removed ,"\x1b[48;2;59;35;40m" ,true ) } 144 145// keepBackground turns full resets into foreground and attribute resets, so 146// a styled span inside a tinted row keeps the row's background. Lip Gloss 147// spells a full reset "ESC[m"; the raw ink spells it "ESC[0m". 148func keepBackground (s string )string { 149return strings .NewReplacer (ansiReset ,"\x1b[39;22;23m" ,"\x1b[m" ,"\x1b[39;22;23m" ).Replace (s ) 150} 151 152// gradient is the brand ramp at t in [0, 1], or nil when the terminal did 153// not report the colors it is mixed from. 154func gradient (t float64 ) color.Color { 155from ,to := parseHex (transcriptInk .brandFrom ),parseHex (transcriptInk .brandTo ) 156if from == nil || to == nil { 157return nil 158 } 159return lipgloss .Color (from .mix (* to ,min (1 ,max (0 ,t ))).hex ()) 160} 161 162func parseHex (hex string )* rgb { 163var r ,g ,b int 164if _ ,err := fmt .Sscanf (hex ,"#%02x%02x%02x" ,& r ,& g ,& b );err != nil { 165return nil 166 } 167return & rgb {float64 (r )/ 255 ,float64 (g )/ 255 ,float64 (b )/ 255 } 168} 169 170// gradientText sweeps the brand ramp across s. Without it s takes the agent 171// color. 172func gradientText (s string ,bold bool )string { 173if gradient (0 )== nil { 174return DefaultStyles .Agent .Bold (bold ).Render (s ) 175 } 176return ramp (rampKey {text :s ,bold :bold },func ()string { 177runes := []rune (s ) 178var b strings.Builder 179st := lipgloss .NewStyle ().Bold (bold ) 180for i ,r := range runes { 181t := float64 (i )/ float64 (max (1 ,len (runes )- 1 )) 182b .WriteString (st .Foreground (gradient (t )).Render (string (r ))) 183 } 184return b .String () 185 }) 186} 187 188// rampKey names a rendering of the brand ramp: text swept by it, or a rule 189// of rule cells fading from it. 190type rampKey struct { 191text string 192bold bool 193rule int 194} 195 196// ramps keeps renderings of the brand ramp. They change only with the 197// detected ink, and every frame's header asks for the same few again. 198var ramps struct { 199byKey map [rampKey ]string 200ink ink 201 sync.Mutex 202} 203 204func ramp (key rampKey ,render func ()string )string { 205ramps .Lock () 206defer ramps .Unlock () 207if ramps .byKey == nil || ramps .ink != transcriptInk || len (ramps .byKey )> 256 { 208ramps .ink ,ramps .byKey = transcriptInk ,map [rampKey ]string {} 209 } 210s ,ok := ramps .byKey [key ] 211if ! ok { 212s = render () 213ramps .byKey [key ]= s 214 } 215return s 216} 217 218// brandInk colors a mark along the brand ramp, or in the agent color. 219func brandInk (t float64 ) lipgloss.Style { 220if c := gradient (t );c != nil { 221return lipgloss .NewStyle ().Foreground (c ) 222 } 223return DefaultStyles .Agent 224} 225 226func brand (name string )string {return gradientText ("✦ " + name ,true ) } 227 228// titleRule heads a screen: left, a rule that fades from the brand's end 229// into Decor, then right. Without room for one rule cell it is left alone. 230func titleRule (width int ,left ,right string )string { 231if right != "" { 232right = " " + right 233 } 234n := width - ansi .StringWidth (left )- ansi .StringWidth (right )- 1 235if n < 1 { 236return left 237 } 238return left + " " + ramp (rampKey {rule :n },func ()string {return fadeRule (n ) })+ right 239} 240 241// fadeRule is n rule cells fading from the brand's end into Decor. 242func fadeRule (n int )string { 243var rule strings.Builder 244from ,to := parseHex (transcriptInk .brandTo ),parseHex (transcriptInk .decor ) 245for i := range n { 246t := float64 (i )/ float64 (n ) 247if from != nil && to != nil && t < 0.35 { 248rule .WriteString (lipgloss .NewStyle ().Foreground (lipgloss .Color (from .mix (* to ,t / 0.35 ).hex ())).Render ("─" )) 249continue 250 } 251rule .WriteString (DefaultStyles .Decor .Render ("─" )) 252 } 253return rule .String () 254} 255 256// located is the brand at a place: "✦ albedo on ~/path". 257func located (name ,place string )string { 258if place == "" { 259return brand (name ) 260 } 261return brand (name )+ DefaultStyles .Faint .Render (" on " )+ DefaultStyles .Muted .Render (place ) 262} 263 264// hint is a key and what it does. 265type hint struct {key ,does string } 266 267// keyHints lists keys in Muted and what they do in Faint. A hint without a 268// key is a plain note. 269func keyHints (hints ... hint )string { 270parts := make ([]string ,len (hints )) 271for i ,h := range hints { 272switch { 273case h .key == "" : 274parts [i ]= DefaultStyles .Faint .Render (h .does ) 275case h .does == "" : 276parts [i ]= DefaultStyles .Muted .Render (h .key ) 277default : 278parts [i ]= DefaultStyles .Muted .Render (h .key )+ " " + DefaultStyles .Faint .Render (h .does ) 279 } 280 } 281return strings .Join (parts ,DefaultStyles .Decor .Render (" · " )) 282} 283 284// sectionRule heads a group in a list: " ─ label count ────". 285func sectionRule (label string ,count ,width int )string { 286head := " " + label + " " 287tail := fmt .Sprintf ("%d " ,count ) 288rule := strings .Repeat ("─" ,max (0 ,width - 2 - ansi .StringWidth (head + tail ))) 289return DefaultStyles .Decor .Render (" ─" )+ DefaultStyles .Muted .Bold (true ).Render (head )+ DefaultStyles .Decor .Render (tail + rule ) 290} 291 292// promptMark is where you type. 293const promptMark = "› " 294const promptMarkWidth = 2 295 296func promptLead ()string {return DefaultStyles .Prompt .Render (promptMark ) } 297 298// selectBar marks the selected row. 299func selectBar ()string {return DefaultStyles .Agent .Render ("▌" ) } 300 301// selectedLine lays the selection surface under a styled line, reopening it 302// after every reset inside the line, and fills it out to width. 303func selectedLine (line string ,width int )string { 304marked := DefaultStyles .Selected .Render ("\x00" ) 305open := marked [:strings .IndexByte (marked ,0 )] 306if pad := width - ansi .StringWidth (line );pad > 0 { 307line += strings .Repeat (" " ,pad ) 308 } 309return open + strings .NewReplacer (ansiReset ,ansiReset + open ,"\x1b[m" ,"\x1b[m" + open ).Replace (line )+ ansiReset 310} 311 312// homePath shortens a path under your home directory to start with ~. 313func homePath (p string )string { 314home ,_ := os .UserHomeDir () 315return underHome (p ,home ) 316} 317 318// pathFolds shortens a path by folding its middle into "…" one more segment 319// at a time, keeping its root and its name: ~/a/b/c, ~/…/b/c, ~/…/c. 320func pathFolds (p string ) []string { 321sep := string (filepath .Separator ) 322parts := strings .Split (p ,sep ) 323folds := []string {p } 324for keep := len (parts )- 2 ;keep >= 1 ;keep -- { 325folds = append (folds ,strings .Join (append ([]string {parts [0 ],"…" },parts [len (parts )- keep :]... ),sep )) 326 } 327return folds 328} 329 330// underHome shortens p under home to start with ~. 331func underHome (p ,home string )string { 332rest ,ok := strings .CutPrefix (p ,home ) 333if home == "" || ! ok || rest != "" && rest [0 ]!= filepath .Separator { 334return p 335 } 336return "~" + rest 337} 338 339// scpRelative is a remote path after its host's colon as scp reads it: 340// one under ~ relative to the home, which itself stays ~. 341func scpRelative (p string )string { 342if rest ,ok := strings .CutPrefix (p ,"~/" );ok { 343return rest 344 } 345return p 346} 347 348// languageHues are our own colors for common languages, by linguist name. 349// Each starts from linguist's hue, softened to a pastel and nudged so 350// languages that often share a repository stay apart. 351var languageHues = map [string ]string { 352"Go" :"#6fd1c4" , 353"Nix" :"#b9a6f5" , 354"TypeScript" :"#80aaf9" , 355"Gleam" :"#f28fb8" , 356"Erlang" :"#ef7a85" , 357"Python" :"#f0c674" , 358"Markdown" :"#b6e37a" , 359"Rust" :"#f5a97f" , 360"JavaScript" :"#e9e48a" , 361"Shell" :"#8fdc9a" , 362"C" :"#a3b4c8" , 363"Elixir" :"#d4a0d8" , 364"Java" :"#dcb68a" , 365} 366 367// languageLc is the contrast a language's color keeps with the background: 368// file names are read, so about what content text gets. 369const languageLc = 60 370 371// languageStyle colors a language: our hue for it, else linguist's, adapted 372// to the terminal. A language with neither steps back like other metadata. 373func languageStyle (name ,linguist string ) lipgloss.Style { 374c := parseHex (cmp .Or (languageHues [name ],linguist )) 375if c == nil { 376return DefaultStyles .Muted 377 } 378if bg := parseHex (transcriptInk .bg );bg != nil { 379* c = legible (* c ,* bg ,languageLc ) 380 } 381return lipgloss .NewStyle ().Foreground (lipgloss .Color (c .hex ())) 382} 383 384// identityHues tell things apart that have no meaning of their own to show: 385// agents in the orchestrator view, hosts beside their paths. 386var identityHues = []string {"#e08cf5" ,"#6fd1c4" ,"#f28fb8" ,"#f0c674" ,"#b6e37a" ,"#b9a6f5" ,"#80aaf9" ,"#f5a97f" } 387 388// hostStyle colors a host label the same way every time, legible on the 389// terminal's background like a language's color. 390func hostStyle (host string ) lipgloss.Style { 391h := fnv .New32a () 392h .Write ([]byte (host )) 393c := parseHex (identityHues [h .Sum32 ()% uint32 (len (identityHues ))]) 394if bg := parseHex (transcriptInk .bg );bg != nil { 395* c = legible (* c ,* bg ,languageLc ) 396 } 397return lipgloss .NewStyle ().Foreground (lipgloss .Color (c .hex ())) 398} 399 400// agentPalette colors the orchestrator view. Each agent keeps one identity 401// hue wherever it appears; structure and quiet text follow the terminal's 402// own ink like every other screen. 403type agentPalette struct { 404hues []rgb 405you ,faint ,decor ,hi rgb 406} 407 408func agentColors ()agentPalette { 409hex := func (val ,fallback string )rgb { 410if c := parseHex (val );c != nil { 411return * c 412 } 413return * parseHex (fallback ) 414 } 415hues := make ([]rgb ,len (identityHues )) 416for i ,h := range identityHues { 417hues [i ]= * parseHex (h ) 418 } 419return agentPalette { 420hues :hues , 421you :* parseHex ("#7fd8e6" ), 422* parseHex ("#f0c674" ), 423faint :hex (transcriptInk .secondary ,"#6a6378" ), 424decor :hex (transcriptInk .decor ,"#3a3448" ), 425hi :* parseHex ("#ffffff" ), 426 } 427}