diff --git a/package-lock.json b/package-lock.json
index df633f23259..fae4754c26d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9776,6 +9776,15 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/highlight.js": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz",
+ "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/hono": {
"version": "4.13.13",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz",
@@ -16352,7 +16361,8 @@
"@maka/mcp": "0.1.0",
"@maka/runtime": "0.1.0",
"@maka/runtime-host": "0.1.0",
- "@maka/storage": "0.1.0"
+ "@maka/storage": "0.1.0",
+ "highlight.js": "11.12.0"
},
"bin": {
"maka": "dist/cli.js"
diff --git a/packages/cli/README.md b/packages/cli/README.md
index a0e3c53675b..453cfc20ed8 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -288,6 +288,18 @@ on your selected branch. It explains Session coordination; it does not install M
npx skills add apache/maka --skill maka-cli
```
+## Code blocks in the TUI
+
+The live conversation and detailed transcript reader highlight tagged Markdown code blocks using
+the terminal's existing color settings. `NO_COLOR` and `TERM=dumb` disable highlighting colors.
+
+Supported languages are Bash (`sh`, `shell`, `zsh`), C, C++ (`c++`), C# (`cs`), CSS, diff, Go,
+Java, JavaScript (`js`, `jsx`, `mjs`, `cjs`), JSON, Markdown (`md`), PowerShell (`ps1`),
+Python (`py`), Rust (`rs`), SQL, TypeScript (`ts`, `tsx`), XML (`html`, `htm`, `svg`), and
+YAML (`yml`). Tags are case-insensitive. Missing or unknown tags, highlighting failures, and
+blocks longer than 4,096 UTF-16 code units render as plain code. Language auto-detection is
+disabled; highlighting never changes stored message content.
+
## Upgrade
Update within the nightly line by pinning the exact release (`--target` accepts `latest`,
diff --git a/packages/cli/README.zh-CN.md b/packages/cli/README.zh-CN.md
index 55bfdbf8564..dccbc0298e9 100644
--- a/packages/cli/README.zh-CN.md
+++ b/packages/cli/README.zh-CN.md
@@ -262,6 +262,17 @@ skill 本身不安装 Maka CLI:
npx skills add apache/maka --skill maka-cli
```
+## TUI 代码块
+
+实时对话和详细记录阅读器会按终端现有的颜色设置,高亮带语言标签的 Markdown 代码块。
+设置 `NO_COLOR` 或 `TERM=dumb` 可禁用高亮颜色。
+
+支持 Bash(`sh`、`shell`、`zsh`)、C、C++(`c++`)、C#(`cs`)、CSS、diff、Go、Java、
+JavaScript(`js`、`jsx`、`mjs`、`cjs`)、JSON、Markdown(`md`)、PowerShell(`ps1`)、
+Python(`py`)、Rust(`rs`)、SQL、TypeScript(`ts`、`tsx`)、XML(`html`、`htm`、`svg`)
+和 YAML(`yml`),标签不区分大小写。标签缺失或未知、高亮失败,以及长度超过 4,096 个
+UTF-16 代码单元的代码块均显示为纯文本。不自动检测语言,高亮不会修改保存的消息内容。
+
## 升级
在 nightly 线内更新时固定精确版本(`--target` 只接受 `latest`、`next` 或精确的 Maka
diff --git a/packages/cli/THIRD_PARTY_NOTICES.txt b/packages/cli/THIRD_PARTY_NOTICES.txt
index 68eca268d26..90fb3e220a6 100644
--- a/packages/cli/THIRD_PARTY_NOTICES.txt
+++ b/packages/cli/THIRD_PARTY_NOTICES.txt
@@ -4605,6 +4605,44 @@ SOFTWARE.
================================================================================
+Package: highlight.js@11.12.0
+Declared license: BSD-3-Clause
+Selected license: BSD-3-Clause
+Repository: git://github.com/highlightjs/highlight.js.git
+
+--- LICENSE ---
+BSD 3-Clause License
+
+Copyright (c) 2006, Ivan Sagalaev.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+================================================================================
+
Package: html-escaper@3.0.3
Declared license: MIT
Selected license: MIT
diff --git a/packages/cli/package.json b/packages/cli/package.json
index e1e2613acd7..8fb64a0ddaa 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -26,6 +26,7 @@
"@maka/mcp": "0.1.0",
"@maka/runtime": "0.1.0",
"@maka/runtime-host": "0.1.0",
- "@maka/storage": "0.1.0"
+ "@maka/storage": "0.1.0",
+ "highlight.js": "11.12.0"
}
}
diff --git a/packages/cli/src/__tests__/pi-transcript-highlight.test.ts b/packages/cli/src/__tests__/pi-transcript-highlight.test.ts
new file mode 100644
index 00000000000..aaeff0d92ad
--- /dev/null
+++ b/packages/cli/src/__tests__/pi-transcript-highlight.test.ts
@@ -0,0 +1,362 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { test } from 'node:test';
+import { markdownTheme } from '../pi-transcript-format.js';
+import { stripAnsi } from '../tui-ansi.js';
+import { highlightMarkdownCode } from '../tui-syntax-highlight.js';
+
+const languageExamples = {
+ bash: 'echo "你好<&>" # comment',
+ c: 'const char *label = "你好<&>";',
+ cpp: 'constexpr auto label = "你好<&>";',
+ csharp: 'public string label = "你好<&>";',
+ css: '.label { color: red; content: "你好<&>"; }',
+ diff: '-old text\n+你好<&> new text',
+ go: 'func main() { fmt.Println("你好<&>") }',
+ java: 'public class Label { String text = "你好<&>"; }',
+ javascript: 'const label = "你好<&>";',
+ json: '{"label": "你好<&>", "enabled": true}',
+ markdown: '# 你好<&>\n**bold** and `code`',
+ powershell: 'Write-Host "你好<&>" # comment',
+ python: 'def label():\n return "你好<&>"',
+ rust: 'fn main() { let label = "你好<&>"; }',
+ sql: "SELECT '你好<&>' FROM labels WHERE id = 1;",
+ typescript: 'const label: string = "你好<&>";',
+ xml: '',
+ yaml: 'label: "你好<&>"\nenabled: true',
+} as const;
+
+const aliases = {
+ sh: 'bash',
+ shell: 'bash',
+ zsh: 'bash',
+ 'c++': 'cpp',
+ cs: 'csharp',
+ html: 'xml',
+ htm: 'xml',
+ svg: 'xml',
+ js: 'javascript',
+ jsx: 'javascript',
+ mjs: 'javascript',
+ cjs: 'javascript',
+ md: 'markdown',
+ ps1: 'powershell',
+ py: 'python',
+ rs: 'rust',
+ ts: 'typescript',
+ tsx: 'typescript',
+ yml: 'yaml',
+} as const;
+
+// Force visible ANSI in these checks even when the test runner pipes stdout.
+// The production theme's environment handling is checked in fresh processes below.
+const testTheme = Object.fromEntries(
+ [
+ 'keyword',
+ 'built_in',
+ 'literal',
+ 'number',
+ 'regexp',
+ 'string',
+ 'comment',
+ 'doctag',
+ 'meta',
+ 'function',
+ 'title',
+ 'class',
+ 'type',
+ 'tag',
+ 'name',
+ 'attr',
+ 'attribute',
+ 'variable',
+ 'params',
+ 'operator',
+ 'addition',
+ 'deletion',
+ 'section',
+ 'strong',
+ 'emphasis',
+ 'code',
+ ].map((scope) => [scope, (text: string) => `\x1b[35m${text}\x1b[39m`]),
+);
+
+test('highlights every supported language and preserves code units and logical lines', () => {
+ const edgeCases = '\n\t<&>"\' & ' 😀 e\u0301 😀 \ud800|\udfff\r\n\n ';
+ for (const [language, example] of Object.entries(languageExamples)) {
+ const highlighted = highlightMarkdownCode(example, language, testTheme);
+ assert.match(highlighted.join('\n'), /\x1b\[35m/u, `${language} must classify tokens`);
+ for (const code of [example, `${example}${edgeCases}`, '', '\n\n', ' \t\r\n ']) {
+ assert.deepEqual(
+ highlightMarkdownCode(code, language, testTheme).map(stripAnsi),
+ code.split('\n'),
+ `${language} must preserve text, whitespace, and empty lines`,
+ );
+ }
+ }
+});
+
+test('every documented alias has the same highlighting as its canonical language', () => {
+ for (const [alias, language] of Object.entries(aliases)) {
+ const code = languageExamples[language];
+ const expected = highlightMarkdownCode(code, language, testTheme);
+ assert.match(expected.join('\n'), /\x1b\[35m/u);
+ assert.deepEqual(highlightMarkdownCode(code, alias, testTheme), expected, alias);
+ assert.deepEqual(
+ highlightMarkdownCode(code, ` ${alias.toUpperCase()} `, testTheme),
+ expected,
+ `${alias} is case-insensitive and trimmed`,
+ );
+ }
+});
+
+test('highlights tagged TypeScript code without changing its text', () => {
+ const highlightCode = markdownTheme.highlightCode;
+ assert.ok(highlightCode, 'the shared Markdown theme must provide the highlighting hook');
+ const code = ['const greeting: string = "你好";', 'console.log(greeting);'].join('\n');
+
+ const highlighted = highlightCode(code, 'typescript');
+
+ assert.equal(stripAnsi(highlighted.join('\n')), code);
+});
+
+test('classifies aliased language tokens without changing escaped source text', () => {
+ const code = 'const label = "你好<&>";';
+ const highlighted = highlightMarkdownCode(code, ' TS ', {
+ keyword: (text) => `${text}`,
+ string: (text) => `${text}`,
+ }).join('\n');
+
+ assert.match(highlighted, /const<\/keyword>/u);
+ assert.match(highlighted, /你好<\/string>/u);
+ assert.equal(highlighted.replace(/<\/?(?:keyword|string)>/gu, ''), code);
+});
+
+test('falls back to plain code for absent, unknown, or failed highlighting', () => {
+ const code = [' first line', '', '最后一行 '].join('\n');
+
+ assert.deepEqual(highlightMarkdownCode(code), code.split('\n'));
+ for (const language of [
+ '',
+ ' ',
+ 'text',
+ 'plaintext',
+ 'made-up-language',
+ '__proto__',
+ 'constructor',
+ ]) {
+ assert.deepEqual(highlightMarkdownCode(code, language, testTheme), code.split('\n'), language);
+ }
+ assert.deepEqual(
+ highlightMarkdownCode('const value = 1;', 'typescript', {
+ keyword: () => {
+ throw new Error('theme failed');
+ },
+ }),
+ ['const value = 1;'],
+ );
+});
+
+test('large streamed code falls back to plain text before formatting', () => {
+ const prefix = 'const value = 1;\n';
+ for (const [language, code] of [
+ ['typescript', prefix + 'x'.repeat(4097 - prefix.length)],
+ ['markdown', '['.repeat(2048) + 'x' + ']'.repeat(2048)],
+ ]) {
+ let formattingCalls = 0;
+ const countFormatting = (text: string) => {
+ formattingCalls++;
+ return text;
+ };
+ assert.deepEqual(
+ highlightMarkdownCode(code, language, {
+ keyword: countFormatting,
+ link: countFormatting,
+ string: countFormatting,
+ default: countFormatting,
+ }),
+ code.split('\n'),
+ );
+ assert.equal(
+ formattingCalls,
+ 0,
+ `${language} must bypass synchronous highlighting above the cap`,
+ );
+ }
+});
+
+test('production highlighting respects terminal color capabilities and NO_COLOR', () => {
+ const code = languageExamples.typescript;
+ const script = [
+ 'const { markdownTheme } = await import(process.argv[1]);',
+ 'if (!markdownTheme.highlightCode) throw new Error("missing shared Markdown hook");',
+ 'process.stdout.write(JSON.stringify(markdownTheme.highlightCode(process.argv[2], "typescript")));',
+ ].join('\n');
+ const moduleUrl = new URL('../pi-transcript-format.js', import.meta.url).href;
+ const cases = [
+ {
+ name: 'truecolor',
+ env: { TERM: 'xterm-256color', COLORTERM: 'truecolor', NO_COLOR: '' },
+ colored: true,
+ },
+ { name: 'basic ANSI', env: { TERM: 'xterm', COLORTERM: '', NO_COLOR: '' }, colored: true },
+ {
+ name: 'NO_COLOR',
+ env: { TERM: 'xterm-256color', COLORTERM: 'truecolor', NO_COLOR: '1' },
+ colored: false,
+ },
+ {
+ name: 'dumb terminal',
+ env: { TERM: 'dumb', COLORTERM: 'truecolor', NO_COLOR: '' },
+ colored: false,
+ },
+ ];
+ for (const { name, env, colored } of cases) {
+ const output = execFileSync(
+ process.execPath,
+ ['--input-type=module', '-e', script, moduleUrl, code],
+ {
+ encoding: 'utf8',
+ env: { ...process.env, FORCE_COLOR: '', ...env },
+ timeout: 15_000,
+ },
+ );
+ const lines = JSON.parse(output) as string[];
+ assert.deepEqual(lines.map(stripAnsi), code.split('\n'), name);
+ assert.equal(/\x1b\[[\d;]*m/u.test(lines.join('\n')), colored, name);
+ if (name === 'truecolor') assert.match(lines.join('\n'), /\x1b\[38;2;/u);
+ if (name === 'basic ANSI') assert.doesNotMatch(lines.join('\n'), /\x1b\[38;[25];/u);
+ }
+});
+
+test('highlighting preserves every live and detailed row during streaming and resizing', async (t) => {
+ const source = [
+ 'const greeting: string = "你好<&>";',
+ '/* multi-line comment',
+ ' with e\u0301 and 😀 */',
+ '',
+ '\tconsole.log(greeting); // ' + 'long-code-'.repeat(8),
+ ].join('\n');
+ // Load the real production styles after color capability detection, with no
+ // mutation of the parent process's theme or assumptions about its stdout.
+ const script = [
+ 'const [layoutUrl, transcriptUrl, formatUrl, openText, closedText] = process.argv.slice(1);',
+ 'const { MakaTranscriptComponent } = await import(layoutUrl);',
+ 'const { createMakaPiTranscriptState } = await import(transcriptUrl);',
+ 'const { markdownTheme } = await import(formatUrl);',
+ 'const highlightCode = markdownTheme.highlightCode;',
+ 'if (!highlightCode) throw new Error("missing shared Markdown hook");',
+ 'function transcriptFor(text) {',
+ ' const state = createMakaPiTranscriptState();',
+ ' const entry = { kind: "assistant", messageId: "assistant-code", text };',
+ ' state.entries.push(entry);',
+ ' const transcript = new MakaTranscriptComponent(state, () => ({',
+ ' title: "Maka", cwd: "/repo", model: "model", connectionSlug: "connection", permissionMode: "ask",',
+ ' }));',
+ ' return { transcript, entry };',
+ '}',
+ 'const results = [];',
+ 'for (const [closed, text] of [[false, openText], [true, closedText]]) {',
+ ' markdownTheme.highlightCode = undefined;',
+ ' const baseline = transcriptFor(text);',
+ ' const baselineDocument = baseline.transcript.createDocumentRenderer();',
+ ' const before = [8, 24, 40, 100].map(width => ({',
+ ' width, live: baseline.transcript.render(width), detailed: baselineDocument(width).lines,',
+ ' }));',
+ ' markdownTheme.highlightCode = highlightCode;',
+ ' const highlighted = transcriptFor(text);',
+ ' const highlightedDocument = highlighted.transcript.createDocumentRenderer();',
+ ' for (const previous of before) results.push({',
+ ' closed, ...previous, afterLive: highlighted.transcript.render(previous.width),',
+ ' afterDetailed: highlightedDocument(previous.width).lines,',
+ ' sourceUnchanged: baseline.entry.text === text && highlighted.entry.text === text,',
+ ' });',
+ '}',
+ 'process.stdout.write(JSON.stringify(results));',
+ ].join('\n');
+ const openText = `Code example:\n\n\`\`\`ts\n${source}`;
+ for (const [name, env, colored] of [
+ ['truecolor', { TERM: 'xterm-256color', COLORTERM: 'truecolor', NO_COLOR: '' }, true],
+ ['basic ANSI', { TERM: 'xterm', COLORTERM: '', NO_COLOR: '' }, true],
+ ['NO_COLOR', { TERM: 'xterm-256color', COLORTERM: 'truecolor', NO_COLOR: '1' }, false],
+ ] as const) {
+ await t.test(name, () => {
+ const output = execFileSync(
+ process.execPath,
+ [
+ '--input-type=module',
+ '-e',
+ script,
+ new URL('../pi-tui-layout.js', import.meta.url).href,
+ new URL('../pi-transcript.js', import.meta.url).href,
+ new URL('../pi-transcript-format.js', import.meta.url).href,
+ openText,
+ `${openText}\n\`\`\`\n\nAfter the code.`,
+ ],
+ {
+ encoding: 'utf8',
+ timeout: 15_000,
+ env: {
+ ...process.env,
+ FORCE_COLOR: '',
+ ...env,
+ },
+ },
+ );
+ const results = JSON.parse(output) as Array<{
+ closed: boolean;
+ width: number;
+ live: string[];
+ detailed: string[];
+ afterLive: string[];
+ afterDetailed: string[];
+ sourceUnchanged: boolean;
+ }>;
+ assert.equal(results.length, 8);
+ for (const {
+ closed,
+ width,
+ live,
+ detailed,
+ afterLive,
+ afterDetailed,
+ sourceUnchanged,
+ } of results) {
+ const context = `${closed ? 'closed' : 'streaming'} fence at width ${width}`;
+ assert.equal(/\x1b\[[\d;]*m/u.test(afterLive.join('\n')), colored, `live ${context}`);
+ assert.equal(
+ /\x1b\[[\d;]*m/u.test(afterDetailed.join('\n')),
+ colored,
+ `detailed ${context}`,
+ );
+ assert.deepEqual(afterLive.map(stripAnsi), live.map(stripAnsi), `live ${context}`);
+ assert.deepEqual(
+ afterDetailed.map(stripAnsi),
+ detailed.map(stripAnsi),
+ `detailed ${context}`,
+ );
+ assert.deepEqual(afterLive.map(stripAnsi), afterDetailed.map(stripAnsi), context);
+ assert.equal(sourceUnchanged, true, context);
+ }
+ });
+ }
+});
diff --git a/packages/cli/src/pi-transcript-format.ts b/packages/cli/src/pi-transcript-format.ts
index 420b6a55b86..9b2b1c6edb3 100644
--- a/packages/cli/src/pi-transcript-format.ts
+++ b/packages/cli/src/pi-transcript-format.ts
@@ -28,6 +28,7 @@ import { projectAgentSwarmResult } from '@maka/core/agent-swarm';
import { ptyHumanTerminalText } from '@maka/core/pty-output-view';
import { type ShellOutput } from '@maka/core/shell-run';
import { ansi } from './tui-ansi.js';
+import { highlightMarkdownCode } from './tui-syntax-highlight.js';
export function renderIndented(text: string, width: number, indent: number): string[] {
const prefix = ' '.repeat(indent);
@@ -185,6 +186,7 @@ export const markdownTheme: MarkdownTheme = {
code: ansi.yellow,
codeBlock: (text) => text,
codeBlockBorder: ansi.dim,
+ highlightCode: highlightMarkdownCode,
quote: ansi.dim,
quoteBorder: ansi.dim,
hr: ansi.dim,
diff --git a/packages/cli/src/tui-syntax-highlight.ts b/packages/cli/src/tui-syntax-highlight.ts
new file mode 100644
index 00000000000..2587bc4f589
--- /dev/null
+++ b/packages/cli/src/tui-syntax-highlight.ts
@@ -0,0 +1,243 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import hljs from 'highlight.js/lib/core';
+import bash from 'highlight.js/lib/languages/bash';
+import c from 'highlight.js/lib/languages/c';
+import cpp from 'highlight.js/lib/languages/cpp';
+import csharp from 'highlight.js/lib/languages/csharp';
+import css from 'highlight.js/lib/languages/css';
+import diff from 'highlight.js/lib/languages/diff';
+import go from 'highlight.js/lib/languages/go';
+import java from 'highlight.js/lib/languages/java';
+import javascript from 'highlight.js/lib/languages/javascript';
+import json from 'highlight.js/lib/languages/json';
+import markdown from 'highlight.js/lib/languages/markdown';
+import powershell from 'highlight.js/lib/languages/powershell';
+import python from 'highlight.js/lib/languages/python';
+import rust from 'highlight.js/lib/languages/rust';
+import sql from 'highlight.js/lib/languages/sql';
+import typescript from 'highlight.js/lib/languages/typescript';
+import xml from 'highlight.js/lib/languages/xml';
+import yaml from 'highlight.js/lib/languages/yaml';
+import { ansi } from './tui-ansi.js';
+
+type HighlightFormatter = (text: string) => string;
+
+export type TuiSyntaxHighlightTheme = Readonly>>;
+
+const languages = {
+ bash,
+ c,
+ cpp,
+ csharp,
+ css,
+ diff,
+ go,
+ java,
+ javascript,
+ json,
+ markdown,
+ powershell,
+ python,
+ rust,
+ sql,
+ typescript,
+ xml,
+ yaml,
+};
+
+for (const [name, language] of Object.entries(languages)) {
+ hljs.registerLanguage(name, language);
+}
+
+/**
+ * Fence aliases are intentionally explicit. Unknown, blank, and plain-text
+ * tags stay unhighlighted instead of invoking unreliable auto-detection.
+ */
+const languageAliases: Readonly> = {
+ sh: 'bash',
+ shell: 'bash',
+ zsh: 'bash',
+ 'c++': 'cpp',
+ cs: 'csharp',
+ html: 'xml',
+ htm: 'xml',
+ svg: 'xml',
+ js: 'javascript',
+ jsx: 'javascript',
+ mjs: 'javascript',
+ cjs: 'javascript',
+ md: 'markdown',
+ ps1: 'powershell',
+ py: 'python',
+ rs: 'rust',
+ ts: 'typescript',
+ tsx: 'typescript',
+ yml: 'yaml',
+};
+
+const defaultTheme: TuiSyntaxHighlightTheme = {
+ keyword: ansi.accent,
+ built_in: ansi.yellow,
+ literal: ansi.yellow,
+ number: ansi.yellow,
+ regexp: ansi.green,
+ string: ansi.green,
+ comment: ansi.dim,
+ doctag: ansi.dim,
+ meta: ansi.muted,
+ function: ansi.accent,
+ title: ansi.accent,
+ class: ansi.accent,
+ type: ansi.accent,
+ tag: ansi.accent,
+ name: ansi.accent,
+ attr: ansi.yellow,
+ attribute: ansi.yellow,
+ variable: ansi.yellow,
+ params: ansi.muted,
+ operator: ansi.yellow,
+ addition: ansi.green,
+ deletion: ansi.red,
+};
+
+/**
+ * Highlight a Markdown fence into independent terminal lines.
+ *
+ * The optional theme keeps token classification testable without assuming a
+ * TTY. Production uses Maka's capability-aware ANSI functions, so NO_COLOR and
+ * basic terminals inherit the same behavior as the rest of the TUI.
+ */
+export function highlightMarkdownCode(
+ code: string,
+ language?: string,
+ theme: TuiSyntaxHighlightTheme = defaultTheme,
+): string[] {
+ // The Markdown hook is synchronous and reruns as a fence streams. Bound its
+ // input to 4,096 UTF-16 code units: grammars can take superlinear time on
+ // repetitive input. Larger blocks retain plain text on the input path.
+ if (code.length > 4 * 1024) return code.split('\n');
+ const normalizedLanguage = normalizeLanguage(language);
+ if (!normalizedLanguage) return code.split('\n');
+
+ try {
+ const html = hljs.highlight(code, {
+ language: normalizedLanguage,
+ ignoreIllegals: true,
+ }).value;
+ return renderHighlightedHtml(html, theme).split('\n');
+ } catch {
+ // Rendering a response must not fail because a grammar or theme failed.
+ return code.split('\n');
+ }
+}
+
+function normalizeLanguage(language: string | undefined): string | undefined {
+ const requested = language?.trim().toLowerCase();
+ if (!requested) return undefined;
+ const normalized = languageAliases[requested] ?? requested;
+ return Object.hasOwn(languages, normalized) ? normalized : undefined;
+}
+
+const HTML_TOKEN = /]*>|<\/span>|&(?:amp|lt|gt|quot|apos|#(?:\d+|x[\da-f]+));/giu;
+
+function renderHighlightedHtml(html: string, theme: TuiSyntaxHighlightTheme): string {
+ const scopes: Array = [];
+ let output = '';
+ let cursor = 0;
+
+ for (const match of html.matchAll(HTML_TOKEN)) {
+ const index = match.index;
+ appendStyled(html.slice(cursor, index));
+ const token = match[0];
+
+ if (token.startsWith('') {
+ scopes.pop();
+ } else {
+ appendStyled(decodeHtmlEntity(token));
+ }
+ cursor = index + token.length;
+ }
+ appendStyled(html.slice(cursor));
+ return output;
+
+ function appendStyled(text: string): void {
+ if (!text) return;
+ const formatter = activeFormatter(scopes, theme);
+ if (!formatter) {
+ output += text;
+ return;
+ }
+ // Keep every rendered line self-contained. A multi-line comment must not
+ // leave an ANSI style open across the array boundary consumed by pi-tui.
+ output += text
+ .split('\n')
+ .map((line) => (line ? formatter(line) : ''))
+ .join('\n');
+ }
+}
+
+function scopeFromSpan(tag: string): string | undefined {
+ const classes = /\bclass=(?:"([^"]*)"|'([^']*)')/u.exec(tag);
+ return (classes?.[1] ?? classes?.[2])
+ ?.split(/\s+/u)
+ .find((className) => className.startsWith('hljs-'))
+ ?.slice('hljs-'.length);
+}
+
+function activeFormatter(
+ scopes: readonly (string | undefined)[],
+ theme: TuiSyntaxHighlightTheme,
+): HighlightFormatter | undefined {
+ for (let index = scopes.length - 1; index >= 0; index -= 1) {
+ const scope = scopes[index];
+ if (!scope) continue;
+ const [prefix] = scope.split(/[.-]/u);
+ const formatter = theme[scope] ?? (prefix ? theme[prefix] : undefined);
+ if (formatter) return formatter;
+ }
+ return theme.default;
+}
+
+function decodeHtmlEntity(entity: string): string {
+ const body = entity.slice(1, -1);
+ switch (body) {
+ case 'amp':
+ return '&';
+ case 'lt':
+ return '<';
+ case 'gt':
+ return '>';
+ case 'quot':
+ return '"';
+ case 'apos':
+ return "'";
+ default: {
+ const radix = body.slice(0, 2).toLowerCase() === '#x' ? 16 : 10;
+ const digits = body.slice(radix === 16 ? 2 : 1);
+ const codePoint = Number.parseInt(digits, radix);
+ return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff
+ ? String.fromCodePoint(codePoint)
+ : entity;
+ }
+ }
+}
diff --git a/patches/@earendil-works+pi-tui+0.87.1.patch b/patches/@earendil-works+pi-tui+0.87.1.patch
index a4ceefa673d..89fd4b87cd5 100644
--- a/patches/@earendil-works+pi-tui+0.87.1.patch
+++ b/patches/@earendil-works+pi-tui+0.87.1.patch
@@ -1,5 +1,5 @@
diff --git a/node_modules/@earendil-works/pi-tui/dist/components/editor.js b/node_modules/@earendil-works/pi-tui/dist/components/editor.js
-index 7eae102..9d00340 100644
+index 7eae102..bcdcf6c 100644
--- a/node_modules/@earendil-works/pi-tui/dist/components/editor.js
+++ b/node_modules/@earendil-works/pi-tui/dist/components/editor.js
@@ -2,7 +2,6 @@ import { getKeybindings } from "../keybindings.js";
@@ -43,3 +43,29 @@ index 7eae102..9d00340 100644
}
undo() {
this.exitHistoryBrowsing();
+diff --git a/node_modules/@earendil-works/pi-tui/dist/utils.js b/node_modules/@earendil-works/pi-tui/dist/utils.js
+index 31bb2e1..484c89e 100644
+--- a/node_modules/@earendil-works/pi-tui/dist/utils.js
++++ b/node_modules/@earendil-works/pi-tui/dist/utils.js
+@@ -803,10 +803,10 @@ function wrapSingleLine(line, width) {
+ let currentVisibleLength = 0;
+ for (const token of tokens) {
+ const tokenVisibleLength = visibleWidth(token);
+- const isWhitespace = token.trim() === "";
++ const isWhitespace = stripTerminalSequences(token).trim() === "";
+ // Token itself is too long - break it character by character
+ if (tokenVisibleLength > width && !isWhitespace) {
+- if (currentLine) {
++ if (stripTerminalSequences(currentLine)) {
+ // Add specific reset for underline only (preserves background)
+ const lineEndReset = tracker.getLineEndReset();
+ if (lineEndReset) {
+@@ -852,7 +852,7 @@ function wrapSingleLine(line, width) {
+ }
+ updateTrackerFromText(token, tracker);
+ }
+- if (currentLine) {
++ if (stripTerminalSequences(currentLine)) {
+ // No reset at end of final line - let caller handle it
+ wrapped.push(currentLine);
+ }
diff --git a/scripts/check-tui-copy.mjs b/scripts/check-tui-copy.mjs
index e03e5f6f41a..98f89a34a63 100644
--- a/scripts/check-tui-copy.mjs
+++ b/scripts/check-tui-copy.mjs
@@ -58,6 +58,7 @@ export const EXCLUDED_TUI_FILES = [
'packages/cli/src/tui-editor-render.ts',
'packages/cli/src/tui-mcp-control.ts',
'packages/cli/src/tui-mcp-remote-publication.ts',
+ 'packages/cli/src/tui-syntax-highlight.ts',
];
const CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/u;
diff --git a/scripts/release-cli-file-policy.mjs b/scripts/release-cli-file-policy.mjs
index d29c5805b19..9ef7e241a9a 100644
--- a/scripts/release-cli-file-policy.mjs
+++ b/scripts/release-cli-file-policy.mjs
@@ -211,6 +211,51 @@ export function releaseNpmEnvironment(environment, userConfigPath) {
};
}
+export const CLI_HIGHLIGHT_JS_LANGUAGE_MODULES = Object.freeze([
+ 'bash',
+ 'c',
+ 'cpp',
+ 'csharp',
+ 'css',
+ 'diff',
+ 'go',
+ 'java',
+ 'javascript',
+ 'json',
+ 'markdown',
+ 'powershell',
+ 'python',
+ 'rust',
+ 'sql',
+ 'typescript',
+ 'xml',
+ 'yaml',
+]);
+
+const HIGHLIGHT_JS_RUNTIME_DIRECTORIES = new Set(['es', 'es/languages', 'lib']);
+const HIGHLIGHT_JS_RUNTIME_FILES = new Set([
+ 'LICENSE',
+ 'package.json',
+ 'es/core.js',
+ 'es/package.json',
+ 'lib/core.js',
+ ...CLI_HIGHLIGHT_JS_LANGUAGE_MODULES.map((language) => `es/languages/${language}.js`),
+]);
+
+/**
+ * Keep the CLI release's syntax highlighter dependency to its actual ESM
+ * runtime closure. npm publishes every highlight.js grammar and theme in one
+ * package, while Maka deliberately registers a bounded language set.
+ */
+export function isCliThirdPartyReleasePath(packageKey, relativePath) {
+ if (packageKey !== 'highlight.js@11.12.0') return true;
+ const portablePath = relativePath.split(/[\\/]/u).filter(Boolean).join('/');
+ return (
+ HIGHLIGHT_JS_RUNTIME_DIRECTORIES.has(portablePath) ||
+ HIGHLIGHT_JS_RUNTIME_FILES.has(portablePath)
+ );
+}
+
export function isThirdPartyDevelopmentArtifact(relativePath) {
const segments = relativePath.split(/[\\/]/).filter(Boolean);
if (segments.some((segment) => DEVELOPMENT_DIRECTORIES.has(segment))) return true;
diff --git a/scripts/release-cli-file-policy.test.mjs b/scripts/release-cli-file-policy.test.mjs
index 0cbf0c401ff..63ec396d807 100644
--- a/scripts/release-cli-file-policy.test.mjs
+++ b/scripts/release-cli-file-policy.test.mjs
@@ -31,7 +31,9 @@ import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { describe, test } from 'node:test';
import {
+ CLI_HIGHLIGHT_JS_LANGUAGE_MODULES,
collectWorkspaceDependencyClosure,
+ isCliThirdPartyReleasePath,
isCurrentDevelopmentJavaScript,
isMakaDevelopmentArtifact,
isThirdPartyDevelopmentArtifact,
@@ -208,6 +210,43 @@ describe('CLI release file policy', () => {
}
});
+ test('keeps only the reviewed highlight.js runtime closure in CLI artifacts', () => {
+ for (const path of [
+ 'package.json',
+ 'LICENSE',
+ 'es',
+ 'es/core.js',
+ 'es/package.json',
+ 'es/languages',
+ 'es/languages/typescript.js',
+ 'lib',
+ 'lib/core.js',
+ ]) {
+ assert.equal(isCliThirdPartyReleasePath('highlight.js@11.12.0', path), true, path);
+ }
+ for (const path of [
+ 'README.md',
+ 'es/index.js',
+ 'es/languages/ada.js',
+ 'lib/languages/typescript.js',
+ 'styles/github.css',
+ 'types/index.d.ts',
+ ]) {
+ assert.equal(isCliThirdPartyReleasePath('highlight.js@11.12.0', path), false, path);
+ }
+
+ assert.equal(isCliThirdPartyReleasePath('another-package@1.0.0', 'README.md'), true);
+
+ const highlighterSource = readFileSync(
+ resolve(import.meta.dirname, '../packages/cli/src/tui-syntax-highlight.ts'),
+ 'utf8',
+ );
+ const importedLanguages = [
+ ...highlighterSource.matchAll(/from 'highlight\.js\/lib\/languages\/([^']+)'/gu),
+ ].map((match) => match[1]);
+ assert.deepEqual(importedLanguages.sort(), [...CLI_HIGHLIGHT_JS_LANGUAGE_MODULES].sort());
+ });
+
test('keeps the stricter Maka-owned package boundary', () => {
assert.equal(isMakaDevelopmentArtifact('src/index.js'), true);
assert.equal(isMakaDevelopmentArtifact('dist/__tests__/fixture.js'), true);
diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs
index fb4aaacaf83..2344d9250c2 100644
--- a/scripts/release-cli-package.mjs
+++ b/scripts/release-cli-package.mjs
@@ -41,6 +41,7 @@ import { npmSpawnOptions } from './npm-spawn.mjs';
import { assertProductNightlyVersion } from './release-version.mjs';
import {
isCurrentDevelopmentJavaScript,
+ isCliThirdPartyReleasePath,
isMakaDevelopmentArtifact,
isThirdPartyDevelopmentArtifact,
orderWorkspaceBuilds,
@@ -453,9 +454,12 @@ function copyRuntimeDist(source, destination, packageName = 'maka-agent') {
}
function copyThirdPartyPackage(source, destination) {
- if (!existsSync(join(source, 'package.json'))) {
+ const manifestPath = join(source, 'package.json');
+ if (!existsSync(manifestPath)) {
throw new Error(`Installed dependency has no package.json: ${source}`);
}
+ const manifest = readJson(manifestPath);
+ const packageKey = `${manifest.name}@${manifest.version}`;
rmSync(destination, { recursive: true, force: true });
cpSync(source, destination, {
recursive: true,
@@ -465,7 +469,8 @@ function copyThirdPartyPackage(source, destination) {
const relativePath = relative(source, path);
return (
!relativePath.split(sep).includes('node_modules') &&
- !isThirdPartyDevelopmentArtifact(relativePath)
+ !isThirdPartyDevelopmentArtifact(relativePath) &&
+ isCliThirdPartyReleasePath(packageKey, relativePath)
);
},
});