summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/ui/themes/theme-manager.ts29
-rw-r--r--packages/cli/src/ui/themes/theme.ts278
-rw-r--r--packages/cli/src/ui/themes/vs2015.ts144
-rw-r--r--packages/cli/src/ui/utils/CodeColorizer.tsx117
-rw-r--r--packages/cli/src/ui/utils/MarkdownRenderer.tsx20
5 files changed, 574 insertions, 14 deletions
diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts
new file mode 100644
index 00000000..e083575c
--- /dev/null
+++ b/packages/cli/src/ui/themes/theme-manager.ts
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { VS2015 } from './vs2015.js';
+import { Theme } from './theme.js';
+
+class ThemeManager {
+ private static readonly DEFAULT_THEME: Theme = VS2015;
+ private readonly availableThemes: Theme[];
+ private activeTheme: Theme;
+
+ constructor() {
+ this.availableThemes = [VS2015];
+ this.activeTheme = ThemeManager.DEFAULT_THEME;
+ }
+
+ /**
+ * Returns the currently active theme object.
+ */
+ getActiveTheme(): Theme {
+ return this.activeTheme;
+ }
+}
+
+// Export an instance of the ThemeManager
+export const themeManager = new ThemeManager();
diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts
new file mode 100644
index 00000000..f7bd1cd0
--- /dev/null
+++ b/packages/cli/src/ui/themes/theme.ts
@@ -0,0 +1,278 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { CSSProperties } from 'react';
+
+export class Theme {
+ /**
+ * The user-facing name of the theme.
+ */
+ readonly name: string;
+
+ /**
+ * The default foreground color for text when no specific highlight rule applies.
+ * This is an Ink-compatible color string (hex or name).
+ */
+ readonly defaultColor: string;
+
+ /**
+ * Stores the mapping from highlight.js class names (e.g., 'hljs-keyword')
+ * to Ink-compatible color strings (hex or name).
+ */
+ protected readonly _colorMap: Readonly<Record<string, string>>;
+
+ // --- Static Helper Data ---
+
+ // Mapping from common CSS color names (lowercase) to hex codes (lowercase)
+ // Excludes names directly supported by Ink
+ private static readonly cssNameToHexMap: Readonly<Record<string, string>> = {
+ aliceblue: '#f0f8ff',
+ antiquewhite: '#faebd7',
+ aqua: '#00ffff',
+ aquamarine: '#7fffd4',
+ azure: '#f0ffff',
+ beige: '#f5f5dc',
+ bisque: '#ffe4c4',
+ blanchedalmond: '#ffebcd',
+ blueviolet: '#8a2be2',
+ brown: '#a52a2a',
+ burlywood: '#deb887',
+ cadetblue: '#5f9ea0',
+ chartreuse: '#7fff00',
+ chocolate: '#d2691e',
+ coral: '#ff7f50',
+ cornflowerblue: '#6495ed',
+ cornsilk: '#fff8dc',
+ crimson: '#dc143c',
+ darkblue: '#00008b',
+ darkcyan: '#008b8b',
+ darkgoldenrod: '#b8860b',
+ darkgray: '#a9a9a9',
+ darkgrey: '#a9a9a9',
+ darkgreen: '#006400',
+ darkkhaki: '#bdb76b',
+ darkmagenta: '#8b008b',
+ darkolivegreen: '#556b2f',
+ darkorange: '#ff8c00',
+ darkorchid: '#9932cc',
+ darkred: '#8b0000',
+ darksalmon: '#e9967a',
+ darkseagreen: '#8fbc8f',
+ darkslateblue: '#483d8b',
+ darkslategray: '#2f4f4f',
+ darkslategrey: '#2f4f4f',
+ darkturquoise: '#00ced1',
+ darkviolet: '#9400d3',
+ deeppink: '#ff1493',
+ deepskyblue: '#00bfff',
+ dimgray: '#696969',
+ dimgrey: '#696969',
+ dodgerblue: '#1e90ff',
+ firebrick: '#b22222',
+ floralwhite: '#fffaf0',
+ forestgreen: '#228b22',
+ fuchsia: '#ff00ff',
+ gainsboro: '#dcdcdc',
+ ghostwhite: '#f8f8ff',
+ gold: '#ffd700',
+ goldenrod: '#daa520',
+ greenyellow: '#adff2f',
+ honeydew: '#f0fff0',
+ hotpink: '#ff69b4',
+ indianred: '#cd5c5c',
+ indigo: '#4b0082',
+ ivory: '#fffff0',
+ khaki: '#f0e68c',
+ lavender: '#e6e6fa',
+ lavenderblush: '#fff0f5',
+ lawngreen: '#7cfc00',
+ lemonchiffon: '#fffacd',
+ lightblue: '#add8e6',
+ lightcoral: '#f08080',
+ lightcyan: '#e0ffff',
+ lightgoldenrodyellow: '#fafad2',
+ lightgray: '#d3d3d3',
+ lightgrey: '#d3d3d3',
+ lightgreen: '#90ee90',
+ lightpink: '#ffb6c1',
+ lightsalmon: '#ffa07a',
+ lightseagreen: '#20b2aa',
+ lightskyblue: '#87cefa',
+ lightslategray: '#778899',
+ lightslategrey: '#778899',
+ lightsteelblue: '#b0c4de',
+ lightyellow: '#ffffe0',
+ lime: '#00ff00',
+ limegreen: '#32cd32',
+ linen: '#faf0e6',
+ maroon: '#800000',
+ mediumaquamarine: '#66cdaa',
+ mediumblue: '#0000cd',
+ mediumorchid: '#ba55d3',
+ mediumpurple: '#9370db',
+ mediumseagreen: '#3cb371',
+ mediumslateblue: '#7b68ee',
+ mediumspringgreen: '#00fa9a',
+ mediumturquoise: '#48d1cc',
+ mediumvioletred: '#c71585',
+ midnightblue: '#191970',
+ mintcream: '#f5fffa',
+ mistyrose: '#ffe4e1',
+ moccasin: '#ffe4b5',
+ navajowhite: '#ffdead',
+ navy: '#000080',
+ oldlace: '#fdf5e6',
+ olive: '#808000',
+ olivedrab: '#6b8e23',
+ orange: '#ffa500',
+ orangered: '#ff4500',
+ orchid: '#da70d6',
+ palegoldenrod: '#eee8aa',
+ palegreen: '#98fb98',
+ paleturquoise: '#afeeee',
+ palevioletred: '#db7093',
+ papayawhip: '#ffefd5',
+ peachpuff: '#ffdab9',
+ peru: '#cd853f',
+ pink: '#ffc0cb',
+ plum: '#dda0dd',
+ powderblue: '#b0e0e6',
+ purple: '#800080',
+ rebeccapurple: '#663399',
+ rosybrown: '#bc8f8f',
+ royalblue: '#4169e1',
+ saddlebrown: '#8b4513',
+ salmon: '#fa8072',
+ sandybrown: '#f4a460',
+ seagreen: '#2e8b57',
+ seashell: '#fff5ee',
+ sienna: '#a0522d',
+ silver: '#c0c0c0',
+ skyblue: '#87ceeb',
+ slateblue: '#6a5acd',
+ slategray: '#708090',
+ slategrey: '#708090',
+ snow: '#fffafa',
+ springgreen: '#00ff7f',
+ steelblue: '#4682b4',
+ tan: '#d2b48c',
+ teal: '#008080',
+ thistle: '#d8bfd8',
+ tomato: '#ff6347',
+ turquoise: '#40e0d0',
+ violet: '#ee82ee',
+ wheat: '#f5deb3',
+ whitesmoke: '#f5f5f5',
+ yellowgreen: '#9acd32',
+ };
+
+ // Define the set of Ink's named colors for quick lookup
+ private static readonly inkSupportedNames = new Set([
+ 'black',
+ 'red',
+ 'green',
+ 'yellow',
+ 'blue',
+ 'cyan',
+ 'magenta',
+ 'white',
+ 'gray',
+ 'grey',
+ 'blackbright',
+ 'redbright',
+ 'greenbright',
+ 'yellowbright',
+ 'bluebright',
+ 'cyanbright',
+ 'magentabright',
+ 'whitebright',
+ ]);
+
+ /**
+ * Creates a new Theme instance.
+ * @param name The name of the theme.
+ * @param rawMappings The raw CSSProperties mappings from a react-syntax-highlighter theme object.
+ */
+ constructor(name: string, rawMappings: Record<string, CSSProperties>) {
+ this.name = name;
+ this._colorMap = Object.freeze(this._buildColorMap(rawMappings)); // Build and freeze the map
+
+ // Determine the default foreground color
+ const rawDefaultColor = rawMappings['hljs']?.color;
+ this.defaultColor =
+ (rawDefaultColor ? Theme._resolveColor(rawDefaultColor) : undefined) ??
+ ''; // Default to empty string if not found or resolvable
+ }
+
+ /**
+ * Gets the Ink-compatible color string for a given highlight.js class name.
+ * @param hljsClass The highlight.js class name (e.g., 'hljs-keyword', 'hljs-string').
+ * @returns The corresponding Ink color string (hex or name) if it exists.
+ */
+ getInkColor(hljsClass: string): string | undefined {
+ return this._colorMap[hljsClass];
+ }
+
+ /**
+ * Resolves a CSS color value (name or hex) into an Ink-compatible color string.
+ * @param colorValue The raw color string (e.g., 'blue', '#ff0000', 'darkkhaki').
+ * @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
+ */
+ private static _resolveColor(colorValue: string): string | undefined {
+ const lowerColor = colorValue.toLowerCase();
+
+ // 1. Check if it's already a hex code
+ if (lowerColor.startsWith('#')) {
+ return lowerColor; // Use hex directly
+ }
+ // 2. Check if it's an Ink supported name (lowercase)
+ else if (Theme.inkSupportedNames.has(lowerColor)) {
+ return lowerColor; // Use Ink name directly
+ }
+ // 3. Check if it's a known CSS name we can map to hex
+ else if (Theme.cssNameToHexMap[lowerColor]) {
+ return Theme.cssNameToHexMap[lowerColor]; // Use mapped hex
+ }
+
+ // 4. Could not resolve
+ console.warn(
+ `[Theme] Could not resolve color "${colorValue}" to an Ink-compatible format.`,
+ );
+ return undefined;
+ }
+
+ /**
+ * Builds the internal map from highlight.js class names to Ink-compatible color strings.
+ * This method is protected and primarily intended for use by the constructor.
+ * @param hljsTheme The raw CSSProperties mappings from a react-syntax-highlighter theme object.
+ * @returns An Ink-compatible theme map (Record<string, string>).
+ */
+ protected _buildColorMap(
+ hljsTheme: Record<string, CSSProperties>,
+ ): Record<string, string> {
+ const inkTheme: Record<string, string> = {};
+ for (const key in hljsTheme) {
+ // Ensure the key starts with 'hljs-' or is 'hljs' for the base style
+ if (!key.startsWith('hljs-') && key !== 'hljs') {
+ continue; // Skip keys not related to highlighting classes
+ }
+
+ const style = hljsTheme[key];
+ if (style?.color) {
+ const resolvedColor = Theme._resolveColor(style.color);
+ if (resolvedColor !== undefined) {
+ // Use the original key from the hljsTheme (e.g., 'hljs-keyword')
+ inkTheme[key] = resolvedColor;
+ }
+ // If color is not resolvable, it's omitted from the map,
+ // allowing fallback to the default foreground color.
+ }
+ // We currently only care about the 'color' property for Ink rendering.
+ // Other properties like background, fontStyle, etc., are ignored.
+ }
+ return inkTheme;
+ }
+}
diff --git a/packages/cli/src/ui/themes/vs2015.ts b/packages/cli/src/ui/themes/vs2015.ts
new file mode 100644
index 00000000..fa4ec1ee
--- /dev/null
+++ b/packages/cli/src/ui/themes/vs2015.ts
@@ -0,0 +1,144 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Theme } from './theme.js';
+
+export const VS2015: Theme = new Theme('VS2015', {
+ hljs: {
+ display: 'block',
+ overflowX: 'auto',
+ padding: '0.5em',
+ background: '#1E1E1E',
+ color: '#DCDCDC',
+ },
+ 'hljs-keyword': {
+ color: '#569CD6',
+ },
+ 'hljs-literal': {
+ color: '#569CD6',
+ },
+ 'hljs-symbol': {
+ color: '#569CD6',
+ },
+ 'hljs-name': {
+ color: '#569CD6',
+ },
+ 'hljs-link': {
+ color: '#569CD6',
+ textDecoration: 'underline',
+ },
+ 'hljs-built_in': {
+ color: '#4EC9B0',
+ },
+ 'hljs-type': {
+ color: '#4EC9B0',
+ },
+ 'hljs-number': {
+ color: '#B8D7A3',
+ },
+ 'hljs-class': {
+ color: '#B8D7A3',
+ },
+ 'hljs-string': {
+ color: '#D69D85',
+ },
+ 'hljs-meta-string': {
+ color: '#D69D85',
+ },
+ 'hljs-regexp': {
+ color: '#9A5334',
+ },
+ 'hljs-template-tag': {
+ color: '#9A5334',
+ },
+ 'hljs-subst': {
+ color: '#DCDCDC',
+ },
+ 'hljs-function': {
+ color: '#DCDCDC',
+ },
+ 'hljs-title': {
+ color: '#DCDCDC',
+ },
+ 'hljs-params': {
+ color: '#DCDCDC',
+ },
+ 'hljs-formula': {
+ color: '#DCDCDC',
+ },
+ 'hljs-comment': {
+ color: '#57A64A',
+ fontStyle: 'italic',
+ },
+ 'hljs-quote': {
+ color: '#57A64A',
+ fontStyle: 'italic',
+ },
+ 'hljs-doctag': {
+ color: '#608B4E',
+ },
+ 'hljs-meta': {
+ color: '#9B9B9B',
+ },
+ 'hljs-meta-keyword': {
+ color: '#9B9B9B',
+ },
+ 'hljs-tag': {
+ color: '#9B9B9B',
+ },
+ 'hljs-variable': {
+ color: '#BD63C5',
+ },
+ 'hljs-template-variable': {
+ color: '#BD63C5',
+ },
+ 'hljs-attr': {
+ color: '#9CDCFE',
+ },
+ 'hljs-attribute': {
+ color: '#9CDCFE',
+ },
+ 'hljs-builtin-name': {
+ color: '#9CDCFE',
+ },
+ 'hljs-section': {
+ color: 'gold',
+ },
+ 'hljs-emphasis': {
+ fontStyle: 'italic',
+ },
+ 'hljs-strong': {
+ fontWeight: 'bold',
+ },
+ 'hljs-bullet': {
+ color: '#D7BA7D',
+ },
+ 'hljs-selector-tag': {
+ color: '#D7BA7D',
+ },
+ 'hljs-selector-id': {
+ color: '#D7BA7D',
+ },
+ 'hljs-selector-class': {
+ color: '#D7BA7D',
+ },
+ 'hljs-selector-attr': {
+ color: '#D7BA7D',
+ },
+ 'hljs-selector-pseudo': {
+ color: '#D7BA7D',
+ },
+ 'hljs-addition': {
+ backgroundColor: '#144212',
+ display: 'inline-block',
+ width: '100%',
+ },
+ 'hljs-deletion': {
+ backgroundColor: '#600',
+ display: 'inline-block',
+ width: '100%',
+ },
+});
diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx
new file mode 100644
index 00000000..1916ff50
--- /dev/null
+++ b/packages/cli/src/ui/utils/CodeColorizer.tsx
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { Text } from 'ink';
+import { common, createLowlight } from 'lowlight';
+import type {
+ Root,
+ Element,
+ Text as HastText,
+ ElementContent,
+ RootContent,
+} from 'hast';
+import { themeManager } from '../themes/theme-manager.js';
+import { Theme } from '../themes/theme.js';
+
+// Configure themeing and parsing utilities.
+const lowlight = createLowlight(common);
+
+function renderHastNode(
+ node: Root | Element | HastText | RootContent,
+ theme: Theme,
+ inheritedColor: string | undefined,
+): React.ReactNode {
+ if (node.type === 'text') {
+ // Use the color passed down from parent element, if any
+ return <Text color={inheritedColor}>{node.value}</Text>;
+ }
+
+ // Handle Element Nodes: Determine color and pass it down, don't wrap
+ if (node.type === 'element') {
+ const nodeClasses: string[] =
+ (node.properties?.className as string[]) || [];
+ let elementColor: string | undefined = undefined;
+
+ // Find color defined specifically for this element's class
+ for (let i = nodeClasses.length - 1; i >= 0; i--) {
+ const color = theme.getInkColor(nodeClasses[i]);
+ if (color) {
+ elementColor = color;
+ break;
+ }
+ }
+
+ // Determine the color to pass down: Use this element's specific color
+ // if found, otherwise, continue passing down the already inherited color.
+ const colorToPassDown = elementColor || inheritedColor;
+
+ // Recursively render children, passing the determined color down
+ // Ensure child type matches expected HAST structure (ElementContent is common)
+ const children = node.children?.map(
+ (child: ElementContent, index: number) => (
+ <React.Fragment key={index}>
+ {renderHastNode(child, theme, colorToPassDown)}
+ </React.Fragment>
+ ),
+ );
+
+ // Element nodes now only group children; color is applied by Text nodes.
+ // Use a React Fragment to avoid adding unnecessary elements.
+ return <React.Fragment>{children}</React.Fragment>;
+ }
+
+ // Handle Root Node: Start recursion with initial inherited color
+ if (node.type === 'root') {
+ // Pass down the initial inheritedColor (likely undefined from the top call)
+ // Ensure child type matches expected HAST structure (RootContent is common)
+ return node.children?.map((child: RootContent, index: number) => (
+ <React.Fragment key={index}>
+ {renderHastNode(child, theme, inheritedColor)}
+ </React.Fragment>
+ ));
+ }
+
+ // Handle unknown or unsupported node types
+ return null;
+}
+
+/**
+ * Renders syntax-highlighted code for Ink applications using a selected theme.
+ *
+ * @param code The code string to highlight.
+ * @param language The language identifier (e.g., 'javascript', 'css', 'html')
+ * @returns A React.ReactNode containing Ink <Text> elements for the highlighted code.
+ */
+export function colorizeCode(
+ code: string,
+ language: string | null,
+): React.ReactNode {
+ const codeToHighlight = code.replace(/\n$/, '');
+ const activeTheme = themeManager.getActiveTheme();
+
+ try {
+ const hastTree =
+ !language || !lowlight.registered(language)
+ ? lowlight.highlightAuto(codeToHighlight)
+ : lowlight.highlight(language, codeToHighlight);
+
+ // Render the HAST tree using the adapted theme
+ // Apply the theme's default foreground color to the top-level Text element
+ return (
+ <Text color={activeTheme.defaultColor}>
+ {renderHastNode(hastTree, activeTheme, undefined)}
+ </Text>
+ );
+ } catch (error) {
+ console.error(
+ `[colorizeCode] Error highlighting code for language "${language}":`,
+ error,
+ );
+ // Fallback to plain text with default color on error
+ return <Text color={activeTheme.defaultColor}>{codeToHighlight}</Text>;
+ }
+}
diff --git a/packages/cli/src/ui/utils/MarkdownRenderer.tsx b/packages/cli/src/ui/utils/MarkdownRenderer.tsx
index 680d7407..c9053728 100644
--- a/packages/cli/src/ui/utils/MarkdownRenderer.tsx
+++ b/packages/cli/src/ui/utils/MarkdownRenderer.tsx
@@ -7,6 +7,7 @@
import React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../colors.js';
+import { colorizeCode } from './CodeColorizer.js';
/**
* A utility class to render a subset of Markdown into Ink components.
@@ -155,21 +156,12 @@ export class MarkdownRenderer {
content: string[],
lang: string | null,
): React.ReactNode {
- // Basic styling for code block
+ const fullContent = content.join('\n');
+ const colorizedCode = colorizeCode(fullContent, lang);
+
return (
- <Box
- key={key}
- borderStyle="round"
- borderColor={Colors.SubtleComment}
- borderLeft={false}
- borderRight={false}
- flexDirection="column"
- >
- {lang && <Text dimColor>{lang}</Text>}
- {/* Render each line preserving whitespace (within Text component) */}
- {content.map((line, idx) => (
- <Text key={idx}>{line}</Text>
- ))}
+ <Box key={key} flexDirection="column" padding={1}>
+ {colorizedCode}
</Box>
);
}