package 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) } // 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", "#f28fb8", "#f0c674", "#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"), } }