From 0e3c9e4a0e5bba1b1a040d2cca1ffb04124a00b1 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 18 Jan 2026 16:24:14 -0800 Subject: [PATCH] feat(tui): add syntax highlighting for code blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add syntax highlighting to markdown code blocks in the TUI using cli-highlight with a VS Code Dark-inspired color theme. Features: - 191 languages supported via highlight.js - Auto-detection fallback for unknown languages - Graceful fallback to plain styling on errors - VS Code Dark-inspired color palette Colors: - Purple: keywords (const, function, if, etc.) - Teal: built-ins (console, Math, print, etc.) - Orange: strings - Green: numbers, comments - Yellow: function names - Blue: literals (true, false, null) - Red: diff deletions - Light blue: variables, parameters 🤖 AI-assisted (Claude) - fully tested locally --- package.json | 1 + pnpm-lock.yaml | 3 ++ src/tui/theme/theme.test.ts | 95 +++++++++++++++++++++++++++++++++++++ src/tui/theme/theme.ts | 69 +++++++++++++++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 src/tui/theme/theme.test.ts diff --git a/package.json b/package.json index a08ec1c48..a6febecfa 100644 --- a/package.json +++ b/package.json @@ -161,6 +161,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..0471dda5c --- /dev/null +++ b/src/tui/theme/theme.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { markdownTheme } from "./theme.js"; + +describe("markdownTheme", () => { + describe("highlightCode", () => { + it("should highlight JavaScript code", () => { + const code = `const x = 42;`; + const result = markdownTheme.highlightCode!(code, "javascript"); + + expect(result).toBeInstanceOf(Array); + expect(result).toHaveLength(1); + // Should contain ANSI escape codes + expect(result[0]).toContain("\x1b["); + }); + + it("should highlight TypeScript code with multiple lines", () => { + const code = `function greet(name: string) { + return "Hello, " + name; +}`; + const result = markdownTheme.highlightCode!(code, "typescript"); + + expect(result).toHaveLength(3); + // Each line should have highlighting + result.forEach((line) => { + expect(line).toContain("\x1b["); + }); + }); + + it("should highlight Python code", () => { + const code = `def hello(): + print("world")`; + const result = markdownTheme.highlightCode!(code, "python"); + + expect(result).toHaveLength(2); + expect(result[0]).toContain("\x1b["); // def keyword colored + }); + + it("should handle unknown languages with auto-detection", () => { + 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 something (auto-detected or fallback) + expect(result[0].length).toBeGreaterThan(0); + }); + + 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); + }); + + it("should handle empty code", () => { + const result = markdownTheme.highlightCode!("", "javascript"); + + expect(result).toBeInstanceOf(Array); + expect(result).toHaveLength(1); + expect(result[0]).toBe(""); + }); + + it("should highlight 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); + // Should have colored output + expect(result.some((line) => line.includes("\x1b["))).toBe(true); + }); + + it("should highlight JSON", () => { + const code = `{"name": "test", "count": 42, "active": true}`; + const result = markdownTheme.highlightCode!(code, "json"); + + expect(result).toHaveLength(1); + expect(result[0]).toContain("\x1b["); + }); + + 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); + }); + }); +}); 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 = {