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:
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user