From f8e6d62db7227c997a6f1134bfa8a4bb6da77666 Mon Sep 17 00:00:00 2001 From: Sergii Kozak Date: Wed, 17 Dec 2025 20:15:44 -0800 Subject: [PATCH 1/4] Add syntax highlighting to markdown code blocks --- package-lock.json | 236 +++++++++++++++++- packages/coding-agent/package.json | 1 + .../src/modes/interactive/theme/theme.ts | 58 +++++ packages/tui/src/components/markdown.ts | 29 ++- 4 files changed, 313 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9bd8c93b..6fa531f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2583,6 +2583,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2806,6 +2812,169 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-highlight/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cli-highlight/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/cli-highlight/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-highlight/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-highlight/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-highlight/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-highlight/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3294,7 +3463,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3562,7 +3730,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4551,6 +4718,17 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -4634,6 +4812,15 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ollama": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz", @@ -4742,6 +4929,27 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "license": "MIT" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "license": "MIT", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "license": "MIT" + }, "node_modules/partial-json": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", @@ -4937,7 +5145,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5502,6 +5709,27 @@ "node": ">=6" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -6065,7 +6293,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -6242,6 +6469,7 @@ "@mariozechner/pi-ai": "^0.23.3", "@mariozechner/pi-tui": "^0.23.3", "chalk": "^5.5.0", + "cli-highlight": "^2.1.11", "diff": "^8.0.2", "file-type": "^21.1.1", "glob": "^11.0.3", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 94cb8ef7..4cf7f2dc 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -44,6 +44,7 @@ "@mariozechner/pi-ai": "^0.23.3", "@mariozechner/pi-tui": "^0.23.3", "chalk": "^5.5.0", + "cli-highlight": "^2.1.11", "diff": "^8.0.2", "file-type": "^21.1.1", "glob": "^11.0.3", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index ec928beb..db7a6bcc 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs"; +import { createRequire } from "node:module"; import * as path from "node:path"; import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@mariozechner/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; @@ -630,6 +631,50 @@ export function stopThemeWatcher(): void { // TUI Helpers // ============================================================================ +type CliHighlightTheme = Record string>; + +let cachedHighlightThemeFor: Theme | undefined; +let cachedCliHighlightTheme: CliHighlightTheme | undefined; + +function buildCliHighlightTheme(t: Theme): CliHighlightTheme { + return { + keyword: (s: string) => t.fg("syntaxKeyword", s), + built_in: (s: string) => t.fg("syntaxType", s), + literal: (s: string) => t.fg("syntaxNumber", s), + number: (s: string) => t.fg("syntaxNumber", s), + string: (s: string) => t.fg("syntaxString", s), + comment: (s: string) => t.fg("syntaxComment", s), + function: (s: string) => t.fg("syntaxFunction", s), + title: (s: string) => t.fg("syntaxFunction", s), + class: (s: string) => t.fg("syntaxType", s), + type: (s: string) => t.fg("syntaxType", s), + attr: (s: string) => t.fg("syntaxVariable", s), + variable: (s: string) => t.fg("syntaxVariable", s), + params: (s: string) => t.fg("syntaxVariable", s), + operator: (s: string) => t.fg("syntaxOperator", s), + punctuation: (s: string) => t.fg("syntaxPunctuation", s), + }; +} + +function getCliHighlightTheme(t: Theme): CliHighlightTheme { + if (cachedHighlightThemeFor !== t || !cachedCliHighlightTheme) { + cachedHighlightThemeFor = t; + cachedCliHighlightTheme = buildCliHighlightTheme(t); + } + return cachedCliHighlightTheme; +} + +function requireCliHighlight(): { highlight: (code: string, opts?: any) => string } { + try { + const require = createRequire(import.meta.url); + return require("cli-highlight"); + } catch { + return { + highlight: (code: string) => code, + }; + } +} + export function getMarkdownTheme(): MarkdownTheme { return { heading: (text: string) => theme.fg("mdHeading", text), @@ -646,6 +691,19 @@ export function getMarkdownTheme(): MarkdownTheme { italic: (text: string) => theme.italic(text), underline: (text: string) => theme.underline(text), strikethrough: (text: string) => chalk.strikethrough(text), + highlightCode: (code: string, lang?: string): string[] => { + const { highlight } = requireCliHighlight(); + const opts: any = { + language: lang, + ignoreIllegals: true, + theme: getCliHighlightTheme(theme), + }; + try { + return highlight(code, opts).split("\n"); + } catch { + return code.split("\n").map((line) => theme.fg("mdCodeBlock", line)); + } + }, }; } diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index c601c7be..d6e8671f 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -40,6 +40,7 @@ export interface MarkdownTheme { italic: (text: string) => string; strikethrough: (text: string) => string; underline: (text: string) => string; + highlightCode?: (code: string, lang?: string) => string[]; } export class Markdown implements Component { @@ -263,10 +264,17 @@ export class Markdown implements Component { case "code": { lines.push(this.theme.codeBlockBorder("```" + (token.lang || ""))); - // Split code by newlines and style each line - const codeLines = token.text.split("\n"); - for (const codeLine of codeLines) { - lines.push(" " + this.theme.codeBlock(codeLine)); + if (this.theme.highlightCode) { + const highlightedLines = this.theme.highlightCode(token.text, token.lang); + for (const hlLine of highlightedLines) { + lines.push(" " + hlLine); + } + } else { + // Split code by newlines and style each line + const codeLines = token.text.split("\n"); + for (const codeLine of codeLines) { + lines.push(" " + this.theme.codeBlock(codeLine)); + } } lines.push(this.theme.codeBlockBorder("```")); if (nextTokenType !== "space") { @@ -471,9 +479,16 @@ export class Markdown implements Component { } else if (token.type === "code") { // Code block in list item lines.push(this.theme.codeBlockBorder("```" + (token.lang || ""))); - const codeLines = token.text.split("\n"); - for (const codeLine of codeLines) { - lines.push(" " + this.theme.codeBlock(codeLine)); + if (this.theme.highlightCode) { + const highlightedLines = this.theme.highlightCode(token.text, token.lang); + for (const hlLine of highlightedLines) { + lines.push(" " + hlLine); + } + } else { + const codeLines = token.text.split("\n"); + for (const codeLine of codeLines) { + lines.push(" " + this.theme.codeBlock(codeLine)); + } } lines.push(this.theme.codeBlockBorder("```")); } else { From d2088f7789c37b365f8faef7ec839849d4d7fcd7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 18 Dec 2025 15:47:15 +0100 Subject: [PATCH 2/4] Fix: handle IMAGE_RECITATION and IMAGE_OTHER FinishReason in Google provider --- packages/ai/src/providers/google.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index bf9e8050..6e0546e2 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -469,6 +469,8 @@ function mapStopReason(reason: FinishReason): StopReason { case FinishReason.IMAGE_SAFETY: case FinishReason.IMAGE_PROHIBITED_CONTENT: case FinishReason.RECITATION: + case FinishReason.IMAGE_RECITATION: + case FinishReason.IMAGE_OTHER: case FinishReason.FINISH_REASON_UNSPECIFIED: case FinishReason.OTHER: case FinishReason.LANGUAGE: From 1a944f50f935c28a967ac83f77f31c46e3cc05ae Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 18 Dec 2025 16:01:01 +0100 Subject: [PATCH 3/4] Syntax highlighting improvements - Fix cli-highlight import (use static import instead of dynamic require) - Add syntax highlighting to read/write tool output - Update dark/light themes with VS Code default syntax colors - Add highlightCode and getLanguageFromPath exports - Upgrade @google/genai to 1.34.0 for IMAGE_RECITATION/IMAGE_OTHER FinishReason - Add AGENTS.md rule: never downgrade code to fix type errors from outdated deps --- AGENTS.md | 2 + package-lock.json | 10 +- packages/ai/package.json | 2 +- .../interactive/components/tool-execution.ts | 28 ++++-- .../src/modes/interactive/theme/dark.json | 18 ++-- .../src/modes/interactive/theme/light.json | 18 ++-- .../src/modes/interactive/theme/theme.ts | 94 +++++++++++++++++-- 7 files changed, 133 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3e35c109..1191b958 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,8 @@ read README.md, then ask which module(s) to work on. Based on the answer, read t - No `any` types unless absolutely necessary - Check node_modules for external API type definitions instead of guessing - No inline imports like `await import("./foo.js")` +- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead +- Always ask before removing functionality or code that appears to be intentional ## Commands - After code changes: `npm run check` (get full output, no tail) diff --git a/package-lock.json b/package-lock.json index 6fa531f0..3ee012b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -675,9 +675,9 @@ } }, "node_modules/@google/genai": { - "version": "1.31.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.31.0.tgz", - "integrity": "sha512-rK0RKXxNkbK35eDl+G651SxtxwHNEOogjyeZJUJe+Ed4yxu3xy5ufCiU0+QLT7xo4M9Spey8OAYfD8LPRlYBKw==", + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.34.0.tgz", + "integrity": "sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw==", "license": "Apache-2.0", "dependencies": { "google-auth-library": "^10.3.0", @@ -687,7 +687,7 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.20.1" + "@modelcontextprotocol/sdk": "^1.24.0" }, "peerDependenciesMeta": { "@modelcontextprotocol/sdk": { @@ -6424,7 +6424,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.71.2", - "@google/genai": "1.31.0", + "@google/genai": "^1.34.0", "@mistralai/mistralai": "1.10.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", diff --git a/packages/ai/package.json b/packages/ai/package.json index 4a7559e5..e466e06a 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "0.71.2", - "@google/genai": "1.31.0", + "@google/genai": "^1.34.0", "@mistralai/mistralai": "1.10.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 6d2d7d2d..61e0113d 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -12,7 +12,7 @@ import { import stripAnsi from "strip-ansi"; import type { CustomAgentTool } from "../../../core/custom-tools/types.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js"; -import { theme } from "../theme/theme.js"; +import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js"; /** * Convert absolute path to tilde notation if it's in home directory @@ -280,13 +280,19 @@ export class ToolExecutionComponent extends Container { if (this.result) { const output = this.getTextOutput(); - const lines = output.split("\n"); + const rawPath = this.args?.file_path || this.args?.path || ""; + const lang = getLanguageFromPath(rawPath); + const lines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n"); const maxLines = this.expanded ? lines.length : 10; const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; - text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", replaceTabs(line))).join("\n"); + text += + "\n\n" + + displayLines + .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))) + .join("\n"); if (remaining > 0) { text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); } @@ -318,9 +324,15 @@ export class ToolExecutionComponent extends Container { } } } else if (this.toolName === "write") { - const path = shortenPath(this.args?.file_path || this.args?.path || ""); + const rawPath = this.args?.file_path || this.args?.path || ""; + const path = shortenPath(rawPath); const fileContent = this.args?.content || ""; - const lines = fileContent ? fileContent.split("\n") : []; + const lang = getLanguageFromPath(rawPath); + const lines = fileContent + ? lang + ? highlightCode(replaceTabs(fileContent), lang) + : fileContent.split("\n") + : []; const totalLines = lines.length; text = @@ -336,7 +348,11 @@ export class ToolExecutionComponent extends Container { const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; - text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", replaceTabs(line))).join("\n"); + text += + "\n\n" + + displayLines + .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))) + .join("\n"); if (remaining > 0) { text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); } diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index 28b84c4d..51ad7749 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -51,15 +51,15 @@ "toolDiffRemoved": "red", "toolDiffContext": "gray", - "syntaxComment": "gray", - "syntaxKeyword": "cyan", - "syntaxFunction": "blue", - "syntaxVariable": "", - "syntaxString": "green", - "syntaxNumber": "yellow", - "syntaxType": "cyan", - "syntaxOperator": "", - "syntaxPunctuation": "gray", + "syntaxComment": "#6A9955", + "syntaxKeyword": "#569CD6", + "syntaxFunction": "#DCDCAA", + "syntaxVariable": "#9CDCFE", + "syntaxString": "#CE9178", + "syntaxNumber": "#B5CEA8", + "syntaxType": "#4EC9B0", + "syntaxOperator": "#D4D4D4", + "syntaxPunctuation": "#D4D4D4", "thinkingOff": "darkGray", "thinkingMinimal": "#6e6e6e", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index 09405d14..57eb2643 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -50,15 +50,15 @@ "toolDiffRemoved": "red", "toolDiffContext": "mediumGray", - "syntaxComment": "mediumGray", - "syntaxKeyword": "teal", - "syntaxFunction": "blue", - "syntaxVariable": "", - "syntaxString": "green", - "syntaxNumber": "yellow", - "syntaxType": "teal", - "syntaxOperator": "", - "syntaxPunctuation": "mediumGray", + "syntaxComment": "#008000", + "syntaxKeyword": "#0000FF", + "syntaxFunction": "#795E26", + "syntaxVariable": "#001080", + "syntaxString": "#A31515", + "syntaxNumber": "#098658", + "syntaxType": "#267F99", + "syntaxOperator": "#000000", + "syntaxPunctuation": "#000000", "thinkingOff": "lightGray", "thinkingMinimal": "#9e9e9e", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index db7a6bcc..f02018a1 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -1,10 +1,10 @@ import * as fs from "node:fs"; -import { createRequire } from "node:module"; import * as path from "node:path"; import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@mariozechner/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; import { TypeCompiler } from "@sinclair/typebox/compiler"; import chalk from "chalk"; +import { highlight } from "cli-highlight"; import { getCustomThemesDir, getThemesDir } from "../../../config.js"; // ============================================================================ @@ -664,17 +664,94 @@ function getCliHighlightTheme(t: Theme): CliHighlightTheme { return cachedCliHighlightTheme; } -function requireCliHighlight(): { highlight: (code: string, opts?: any) => string } { +/** + * Highlight code with syntax coloring based on file extension or language. + * Returns array of highlighted lines. + */ +export function highlightCode(code: string, lang?: string): string[] { + const opts = { + language: lang, + ignoreIllegals: true, + theme: getCliHighlightTheme(theme), + }; try { - const require = createRequire(import.meta.url); - return require("cli-highlight"); + return highlight(code, opts).split("\n"); } catch { - return { - highlight: (code: string) => code, - }; + return code.split("\n"); } } +/** + * Get language identifier from file path extension. + */ +export function getLanguageFromPath(filePath: string): string | undefined { + const ext = filePath.split(".").pop()?.toLowerCase(); + if (!ext) return undefined; + + const extToLang: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + py: "python", + rb: "ruby", + rs: "rust", + go: "go", + java: "java", + kt: "kotlin", + swift: "swift", + c: "c", + h: "c", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + hpp: "cpp", + cs: "csharp", + php: "php", + sh: "bash", + bash: "bash", + zsh: "bash", + fish: "fish", + ps1: "powershell", + sql: "sql", + html: "html", + htm: "html", + css: "css", + scss: "scss", + sass: "sass", + less: "less", + json: "json", + yaml: "yaml", + yml: "yaml", + toml: "toml", + xml: "xml", + md: "markdown", + markdown: "markdown", + dockerfile: "dockerfile", + makefile: "makefile", + cmake: "cmake", + lua: "lua", + perl: "perl", + r: "r", + scala: "scala", + clj: "clojure", + ex: "elixir", + exs: "elixir", + erl: "erlang", + hs: "haskell", + ml: "ocaml", + vim: "vim", + graphql: "graphql", + proto: "protobuf", + tf: "hcl", + hcl: "hcl", + }; + + return extToLang[ext]; +} + export function getMarkdownTheme(): MarkdownTheme { return { heading: (text: string) => theme.fg("mdHeading", text), @@ -692,8 +769,7 @@ export function getMarkdownTheme(): MarkdownTheme { underline: (text: string) => theme.underline(text), strikethrough: (text: string) => chalk.strikethrough(text), highlightCode: (code: string, lang?: string): string[] => { - const { highlight } = requireCliHighlight(); - const opts: any = { + const opts = { language: lang, ignoreIllegals: true, theme: getCliHighlightTheme(theme), From 5117187362fad23752b0c6c512e0717bcf05cb79 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 18 Dec 2025 16:49:44 +0100 Subject: [PATCH 4/4] Add intra-line diff highlighting for edit tool - Extract diff rendering to dedicated diff.ts component - Show word-level changes with inverse highlighting when a single line is modified - Use diffWords for cleaner token grouping (includes adjacent whitespace) - Only apply intra-line diff when exactly 1 removed and 1 added line (true modification) - Multi-line changes show all removed then all added without incorrect pairing - Add theme.inverse() method for inverse text styling --- .../src/modes/interactive/components/diff.ts | 147 ++++++++++++++++++ .../interactive/components/tool-execution.ts | 16 +- .../src/modes/interactive/theme/theme.ts | 4 + 3 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/diff.ts diff --git a/packages/coding-agent/src/modes/interactive/components/diff.ts b/packages/coding-agent/src/modes/interactive/components/diff.ts new file mode 100644 index 00000000..d575d63e --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/diff.ts @@ -0,0 +1,147 @@ +import * as Diff from "diff"; +import { theme } from "../theme/theme.js"; + +/** + * Parse diff line to extract prefix, line number, and content. + * Format: "+123 content" or "-123 content" or " 123 content" or " ..." + */ +function parseDiffLine(line: string): { prefix: string; lineNum: string; content: string } | null { + const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/); + if (!match) return null; + return { prefix: match[1], lineNum: match[2], content: match[3] }; +} + +/** + * Replace tabs with spaces for consistent rendering. + */ +function replaceTabs(text: string): string { + return text.replace(/\t/g, " "); +} + +/** + * Compute word-level diff and render with inverse on changed parts. + * Uses diffWords which groups whitespace with adjacent words for cleaner highlighting. + * Strips leading whitespace from inverse to avoid highlighting indentation. + */ +function renderIntraLineDiff(oldContent: string, newContent: string): { removedLine: string; addedLine: string } { + const wordDiff = Diff.diffWords(oldContent, newContent); + + let removedLine = ""; + let addedLine = ""; + let isFirstRemoved = true; + let isFirstAdded = true; + + for (const part of wordDiff) { + if (part.removed) { + let value = part.value; + // Strip leading whitespace from the first removed part + if (isFirstRemoved) { + const leadingWs = value.match(/^(\s*)/)?.[1] || ""; + value = value.slice(leadingWs.length); + removedLine += leadingWs; + isFirstRemoved = false; + } + if (value) { + removedLine += theme.inverse(value); + } + } else if (part.added) { + let value = part.value; + // Strip leading whitespace from the first added part + if (isFirstAdded) { + const leadingWs = value.match(/^(\s*)/)?.[1] || ""; + value = value.slice(leadingWs.length); + addedLine += leadingWs; + isFirstAdded = false; + } + if (value) { + addedLine += theme.inverse(value); + } + } else { + removedLine += part.value; + addedLine += part.value; + } + } + + return { removedLine, addedLine }; +} + +export interface RenderDiffOptions { + /** File path (unused, kept for API compatibility) */ + filePath?: string; +} + +/** + * Render a diff string with colored lines and intra-line change highlighting. + * - Context lines: dim/gray + * - Removed lines: red, with inverse on changed tokens + * - Added lines: green, with inverse on changed tokens + */ +export function renderDiff(diffText: string, _options: RenderDiffOptions = {}): string { + const lines = diffText.split("\n"); + const result: string[] = []; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const parsed = parseDiffLine(line); + + if (!parsed) { + result.push(theme.fg("toolDiffContext", line)); + i++; + continue; + } + + if (parsed.prefix === "-") { + // Collect consecutive removed lines + const removedLines: { lineNum: string; content: string }[] = []; + while (i < lines.length) { + const p = parseDiffLine(lines[i]); + if (!p || p.prefix !== "-") break; + removedLines.push({ lineNum: p.lineNum, content: p.content }); + i++; + } + + // Collect consecutive added lines + const addedLines: { lineNum: string; content: string }[] = []; + while (i < lines.length) { + const p = parseDiffLine(lines[i]); + if (!p || p.prefix !== "+") break; + addedLines.push({ lineNum: p.lineNum, content: p.content }); + i++; + } + + // Only do intra-line diffing when there's exactly one removed and one added line + // (indicating a single line modification). Otherwise, show lines as-is. + if (removedLines.length === 1 && addedLines.length === 1) { + const removed = removedLines[0]; + const added = addedLines[0]; + + const { removedLine, addedLine } = renderIntraLineDiff( + replaceTabs(removed.content), + replaceTabs(added.content), + ); + + result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${removedLine}`)); + result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${addedLine}`)); + } else { + // Show all removed lines first, then all added lines + for (const removed of removedLines) { + result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${replaceTabs(removed.content)}`)); + } + for (const added of addedLines) { + result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${replaceTabs(added.content)}`)); + } + } + } else if (parsed.prefix === "+") { + // Standalone added line + result.push(theme.fg("toolDiffAdded", `+${parsed.lineNum} ${replaceTabs(parsed.content)}`)); + i++; + } else { + // Context line + result.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${replaceTabs(parsed.content)}`)); + i++; + } + } + + return result.join("\n"); +} diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 61e0113d..98dec495 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -13,6 +13,7 @@ import stripAnsi from "strip-ansi"; import type { CustomAgentTool } from "../../../core/custom-tools/types.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js"; import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js"; +import { renderDiff } from "./diff.js"; /** * Convert absolute path to tilde notation if it's in home directory @@ -358,7 +359,8 @@ export class ToolExecutionComponent extends Container { } } } else if (this.toolName === "edit") { - const path = shortenPath(this.args?.file_path || this.args?.path || ""); + const rawPath = this.args?.file_path || this.args?.path || ""; + const path = shortenPath(rawPath); text = theme.fg("toolTitle", theme.bold("edit")) + " " + @@ -371,17 +373,7 @@ export class ToolExecutionComponent extends Container { text += "\n\n" + theme.fg("error", errorText); } } else if (this.result.details?.diff) { - const diffLines = this.result.details.diff.split("\n"); - const coloredLines = diffLines.map((line: string) => { - if (line.startsWith("+")) { - return theme.fg("toolDiffAdded", line); - } else if (line.startsWith("-")) { - return theme.fg("toolDiffRemoved", line); - } else { - return theme.fg("toolDiffContext", line); - } - }); - text += "\n\n" + coloredLines.join("\n"); + text += "\n\n" + renderDiff(this.result.details.diff, { filePath: rawPath }); } } } else if (this.toolName === "ls") { diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index f02018a1..885f2c01 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -351,6 +351,10 @@ export class Theme { return chalk.underline(text); } + inverse(text: string): string { + return chalk.inverse(text); + } + getFgAnsi(color: ThemeColor): string { const ansi = this.fgColors.get(color); if (!ansi) throw new Error(`Unknown theme color: ${color}`);