Files
oh-my-pi/packages/coding-agent/src/modes/gradient-highlight.ts
T
can1357 c637db9741 feat(coding-agent): added markdown-aware magic-keyword flow for 3 modes
- Added `highlightMagicKeywords(text: string, resetTo?: string): string` and chained ultrathink, orchestrate, workflow glow.
- Updated ultrathink, orchestrate, and workflow matching to lowercase whitespace-delimited patterns with prose-only checks.
- Added `maskNonProse` and `keywordInProse` to skip fenced/inline code and HTML/XML segments while highlighting keywords.
- Added `KeywordHighlighter` with optional `resetTo`, and updated gradient highlighting to use masked match slicing.
2026-05-31 09:47:24 +02:00

88 lines
3.3 KiB
TypeScript

import { maskNonProse } from "./markdown-prose";
import { theme } from "./theme/theme";
/** A gradient keyword highlighter. `resetTo` is the SGR foreground sequence
* re-emitted after each painted keyword so surrounding text keeps its color;
* it defaults to a plain foreground reset (editor / default-colored text). */
export type KeywordHighlighter = (text: string, resetTo?: string) => string;
const FG_RESET = "\x1b[39m";
/** Declarative spec for {@link createGradientHighlighter}. */
export interface GradientHighlightSpec {
/** Cheap, stateless presence probe used to skip the boundary regex on most lines. Must be non-global. */
probe: RegExp;
/** Global, word-bounded match regex walked by `.replace`. */
highlight: RegExp;
/** Number of color stops swept across the gradient. */
stops: number;
/** Maps a normalized position `t` in [0, 1) to an HSL hue in degrees. */
hue: (t: number) => number;
/** HSL saturation percentage. Default 90. */
saturation?: number;
/** HSL lightness percentage. Default 62. */
lightness?: number;
}
/**
* Build a stateless highlighter that paints each standalone match of `highlight`
* with a smooth HSL gradient for editor display. The returned function adds only
* zero-width SGR escapes — the visible width is unchanged — and returns the input
* untouched when `probe` does not match. The palette is compiled lazily and
* memoized per active color mode.
*/
export function createGradientHighlighter(spec: GradientHighlightSpec): KeywordHighlighter {
const { probe, highlight, stops, hue, saturation = 90, lightness = 62 } = spec;
let cachedMode: string | undefined;
let cachedPalette: readonly string[] | undefined;
/** Gradient foreground escapes for the active color mode, compiled once per mode. */
const palette = (): readonly string[] => {
const mode = theme.getColorMode();
if (cachedPalette && cachedMode === mode) return cachedPalette;
const format = mode === "truecolor" ? "ansi-16m" : "ansi-256";
const next: string[] = [];
for (let i = 0; i < stops; i++) {
next.push(Bun.color(`hsl(${Math.round(hue(i / stops))}, ${saturation}%, ${lightness}%)`, format) ?? "");
}
cachedMode = mode;
cachedPalette = next;
return next;
};
/** Paint each character of `word` with the next gradient stop, restoring `resetTo` after. */
const paint = (word: string, resetTo: string): string => {
const stopsArr = palette();
const n = word.length;
let out = "";
let prev = "";
for (let i = 0; i < n; i++) {
const color = stopsArr[Math.floor((i / n) * stopsArr.length)] ?? stopsArr[0] ?? "";
// Coalesce consecutive characters that resolve to the same stop.
if (color !== prev) {
out += color;
prev = color;
}
out += word[i];
}
return `${out}${resetTo}`;
};
return (text: string, resetTo: string = FG_RESET): string => {
if (!probe.test(text)) return text;
// Match against a code/markup-masked copy so keywords inside code spans,
// fenced blocks, or XML sections never paint; indices still address `text`.
const masked = maskNonProse(text);
let out = "";
let last = 0;
for (const m of masked.matchAll(highlight)) {
const start = m.index ?? 0;
const end = start + m[0].length;
out += text.slice(last, start) + paint(text.slice(start, end), resetTo);
last = end;
}
return out + text.slice(last);
};
}