feat(tui): added inline color swatches for markdown hex color mentions

- Enhanced markdown rendering to insert a painted theme swatch before matching inline hex colors in prose and code spans.
- Skipped short numeric-only 3- and 4-digit values in plain text so issue-like references do not get swatches.
- Added `colorSwatch` to symbol themes and added tests for swatch rendering and surrounding-style preservation.
This commit is contained in:
can1357
2026-05-30 01:03:34 +02:00
parent 41796589a6
commit 9eea27619c
5 changed files with 141 additions and 3 deletions
@@ -144,6 +144,7 @@ export type SymbolKey =
| "md.quoteBorder"
| "md.hrChar"
| "md.bullet"
| "md.colorSwatch"
// Language/file type icons
| "lang.default"
| "lang.typescript"
@@ -308,6 +309,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
"md.quoteBorder": "▏",
"md.hrChar": "─",
"md.bullet": "•",
"md.colorSwatch": "■",
// Language/file icons (emoji-centric, no Nerd Font required)
"lang.default": "⌘",
"lang.typescript": "🟦",
@@ -568,6 +570,8 @@ const NERD_SYMBOLS: SymbolMap = {
"md.hrChar": "─",
// pick:  | alt:  •
"md.bullet": "\uf111",
// pick: ■ | alt: (U+F096)
"md.colorSwatch": "■",
// Language icons (nerd font devicons)
"lang.default": "",
"lang.typescript": "\u{E628}",
@@ -730,6 +734,7 @@ const ASCII_SYMBOLS: SymbolMap = {
"md.quoteBorder": "|",
"md.hrChar": "-",
"md.bullet": "*",
"md.colorSwatch": "[]",
// Language icons (ASCII uses abbreviations)
"lang.default": "code",
"lang.typescript": "ts",
@@ -1519,6 +1524,7 @@ export class Theme {
quoteBorder: this.#symbols["md.quoteBorder"],
hrChar: this.#symbols["md.hrChar"],
bullet: this.#symbols["md.bullet"],
colorSwatch: this.#symbols["md.colorSwatch"],
};
}
@@ -2340,6 +2346,7 @@ export function getSymbolTheme(): SymbolTheme {
table: theme.boxSharp,
quoteBorder: theme.md.quoteBorder,
hrChar: theme.md.hrChar,
colorSwatch: theme.md.colorSwatch,
spinnerFrames: theme.getSpinnerFrames("activity"),
};
}
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- `Markdown` now renders a small color-chip swatch, painted with the referenced color, in front of CSS hex colors mentioned in prose, thinking traces, lists, tables, and blockquotes (e.g. `#C5FFD6` or `` `#C5FFD6` ``). The chip glyph comes from the theme's symbol set so it degrades across tiers (Nerd Font / Unicode `■` → ASCII `[]`) and is overridable via the `md.colorSwatch` symbol. Truecolor terminals get an exact 24-bit chip; others fall back to the nearest 256-color cell. Bare prose requires a hex letter for 3/4-digit forms so short issue/PR references (`#123`, `#1011`) don't sprout swatches; backticked codes are always treated as colors.
### Fixed
- Fixed the terminal hardware cursor disappearing in Ghostty. `resolveHardwareCursorPreference` force-hid the hardware cursor whenever it detected a Ghostty session (to fight bar-cursor afterimage "trails"), but the editor was simultaneously kept in terminal-cursor (marker-only) mode via `getUseTerminalCursorMarker()`, which renders no glyph and relies on the now-hidden hardware cursor — so Ghostty users had no visible caret at all, regardless of `PI_HARDWARE_CURSOR`. The Ghostty/`PI_FORCE_HARDWARE_CURSOR` override and the redundant `useTerminalCursorMarker` state are removed: `showHardwareCursor` is honored as-requested again (hardware cursor on by default), and disabling it cleanly falls back to the steady software-cursor glyph. The per-paint anti-trail mitigations (hide-cursor + autowrap-off inside the synchronized-output block) are retained, which is the actual trail fix.
+79 -3
View File
@@ -130,6 +130,80 @@ function formatHyperlink(text: string, target: string): string {
return `\x1b]8;;${safeTarget}\x07${text}\x1b]8;;\x07`;
}
// ---------------------------------------------------------------------------
// Inline hex-color swatches
// ---------------------------------------------------------------------------
// When prose/thinking mentions a CSS hex color (e.g. #C5FFD6 or `#C5FFD6`),
// render a small chip painted with that color just before the code. The chip
// glyph comes from the theme's symbol set (ASCII → Unicode → Nerd Font), so it
// degrades gracefully; the color itself is exact 24-bit on truecolor terminals
// and the nearest 256-color cell otherwise (Bun.color quantizes for us).
/** Fallback chip when the theme supplies no `colorSwatch` symbol (Unicode default). */
const DEFAULT_COLOR_SWATCH_GLYPH = "■";
// `#` + 3-8 hex digits, not glued to a surrounding word/`#`/`&` (avoids HTML
// entities like ☃ and paths like foo#fff) and not trailed by more hex
// (so over-long runs never produce a misleading swatch). Length/letter rules
// are enforced in classifyHexColor since the alternation can't express "exactly
// 3, 4, 6, or 8".
const HEX_COLOR_REGEX = /(?<![\w#&])#([0-9a-fA-F]{3,8})(?![0-9a-fA-F])/g;
const HEX_COLOR_EXACT_REGEX = /^#([0-9a-fA-F]{3,8})$/;
/**
* Decide whether a run of hex digits denotes a renderable CSS color.
*
* Only the canonical CSS lengths (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) qualify. In
* `strict` mode (bare prose) a 3/4-digit run must contain a hex letter, so the
* far more common short issue/PR references (#123, #1011) don't sprout swatches.
* Codespans opt out of strictness — the backticks already signal "this is a color".
*/
function classifyHexColor(hex: string, strict: boolean): boolean {
const n = hex.length;
if (n !== 3 && n !== 4 && n !== 6 && n !== 8) return false;
if (strict && n <= 4 && !/[a-fA-F]/.test(hex)) return false;
return true;
}
/** ANSI-painted `glyph` for `#${hex}`, or "" when the color can't be encoded. */
function colorSwatch(hex: string, glyph: string): string {
const ansi = Bun.color(`#${hex}`, TERMINAL.trueColor ? "ansi-16m" : "ansi-256");
// Reset only the foreground (\x1b[39m) so an enclosing background/decoration
// applied later by the line renderer survives across the swatch.
return ansi ? `${ansi}${glyph}\x1b[39m ` : "";
}
/**
* Style a plain-text run, inserting a color swatch before each hex color it
* mentions. Non-color text (including the matched `#hex` itself) is routed
* through `applySegment` so the caller's base styling is preserved verbatim.
*/
function renderTextWithSwatches(text: string, applySegment: (t: string) => string, glyph: string): string {
HEX_COLOR_REGEX.lastIndex = 0;
let result = "";
let last = 0;
for (;;) {
const match = HEX_COLOR_REGEX.exec(text);
if (match === null) break;
if (!classifyHexColor(match[1], true)) continue;
const swatch = colorSwatch(match[1], glyph);
if (!swatch) continue;
if (match.index > last) result += applySegment(text.slice(last, match.index));
result += swatch + applySegment(match[0]);
last = match.index + match[0].length;
}
if (last === 0) return applySegment(text);
if (last < text.length) result += applySegment(text.slice(last));
return result;
}
/** Swatch for a codespan whose entire content is a single hex color, else "". */
function codespanSwatch(code: string, glyph: string): string {
const match = HEX_COLOR_EXACT_REGEX.exec(code.trim());
if (!match || !classifyHexColor(match[1], false)) return "";
return colorSwatch(match[1], glyph);
}
export class Markdown implements Component {
#text: string;
#paddingX: number; // Left/right padding
@@ -543,6 +617,7 @@ export class Markdown implements Component {
const segments: string[] = text.split("\n");
return segments.map((segment: string) => applyText(segment)).join("\n");
};
const swatchGlyph = this.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH;
for (const token of tokens) {
switch (token.type) {
@@ -551,7 +626,7 @@ export class Markdown implements Component {
if (token.tokens && token.tokens.length > 0) {
result += this.#renderInlineTokens(token.tokens, resolvedStyleContext);
} else {
result += applyTextWithNewlines(token.text);
result += renderTextWithSwatches(token.text, applyTextWithNewlines, swatchGlyph);
}
break;
@@ -572,9 +647,10 @@ export class Markdown implements Component {
break;
}
case "codespan":
result += this.#theme.code(token.text) + stylePrefix;
case "codespan": {
result += codespanSwatch(token.text, swatchGlyph) + this.#theme.code(token.text) + stylePrefix;
break;
}
case "link": {
const linkText = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
+2
View File
@@ -20,5 +20,7 @@ export interface SymbolTheme {
table: BoxSymbols;
quoteBorder: string;
hrChar: string;
/** Chip glyph drawn (painted with the referenced color) before inline hex colors. */
colorSwatch?: string;
spinnerFrames: string[];
}
+49
View File
@@ -1168,6 +1168,55 @@ bar`,
});
});
describe("Inline color swatches", () => {
const FMT = TERMINAL.trueColor ? "ansi-16m" : "ansi-256";
// defaultMarkdownTheme supplies no `colorSwatch` symbol, so the renderer uses its ■ default.
const swatchFor = (hex: string, glyph = "■"): string => `${Bun.color(`#${hex}`, FMT)}${glyph}`;
it("paints a colored swatch before a bare hex color in prose", () => {
const out = new Markdown("Accent is #C5FFD6 today.", 0, 0, defaultMarkdownTheme).render(80).join("\n");
// Swatch (color SGR + chip glyph + fg reset + space) sits immediately before the code.
expect(out.includes(`${swatchFor("C5FFD6")}\x1b[39m `)).toBeTruthy();
expect(out.includes("#C5FFD6")).toBeTruthy();
});
it("paints a swatch before a backticked hex color", () => {
const out = new Markdown("Use `#C5FFD6` for the bg.", 0, 0, defaultMarkdownTheme).render(80).join("\n");
expect(out.includes(swatchFor("C5FFD6"))).toBeTruthy();
// The code text survives as inline code (theme styles it yellow).
expect(out.includes("#C5FFD6")).toBeTruthy();
});
it("does not swatch short numeric references that resemble issue numbers", () => {
const out = new Markdown("Fixed #1011, see #123, dark #000.", 0, 0, defaultMarkdownTheme).render(80).join("");
expect(out.includes("■")).toBe(false);
});
it("swatches a 3-digit shorthand that contains a hex letter", () => {
const out = new Markdown("White is #fff.", 0, 0, defaultMarkdownTheme).render(80).join("\n");
expect(out.includes(swatchFor("fff"))).toBeTruthy();
});
it("uses the theme's colorSwatch symbol when provided", () => {
const themed = { ...defaultMarkdownTheme, symbols: { ...defaultMarkdownTheme.symbols, colorSwatch: "▢" } };
const out = new Markdown("Accent #C5FFD6.", 0, 0, themed).render(80).join("\n");
expect(out.includes(swatchFor("C5FFD6", "▢"))).toBeTruthy();
expect(out.includes(swatchFor("C5FFD6", "■"))).toBe(false);
});
it("re-applies the surrounding style after the swatch in thinking traces", () => {
const out = new Markdown("Picked #C5FFD6 for accent.", 1, 0, defaultMarkdownTheme, {
color: text => chalk.gray(text),
italic: true,
})
.render(80)
.join("\n");
expect(out.includes(swatchFor("C5FFD6"))).toBeTruthy();
// Gray (\x1b[90m) is re-opened for the code text — the swatch's fg reset must not bleed.
expect(out.includes("\x1b[90m#C5FFD6")).toBeTruthy();
});
});
describe("Module-level LRU render cache", () => {
it("invokes highlightCode only once for two distinct instances with identical (text, width, theme)", () => {
// Build a theme with a spy on highlightCode. The theme object reference