diff --git a/package.json b/package.json index 3180d1c60..bf274dee1 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ "chalk": "^5.6.2", "chokidar": "^5.0.0", "chromium-bidi": "12.0.1", + "cli-highlight": "^2.1.11", "commander": "^14.0.2", "croner": "^9.1.0", "detect-libc": "^2.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c51170b5..c60188274 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ importers: chromium-bidi: specifier: 12.0.1 version: 12.0.1(devtools-protocol@0.0.1561482) + cli-highlight: + specifier: ^2.1.11 + version: 2.1.11 commander: specifier: ^14.0.2 version: 14.0.2 diff --git a/src/tui/theme/theme.test.ts b/src/tui/theme/theme.test.ts new file mode 100644 index 000000000..648ff87d8 --- /dev/null +++ b/src/tui/theme/theme.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { markdownTheme } from "./theme.js"; + +describe("markdownTheme", () => { + describe("highlightCode", () => { + it("should return an array of lines for JavaScript code", () => { + const code = `const x = 42;`; + const result = markdownTheme.highlightCode!(code, "javascript"); + + expect(result).toBeInstanceOf(Array); + expect(result).toHaveLength(1); + // Result should contain the original code (possibly with ANSI codes) + expect(result[0]).toContain("const"); + expect(result[0]).toContain("42"); + }); + + it("should return correct line count for multi-line code", () => { + const code = `function greet(name: string) { + return "Hello, " + name; +}`; + const result = markdownTheme.highlightCode!(code, "typescript"); + + expect(result).toHaveLength(3); + expect(result[0]).toContain("function"); + expect(result[1]).toContain("return"); + expect(result[2]).toContain("}"); + }); + + it("should handle Python code", () => { + const code = `def hello(): + print("world")`; + const result = markdownTheme.highlightCode!(code, "python"); + + expect(result).toHaveLength(2); + expect(result[0]).toContain("def"); + expect(result[1]).toContain("print"); + }); + + it("should handle unknown languages gracefully", () => { + const code = `const x = 42;`; + const result = markdownTheme.highlightCode!(code, "not-a-real-language"); + + expect(result).toBeInstanceOf(Array); + expect(result).toHaveLength(1); + // Should still return the code content + expect(result[0]).toContain("const"); + }); + + it("should handle code without language specifier", () => { + const code = `echo "hello"`; + const result = markdownTheme.highlightCode!(code, undefined); + + expect(result).toBeInstanceOf(Array); + expect(result).toHaveLength(1); + expect(result[0]).toContain("echo"); + }); + + it("should handle empty code", () => { + const result = markdownTheme.highlightCode!("", "javascript"); + + expect(result).toBeInstanceOf(Array); + expect(result).toHaveLength(1); + expect(result[0]).toBe(""); + }); + + it("should handle bash/shell code", () => { + const code = `#!/bin/bash +echo "Hello" +for i in {1..5}; do + echo $i +done`; + const result = markdownTheme.highlightCode!(code, "bash"); + + expect(result).toHaveLength(5); + expect(result[0]).toContain("#!/bin/bash"); + expect(result[1]).toContain("echo"); + }); + + it("should handle JSON", () => { + const code = `{"name": "test", "count": 42, "active": true}`; + const result = markdownTheme.highlightCode!(code, "json"); + + expect(result).toHaveLength(1); + expect(result[0]).toContain("name"); + expect(result[0]).toContain("42"); + }); + + it("should handle code with special characters", () => { + const code = `const regex = /\\d+/g; +const str = "Hello\\nWorld";`; + const result = markdownTheme.highlightCode!(code, "javascript"); + + expect(result).toHaveLength(2); + // Should not throw and should return valid output + expect(result[0].length).toBeGreaterThan(0); + expect(result[1].length).toBeGreaterThan(0); + }); + + it("should preserve code content through highlighting", () => { + const code = `const message = "Hello, World!"; +console.log(message);`; + const result = markdownTheme.highlightCode!(code, "javascript"); + + // Strip ANSI codes to verify content is preserved + const stripAnsi = (str: string) => str.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripAnsi(result[0])).toBe(`const message = "Hello, World!";`); + expect(stripAnsi(result[1])).toBe("console.log(message);"); + }); + }); +}); diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts index b8072fda3..4c46a6fa5 100644 --- a/src/tui/theme/theme.ts +++ b/src/tui/theme/theme.ts @@ -5,6 +5,7 @@ import type { SettingsListTheme, } from "@mariozechner/pi-tui"; import chalk from "chalk"; +import { highlight, supportsLanguage } from "cli-highlight"; import type { SearchableSelectListTheme } from "../components/searchable-select-list.js"; const palette = { @@ -34,6 +35,73 @@ const palette = { const fg = (hex: string) => (text: string) => chalk.hex(hex)(text); const bg = (hex: string) => (text: string) => chalk.bgHex(hex)(text); +/** + * Syntax highlighting theme for code blocks. + * Uses chalk functions to style different token types. + */ +const syntaxTheme = { + keyword: chalk.hex("#C586C0"), // purple - if, const, function, etc. + built_in: chalk.hex("#4EC9B0"), // teal - console, Math, etc. + type: chalk.hex("#4EC9B0"), // teal - types + literal: chalk.hex("#569CD6"), // blue - true, false, null + number: chalk.hex("#B5CEA8"), // green - numbers + string: chalk.hex("#CE9178"), // orange - strings + regexp: chalk.hex("#D16969"), // red - regex + symbol: chalk.hex("#B5CEA8"), // green - symbols + class: chalk.hex("#4EC9B0"), // teal - class names + function: chalk.hex("#DCDCAA"), // yellow - function names + title: chalk.hex("#DCDCAA"), // yellow - titles/names + params: chalk.hex("#9CDCFE"), // light blue - parameters + comment: chalk.hex("#6A9955"), // green - comments + doctag: chalk.hex("#608B4E"), // darker green - jsdoc tags + meta: chalk.hex("#9CDCFE"), // light blue - meta/preprocessor + "meta-keyword": chalk.hex("#C586C0"), // purple + "meta-string": chalk.hex("#CE9178"), // orange + section: chalk.hex("#DCDCAA"), // yellow - sections + tag: chalk.hex("#569CD6"), // blue - HTML/XML tags + name: chalk.hex("#9CDCFE"), // light blue - tag names + attr: chalk.hex("#9CDCFE"), // light blue - attributes + attribute: chalk.hex("#9CDCFE"), // light blue - attributes + variable: chalk.hex("#9CDCFE"), // light blue - variables + bullet: chalk.hex("#D7BA7D"), // gold - list bullets in markdown + code: chalk.hex("#CE9178"), // orange - inline code + emphasis: chalk.italic, // italic + strong: chalk.bold, // bold + formula: chalk.hex("#C586C0"), // purple - math + link: chalk.hex("#4EC9B0"), // teal - links + quote: chalk.hex("#6A9955"), // green - quotes + addition: chalk.hex("#B5CEA8"), // green - diff additions + deletion: chalk.hex("#F44747"), // red - diff deletions + "selector-tag": chalk.hex("#D7BA7D"), // gold - CSS selectors + "selector-id": chalk.hex("#D7BA7D"), // gold + "selector-class": chalk.hex("#D7BA7D"), // gold + "selector-attr": chalk.hex("#D7BA7D"), // gold + "selector-pseudo": chalk.hex("#D7BA7D"), // gold + "template-tag": chalk.hex("#C586C0"), // purple + "template-variable": chalk.hex("#9CDCFE"), // light blue + default: fg(palette.code), // fallback to code color +}; + +/** + * Highlight code with syntax coloring. + * Returns an array of lines with ANSI escape codes. + */ +function highlightCode(code: string, lang?: string): string[] { + try { + // Check if language is supported, fall back to auto-detect + const language = lang && supportsLanguage(lang) ? lang : undefined; + const highlighted = highlight(code, { + language, + theme: syntaxTheme, + ignoreIllegals: true, + }); + return highlighted.split("\n"); + } catch { + // If highlighting fails, return plain code + return code.split("\n").map((line) => fg(palette.code)(line)); + } +} + export const theme = { fg: fg(palette.text), dim: fg(palette.dim), @@ -70,6 +138,7 @@ export const markdownTheme: MarkdownTheme = { italic: (text) => chalk.italic(text), strikethrough: (text) => chalk.strikethrough(text), underline: (text) => chalk.underline(text), + highlightCode, }; export const selectListTheme: SelectListTheme = {