From 9eea27619ca220152eb99cb7bedf7b91e94deb85 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 30 May 2026 01:03:34 +0200 Subject: [PATCH] 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. --- .../coding-agent/src/modes/theme/theme.ts | 7 ++ packages/tui/CHANGELOG.md | 4 + packages/tui/src/components/markdown.ts | 82 ++++++++++++++++++- packages/tui/src/symbols.ts | 2 + packages/tui/test/markdown.test.ts | 49 +++++++++++ 5 files changed, 141 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 1a4a2c39f..ebf02308b 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -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"), }; } diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 07bf15a15..6d8d3a90e 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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. diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 65360b4de..19e8a0297 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -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 = /(? 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); diff --git a/packages/tui/src/symbols.ts b/packages/tui/src/symbols.ts index d118a2190..b0532fddd 100644 --- a/packages/tui/src/symbols.ts +++ b/packages/tui/src/symbols.ts @@ -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[]; } diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 054f953a8..2aa9dc4bf 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -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