okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: show remote paths relative to the host's home, as scp reads themd7df0e6

main
14.2 KiB427 linesraw
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.
33	Muted lipgloss.Style
34	// Faint is text read at a glance: tool rows, timings, hints.
35	Faint lipgloss.Style
36	// Decor is structure seen, not read: rules, separators, markers.
37	Decor lipgloss.Style
38	Bold  lipgloss.Style
39
40	You   lipgloss.Style
41	Agent lipgloss.Style
42	Busy  lipgloss.Style
43
44	Error   lipgloss.Style
45	Warning lipgloss.Style
46	Success lipgloss.Style
47
48	// Prompt marks where you type.
49	Prompt 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.
52	Selected lipgloss.Style
53	// Effort tiers form a cool-to-warm scale; max is violet.
54	EffortLow    lipgloss.Style
55	EffortMedium lipgloss.Style
56	EffortHigh   lipgloss.Style
57	EffortXHigh  lipgloss.Style
58	EffortMax    lipgloss.Style
59	// Cursor is the text cursor and a drag selection.
60	Cursor lipgloss.Style
61}
62
63var DefaultStyles = Styles{
64	Muted:        lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
65	Faint:        lipgloss.NewStyle().Foreground(lipgloss.Color("8")),
66	Decor:        lipgloss.NewStyle().Foreground(lipgloss.Color("8")),
67	Bold:         lipgloss.NewStyle().Bold(true),
68	You:          lipgloss.NewStyle().Foreground(lipgloss.Color("6")),
69	Agent:        lipgloss.NewStyle().Foreground(lipgloss.Color("5")),
70	Busy:         lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Faint(true),
71	Error:        lipgloss.NewStyle().Foreground(lipgloss.Color("1")),
72	Warning:      lipgloss.NewStyle().Foreground(lipgloss.Color("3")),
73	Success:      lipgloss.NewStyle().Foreground(lipgloss.Color("2")),
74	Prompt:       lipgloss.NewStyle().Foreground(lipgloss.Color("6")),
75	Selected:     lipgloss.NewStyle().Reverse(true),
76	EffortLow:    lipgloss.NewStyle().Foreground(lipgloss.Color("#8DCFF5")),
77	EffortMedium: lipgloss.NewStyle().Foreground(lipgloss.Color("#78CFC3")),
78	EffortHigh:   lipgloss.NewStyle().Foreground(lipgloss.Color("#F3AD68")),
79	EffortXHigh:  lipgloss.NewStyle().Foreground(lipgloss.Color("#F27979")),
80	EffortMax:    lipgloss.NewStyle().Foreground(lipgloss.Color("#C39AF5")),
81	Cursor:       lipgloss.NewStyle().Reverse(true),
82}
83
84func useInk(detected ink) {
85	transcriptInk = detected
86	fg := func(hex string) lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color(hex)) }
87	DefaultStyles.Muted = fg(detected.code)
88	DefaultStyles.Faint = fg(detected.secondary)
89	DefaultStyles.Decor = fg(detected.decor)
90	if detected.busy != "" {
91		DefaultStyles.Busy = fg(detected.busy)
92	}
93	DefaultStyles.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 {
100	ti := textinput.New()
101	s := ti.Styles()
102	s.Focused.Prompt, s.Focused.Text = lipgloss.NewStyle(), lipgloss.NewStyle()
103	s.Blurred = s.Focused
104	s.Cursor = textinput.CursorStyle{Shape: tea.CursorBlock}
105	ti.SetStyles(s)
106	return ti
107}
108
109const (
110	ansiReset  = "\x1b[0m"
111	ansiItalic = "\x1b[3m"
112	ansiGray   = "\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 {
120	if parseHex(hex) == nil {
121		return fallback
122	}
123	if background {
124		return ansi.NewStyle().BackgroundColor(lipgloss.Color(hex)).String()
125	}
126	return 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 {
149	return 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 {
155	from, to := parseHex(transcriptInk.brandFrom), parseHex(transcriptInk.brandTo)
156	if from == nil || to == nil {
157		return nil
158	}
159	return lipgloss.Color(from.mix(*to, min(1, max(0, t))).hex())
160}
161
162func parseHex(hex string) *rgb {
163	var r, g, b int
164	if _, err := fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b); err != nil {
165		return nil
166	}
167	return &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 {
173	if gradient(0) == nil {
174		return DefaultStyles.Agent.Bold(bold).Render(s)
175	}
176	return ramp(rampKey{text: s, bold: bold}, func() string {
177		runes := []rune(s)
178		var b strings.Builder
179		st := lipgloss.NewStyle().Bold(bold)
180		for i, r := range runes {
181			t := float64(i) / float64(max(1, len(runes)-1))
182			b.WriteString(st.Foreground(gradient(t)).Render(string(r)))
183		}
184		return 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 {
191	text string
192	bold bool
193	rule 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 {
199	byKey map[rampKey]string
200	ink   ink
201	sync.Mutex
202}
203
204func ramp(key rampKey, render func() string) string {
205	ramps.Lock()
206	defer ramps.Unlock()
207	if ramps.byKey == nil || ramps.ink != transcriptInk || len(ramps.byKey) > 256 {
208		ramps.ink, ramps.byKey = transcriptInk, map[rampKey]string{}
209	}
210	s, ok := ramps.byKey[key]
211	if !ok {
212		s = render()
213		ramps.byKey[key] = s
214	}
215	return s
216}
217
218// brandInk colors a mark along the brand ramp, or in the agent color.
219func brandInk(t float64) lipgloss.Style {
220	if c := gradient(t); c != nil {
221		return lipgloss.NewStyle().Foreground(c)
222	}
223	return 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 {
231	if right != "" {
232		right = " " + right
233	}
234	n := width - ansi.StringWidth(left) - ansi.StringWidth(right) - 1
235	if n < 1 {
236		return left
237	}
238	return 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 {
243	var rule strings.Builder
244	from, to := parseHex(transcriptInk.brandTo), parseHex(transcriptInk.decor)
245	for i := range n {
246		t := float64(i) / float64(n)
247		if from != nil && to != nil && t < 0.35 {
248			rule.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(from.mix(*to, t/0.35).hex())).Render("─"))
249			continue
250		}
251		rule.WriteString(DefaultStyles.Decor.Render("─"))
252	}
253	return rule.String()
254}
255
256// located is the brand at a place: "✦ albedo on ~/path".
257func located(name, place string) string {
258	if place == "" {
259		return brand(name)
260	}
261	return 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 {
270	parts := make([]string, len(hints))
271	for i, h := range hints {
272		switch {
273		case h.key == "":
274			parts[i] = DefaultStyles.Faint.Render(h.does)
275		case h.does == "":
276			parts[i] = DefaultStyles.Muted.Render(h.key)
277		default:
278			parts[i] = DefaultStyles.Muted.Render(h.key) + " " + DefaultStyles.Faint.Render(h.does)
279		}
280	}
281	return 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 {
286	head := " " + label + " "
287	tail := fmt.Sprintf("%d ", count)
288	rule := strings.Repeat("─", max(0, width-2-ansi.StringWidth(head+tail)))
289	return 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 {
304	marked := DefaultStyles.Selected.Render("\x00")
305	open := marked[:strings.IndexByte(marked, 0)]
306	if pad := width - ansi.StringWidth(line); pad > 0 {
307		line += strings.Repeat(" ", pad)
308	}
309	return 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 {
314	home, _ := os.UserHomeDir()
315	return 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 {
321	sep := string(filepath.Separator)
322	parts := strings.Split(p, sep)
323	folds := []string{p}
324	for keep := len(parts) - 2; keep >= 1; keep-- {
325		folds = append(folds, strings.Join(append([]string{parts[0], "…"}, parts[len(parts)-keep:]...), sep))
326	}
327	return folds
328}
329
330// underHome shortens p under home to start with ~.
331func underHome(p, home string) string {
332	rest, ok := strings.CutPrefix(p, home)
333	if home == "" || !ok || rest != "" && rest[0] != filepath.Separator {
334		return p
335	}
336	return "~" + 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 {
342	if rest, ok := strings.CutPrefix(p, "~/"); ok {
343		return rest
344	}
345	return 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 {
374	c := parseHex(cmp.Or(languageHues[name], linguist))
375	if c == nil {
376		return DefaultStyles.Muted
377	}
378	if bg := parseHex(transcriptInk.bg); bg != nil {
379		*c = legible(*c, *bg, languageLc)
380	}
381	return 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 {
391	h := fnv.New32a()
392	h.Write([]byte(host))
393	c := parseHex(identityHues[h.Sum32()%uint32(len(identityHues))])
394	if bg := parseHex(transcriptInk.bg); bg != nil {
395		*c = legible(*c, *bg, languageLc)
396	}
397	return 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 {
404	hues                        []rgb
405	you, mail, faint, decor, hi rgb
406}
407
408func agentColors() agentPalette {
409	hex := func(val, fallback string) rgb {
410		if c := parseHex(val); c != nil {
411			return *c
412		}
413		return *parseHex(fallback)
414	}
415	hues := make([]rgb, len(identityHues))
416	for i, h := range identityHues {
417		hues[i] = *parseHex(h)
418	}
419	return agentPalette{
420		hues:  hues,
421		you:   *parseHex("#7fd8e6"),
422		mail:  *parseHex("#f0c674"),
423		faint: hex(transcriptInk.secondary, "#6a6378"),
424		decor: hex(transcriptInk.decor, "#3a3448"),
425		hi:    *parseHex("#ffffff"),
426	}
427}