Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433package tui
import ( "cmp" "fmt" "hash/fnv" "image/color" "os" "path/filepath" "strings" "sync"
"charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi")
// Styles defines the shared terminal palette. Screens compose these// styles and components, so the transcript, the session screen, and the// pickers cannot drift into their own palettes.//// Tokens come in three kinds:// - ink, a brightness ramp mixed from the terminal's own colors: prose// keeps the terminal foreground, then Muted, Faint and Decor step down// - identity: You and Agent, and Busy for albedo working in tools// - state: Error, Warning and Success//// Without reported terminal colors every token falls back to a palette slot.type Styles struct { // Muted is text worth reading that steps back from prose: keys, // metadata, section labels. Muted lipgloss.Style // Faint is text read at a glance: tool rows, timings, hints. Faint lipgloss.Style // Decor is structure seen, not read: rules, separators, markers. Decor lipgloss.Style Bold lipgloss.Style
You lipgloss.Style Agent lipgloss.Style Busy lipgloss.Style
Error lipgloss.Style Warning lipgloss.Style Success lipgloss.Style
// Prompt marks where you type. Prompt lipgloss.Style // Selected is the surface under a selected row: a tint where the // terminal reported its colors, reverse video where it did not. Selected lipgloss.Style // Effort tiers form a cool-to-warm scale; max is violet. EffortLow lipgloss.Style EffortMedium lipgloss.Style EffortHigh lipgloss.Style EffortXHigh lipgloss.Style EffortMax lipgloss.Style // Cursor is the text cursor and a drag selection. Cursor lipgloss.Style}
var DefaultStyles = Styles{ Muted: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), Faint: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), Decor: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), Bold: lipgloss.NewStyle().Bold(true), You: lipgloss.NewStyle().Foreground(lipgloss.Color("6")), Agent: lipgloss.NewStyle().Foreground(lipgloss.Color("5")), Busy: lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Faint(true), Error: lipgloss.NewStyle().Foreground(lipgloss.Color("1")), Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("3")), Success: lipgloss.NewStyle().Foreground(lipgloss.Color("2")), Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("6")), Selected: lipgloss.NewStyle().Reverse(true), EffortLow: lipgloss.NewStyle().Foreground(lipgloss.Color("#8DCFF5")), EffortMedium: lipgloss.NewStyle().Foreground(lipgloss.Color("#78CFC3")), EffortHigh: lipgloss.NewStyle().Foreground(lipgloss.Color("#F3AD68")), EffortXHigh: lipgloss.NewStyle().Foreground(lipgloss.Color("#F27979")), EffortMax: lipgloss.NewStyle().Foreground(lipgloss.Color("#C39AF5")), Cursor: lipgloss.NewStyle().Reverse(true),}
func useInk(detected ink) { transcriptInk = detected fg := func(hex string) lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color(hex)) } DefaultStyles.Muted = fg(detected.code) DefaultStyles.Faint = fg(detected.secondary) DefaultStyles.Decor = fg(detected.decor) if detected.busy != "" { DefaultStyles.Busy = fg(detected.busy) } DefaultStyles.Selected = lipgloss.NewStyle().Background(lipgloss.Color(detected.surface))}
// newTextInput is a text input in the theme: plain text and prompt whether// focused or not, and a reverse-video cursor that holds still, so an idle// input never wakes the program to blink.func newTextInput() textinput.Model { ti := textinput.New() s := ti.Styles() s.Focused.Prompt, s.Focused.Text = lipgloss.NewStyle(), lipgloss.NewStyle() s.Blurred = s.Focused s.Cursor = textinput.CursorStyle{Shape: tea.CursorBlock} ti.SetStyles(s) return ti}
const ( ansiReset = "\x1b[0m" ansiItalic = "\x1b[3m" ansiGray = "\x1b[90m")
// sgr is a foreground color as an escape sequence, or fallback when the// ramp is unknown. Bubble Tea downsamples it to the terminal's profile.func sgr(hex, fallback string) string { return sgrLayer(hex, fallback, false) }
func sgrLayer(hex, fallback string, background bool) string { if parseHex(hex) == nil { return fallback } if background { return ansi.NewStyle().BackgroundColor(lipgloss.Color(hex)).String() } return ansi.NewStyle().ForegroundColor(lipgloss.Color(hex)).String()}
// codeInk steps code down from prose so a reply that decorates every// identifier stays quiet.func codeInk() string { return sgr(transcriptInk.code, "\x1b[37m") }
// faintInk is the Faint token as a raw sequence, for markdown's own spans.func faintInk() string { return sgr(transcriptInk.secondary, ansiGray) }
// decorInk is the Decor token as a raw sequence.func decorInk() string { return sgr(transcriptInk.decor, ansiGray) }
// Diff rows sit on tints mixed toward the palette's red and green. The// fallbacks are dark tints, the only case without reported colors.func diffPanel() string { return sgrLayer(transcriptInk.surface, "\x1b[48;2;37;40;50m", true) }func diffAdded() string { return sgrLayer(transcriptInk.added, "\x1b[48;2;24;53;39m", true) }func diffRemoved() string { return sgrLayer(transcriptInk.removed, "\x1b[48;2;59;35;40m", true) }
// keepBackground turns full resets into foreground and attribute resets, so// a styled span inside a tinted row keeps the row's background. Lip Gloss// spells a full reset "ESC[m"; the raw ink spells it "ESC[0m".func keepBackground(s string) string { return strings.NewReplacer(ansiReset, "\x1b[39;22;23m", "\x1b[m", "\x1b[39;22;23m").Replace(s)}
// gradient is the brand ramp at t in [0, 1], or nil when the terminal did// not report the colors it is mixed from.func gradient(t float64) color.Color { from, to := parseHex(transcriptInk.brandFrom), parseHex(transcriptInk.brandTo) if from == nil || to == nil { return nil } return lipgloss.Color(from.mix(*to, min(1, max(0, t))).hex())}
func parseHex(hex string) *rgb { var r, g, b int if _, err := fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b); err != nil { return nil } return &rgb{float64(r) / 255, float64(g) / 255, float64(b) / 255}}
// gradientText sweeps the brand ramp across s. Without it s takes the agent// color.func gradientText(s string, bold bool) string { if gradient(0) == nil { return DefaultStyles.Agent.Bold(bold).Render(s) } return ramp(rampKey{text: s, bold: bold}, func() string { runes := []rune(s) var b strings.Builder st := lipgloss.NewStyle().Bold(bold) for i, r := range runes { t := float64(i) / float64(max(1, len(runes)-1)) b.WriteString(st.Foreground(gradient(t)).Render(string(r))) } return b.String() })}
// rampKey names a rendering of the brand ramp: text swept by it, or a rule// of rule cells fading from it.type rampKey struct { text string bold bool rule int}
// ramps keeps renderings of the brand ramp. They change only with the// detected ink, and every frame's header asks for the same few again.var ramps struct { byKey map[rampKey]string ink ink sync.Mutex}
func ramp(key rampKey, render func() string) string { ramps.Lock() defer ramps.Unlock() if ramps.byKey == nil || ramps.ink != transcriptInk || len(ramps.byKey) > 256 { ramps.ink, ramps.byKey = transcriptInk, map[rampKey]string{} } s, ok := ramps.byKey[key] if !ok { s = render() ramps.byKey[key] = s } return s}
// brandInk colors a mark along the brand ramp, or in the agent color.func brandInk(t float64) lipgloss.Style { if c := gradient(t); c != nil { return lipgloss.NewStyle().Foreground(c) } return DefaultStyles.Agent}
func brand(name string) string { return gradientText("✦ "+name, true) }
// titleRule heads a screen: left, a rule that fades from the brand's end// into Decor, then right. Without room for one rule cell it is left alone.func titleRule(width int, left, right string) string { if right != "" { right = " " + right } n := width - ansi.StringWidth(left) - ansi.StringWidth(right) - 1 if n < 1 { return left } return left + " " + ramp(rampKey{rule: n}, func() string { return fadeRule(n) }) + right}
// fadeRule is n rule cells fading from the brand's end into Decor.func fadeRule(n int) string { var rule strings.Builder from, to := parseHex(transcriptInk.brandTo), parseHex(transcriptInk.decor) for i := range n { t := float64(i) / float64(n) if from != nil && to != nil && t < 0.35 { rule.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(from.mix(*to, t/0.35).hex())).Render("─")) continue } rule.WriteString(DefaultStyles.Decor.Render("─")) } return rule.String()}
// located is the brand at a place: "✦ albedo on ~/path".func located(name, place string) string { if place == "" { return brand(name) } return brand(name) + DefaultStyles.Faint.Render(" on ") + DefaultStyles.Muted.Render(place)}
// hint is a key and what it does.type hint struct{ key, does string }
// keyHints lists keys in Muted and what they do in Faint. A hint without a// key is a plain note.func keyHints(hints ...hint) string { parts := make([]string, len(hints)) for i, h := range hints { switch { case h.key == "": parts[i] = DefaultStyles.Faint.Render(h.does) case h.does == "": parts[i] = DefaultStyles.Muted.Render(h.key) default: parts[i] = DefaultStyles.Muted.Render(h.key) + " " + DefaultStyles.Faint.Render(h.does) } } return strings.Join(parts, DefaultStyles.Decor.Render(" · "))}
// sectionRule heads a group in a list: " ─ label count ────".func sectionRule(label string, count, width int) string { head := " " + label + " " tail := fmt.Sprintf("%d ", count) rule := strings.Repeat("─", max(0, width-2-ansi.StringWidth(head+tail))) return DefaultStyles.Decor.Render(" ─") + DefaultStyles.Muted.Bold(true).Render(head) + DefaultStyles.Decor.Render(tail+rule)}
// groupHeading heads a group of sections in a list, above their rules.func groupHeading(label string, width int) string { return DefaultStyles.Bold.Render(ansi.Truncate(" "+label, width, "…"))}
// promptMark is where you type.const promptMark = "› "const promptMarkWidth = 2
func promptLead() string { return DefaultStyles.Prompt.Render(promptMark) }
// selectBar marks the selected row.func selectBar() string { return DefaultStyles.Agent.Render("▌") }
// selectedLine lays the selection surface under a styled line, reopening it// after every reset inside the line, and fills it out to width.func selectedLine(line string, width int) string { marked := DefaultStyles.Selected.Render("\x00") open := marked[:strings.IndexByte(marked, 0)] if pad := width - ansi.StringWidth(line); pad > 0 { line += strings.Repeat(" ", pad) } return open + strings.NewReplacer(ansiReset, ansiReset+open, "\x1b[m", "\x1b[m"+open).Replace(line) + ansiReset}
// homePath shortens a path under your home directory to start with ~.func homePath(p string) string { home, _ := os.UserHomeDir() return underHome(p, home)}
// pathFolds shortens a path by folding its middle into "…" one more segment// at a time, keeping its root and its name: ~/a/b/c, ~/…/b/c, ~/…/c.func pathFolds(p string) []string { sep := string(filepath.Separator) parts := strings.Split(p, sep) folds := []string{p} for keep := len(parts) - 2; keep >= 1; keep-- { folds = append(folds, strings.Join(append([]string{parts[0], "…"}, parts[len(parts)-keep:]...), sep)) } return folds}
// underHome shortens p under home to start with ~.func underHome(p, home string) string { rest, ok := strings.CutPrefix(p, home) if home == "" || !ok || rest != "" && rest[0] != filepath.Separator { return p } return "~" + rest}
// scpRelative is a remote path after its host's colon as scp reads it:// one under ~ relative to the home, which itself stays ~.func scpRelative(p string) string { if rest, ok := strings.CutPrefix(p, "~/"); ok { return rest } return p}
// languageHues are our own colors for common languages, by linguist name.// Each starts from linguist's hue, softened to a pastel and nudged so// languages that often share a repository stay apart.var languageHues = map[string]string{ "Go": "#6fd1c4", "Nix": "#b9a6f5", "TypeScript": "#80aaf9", "Gleam": "#f28fb8", "Erlang": "#ef7a85", "Python": "#f0c674", "Markdown": "#b6e37a", "Rust": "#f5a97f", "JavaScript": "#e9e48a", "Shell": "#8fdc9a", "C": "#a3b4c8", "Elixir": "#d4a0d8", "Java": "#dcb68a",}
// languageLc is the contrast a language's color keeps with the background:// file names are read, so about what content text gets.const languageLc = 60
// languageStyle colors a language: our hue for it, else linguist's, adapted// to the terminal. A language with neither steps back like other metadata.func languageStyle(name, linguist string) lipgloss.Style { c := parseHex(cmp.Or(languageHues[name], linguist)) if c == nil { return DefaultStyles.Muted } if bg := parseHex(transcriptInk.bg); bg != nil { *c = legible(*c, *bg, languageLc) } return lipgloss.NewStyle().Foreground(lipgloss.Color(c.hex()))}
// identityHues tell things apart that have no meaning of their own to show:// agents in the orchestrator view, hosts beside their paths.var identityHues = []string{"#e08cf5", "#6fd1c4", "#b6e37a", "#b9a6f5", "#80aaf9", "#f5a97f"}
// hostStyle colors a host label the same way every time, legible on the// terminal's background like a language's color.func hostStyle(host string) lipgloss.Style { h := fnv.New32a() h.Write([]byte(host)) c := parseHex(identityHues[h.Sum32()%uint32(len(identityHues))]) if bg := parseHex(transcriptInk.bg); bg != nil { *c = legible(*c, *bg, languageLc) } return lipgloss.NewStyle().Foreground(lipgloss.Color(c.hex()))}
// agentPalette colors the orchestrator view. Each agent keeps one identity// hue wherever it appears; structure and quiet text follow the terminal's// own ink like every other screen.type agentPalette struct { hues []rgb you, mail, faint, decor, hi rgb}
func agentColors() agentPalette { hex := func(val, fallback string) rgb { if c := parseHex(val); c != nil { return *c } return *parseHex(fallback) } hues := make([]rgb, len(identityHues)) for i, h := range identityHues { hues[i] = *parseHex(h) } return agentPalette{ hues: hues, you: *parseHex("#7fd8e6"), mail: *parseHex("#f0c674"), faint: hex(transcriptInk.secondary, "#6a6378"), decor: hex(transcriptInk.decor, "#3a3448"), hi: *parseHex("#ffffff"), }}