From bb41d30737a5755816a8fd495cb4ccdc4349aa6e Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:05:36 +1000 Subject: [PATCH 1/4] feat(app): highlight code with @pierre/highlights instead of Shiki --- bun.lock | 36 +- bunfig.toml | 2 +- package.json | 4 +- .../timeline/session-timeline-stream-probe.ts | 2 +- packages/app/vite.js | 2 +- .../component-tests/markdown.spec.ts | 2 +- packages/session-ui/package.json | 3 +- .../session-ui/src/components/markdown.css | 4 +- .../session-ui/src/components/markdown.tsx | 2 +- .../src/components/markdown.worker.ts | 94 ++--- .../session-ui/src/components/message-file.ts | 80 +++- packages/session-ui/src/pierre/index.ts | 17 +- packages/session-ui/src/pierre/worker.ts | 24 +- packages/storybook/.storybook/preview.tsx | 31 +- packages/ui/package.json | 3 +- packages/ui/src/context/highlight-theme.tsx | 64 +++ packages/ui/src/context/marked-parser.test.ts | 19 +- packages/ui/src/context/marked-parser.tsx | 23 +- .../ui/src/context/marked-theme-register.tsx | 10 - packages/ui/src/context/marked-theme.tsx | 372 ------------------ packages/ui/src/context/marked.tsx | 31 -- 21 files changed, 288 insertions(+), 537 deletions(-) create mode 100644 packages/ui/src/context/highlight-theme.tsx delete mode 100644 packages/ui/src/context/marked-theme-register.tsx delete mode 100644 packages/ui/src/context/marked-theme.tsx delete mode 100644 packages/ui/src/context/marked.tsx diff --git a/bun.lock b/bun.lock index 6fac7858799b..39bef77b4b34 100644 --- a/bun.lock +++ b/bun.lock @@ -669,7 +669,7 @@ "@opencode/ui": "workspace:*", "@opencode/util": "workspace:*", "@pierre/diffs": "catalog:", - "@shikijs/stream": "catalog:", + "@pierre/highlights": "catalog:", "@solid-primitives/event-listener": "catalog:", "@solid-primitives/media": "catalog:", "@solid-primitives/resize-observer": "catalog:", @@ -681,7 +681,6 @@ "mermaid": "11.17.2", "motion": "12.34.5", "remend": "catalog:", - "shiki": "catalog:", "solid-js": "catalog:", "strip-ansi": "7.1.2", }, @@ -860,15 +859,14 @@ "@kitlangton/solid-motion": "0.2.2", "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", + "@pierre/highlights": "catalog:", "@solid-primitives/event-listener": "catalog:", "@solid-primitives/resize-observer": "catalog:", "fuzzysort": "catalog:", "katex": "0.16.47", "marked": "catalog:", - "marked-shiki": "catalog:", "motion": "12.34.5", "remeda": "catalog:", - "shiki": "catalog:", "solid-list": "catalog:", "solid-sonner": "catalog:", }, @@ -1062,11 +1060,11 @@ "@opentui/core": "0.6.6", "@opentui/keymap": "0.6.6", "@opentui/solid": "0.6.6", - "@pierre/diffs": "1.5.1", + "@pierre/diffs": "1.6.1", + "@pierre/highlights": "0.2.0", "@playwright/test": "1.60.0", "@sentry/solid": "10.71.0", "@sentry/vite-plugin": "5.4.0", - "@shikijs/stream": "4.4.3", "@solid-primitives/event-listener": "2.4.6", "@solid-primitives/media": "2.3.6", "@solid-primitives/resize-observer": "2.2.0", @@ -2381,11 +2379,13 @@ "@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="], - "@pierre/diffs": ["@pierre/diffs@1.5.1", "", { "dependencies": { "@pierre/theme": "2.0.0", "@pierre/theming": "1.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-+EXNfz4ZXI6FHr7P6ToWEORwyYiPVfAmzhA70xF+0y/oNvj7gbNTLJJ6jNzkBjuMbRlu564TAmgYZLc5OyHURA=="], + "@pierre/diffs": ["@pierre/diffs@1.6.1", "", { "dependencies": { "@pierre/highlights": "0.2.0", "@pierre/theme": "2.0.0", "@pierre/theming": "1.0.2", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-XTfLW4bFETyosZfFNUoyRh1zPE4tmAijOoxdHx4X805tlaq/7/fuQXIiJSm21+jLkDo8vn5elQ+2O4YtMTKM0Q=="], + + "@pierre/highlights": ["@pierre/highlights@0.2.0", "", {}, "sha512-hTpcOU1EzDAP4bYtEhxmMu2jwMvtkUydRUBUpWMSHTs3dw0IaC3Ld5ansMy18L0GE3b6Vfl2OGkLK+pWWpnBmw=="], "@pierre/theme": ["@pierre/theme@2.0.0", "", {}, "sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw=="], - "@pierre/theming": ["@pierre/theming@1.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.1.0 || ^2.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA=="], + "@pierre/theming": ["@pierre/theming@1.0.2", "", { "peerDependencies": { "@pierre/theme": "^1.1.0 || ^2.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-RmXLLqtx31UGcR8kZyO5hGAQ1EXz6loT1Cu0BFAGsbnBboIxp2BlomkLfWyvdT4WZXnBGyu25onL1gKpFQuSUw=="], "@pierre/trees": ["@pierre/trees@1.0.0-beta.4", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag=="], @@ -2563,7 +2563,7 @@ "@sentry/vite-plugin": ["@sentry/vite-plugin@5.4.0", "", { "dependencies": { "@sentry/bundler-plugins": "^10.64.0" } }, "sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA=="], - "@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="], + "@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="], @@ -2573,13 +2573,11 @@ "@shikijs/primitive": ["@shikijs/primitive@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ=="], - "@shikijs/stream": ["@shikijs/stream@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3" }, "peerDependencies": { "react": "^19.0.0", "solid-js": "^1.9.0", "svelte": "^5.0.0-0", "vue": "^3.2.0" }, "optionalPeers": ["react", "solid-js", "svelte", "vue"] }, "sha512-nG30byPDdyua1R9danJjQjv60PlNHTi1q2O0tzW+ocAFqb63Y/5DK5URpbuGt0jEidpRNx3+OgJ+aEMUBWqRNA=="], - "@shikijs/themes": ["@shikijs/themes@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw=="], "@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], - "@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="], + "@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -5811,9 +5809,15 @@ "@sentry/server-utils/@sentry/core": ["@sentry/core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA=="], - "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="], - "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="], + + "@shikijs/langs/@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="], + + "@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="], + + "@shikijs/themes/@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="], "@solidjs/start/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], @@ -6067,6 +6071,10 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "shiki/@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="], + + "shiki/@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="], + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], diff --git a/bunfig.toml b/bunfig.toml index edbde6d0fd0c..61822626086b 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Only install newly resolved package versions published at least 3 days ago. minimumReleaseAge = 259200 -minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@kitlangton/solid-motion", "@opencode/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opencode-ai/pty-win32-arm64", "@opencode-ai/pty-win32-x64", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "@opentunnel/client", "@opentunnel/protocol", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"] +minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@kitlangton/solid-motion", "@opencode/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opencode-ai/pty-win32-arm64", "@opencode-ai/pty-win32-x64", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "@opentunnel/client", "@opentunnel/protocol", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/highlights", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"] [test] root = "./do-not-run-tests-from-root" diff --git a/package.json b/package.json index c6f92db6b8d4..a388f663b3da 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "@opentui/keymap": "0.6.6", "@opentui/solid": "0.6.6", "@tanstack/solid-virtual": "3.13.37", - "@shikijs/stream": "4.4.3", "@standard-schema/spec": "1.1.0", "ulid": "3.0.1", "@kobalte/core": "0.13.13", @@ -69,7 +68,8 @@ "@tsconfig/node22": "22.0.2", "@tsconfig/bun": "1.0.9", "@cloudflare/workers-types": "4.20251008.0", - "@pierre/diffs": "1.5.1", + "@pierre/diffs": "1.6.1", + "@pierre/highlights": "0.2.0", "@solid-primitives/event-listener": "2.4.6", "@solid-primitives/media": "2.3.6", "@solid-primitives/resize-observer": "2.2.0", diff --git a/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts b/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts index be26eaa6be09..4b50dabcbc5a 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts @@ -426,7 +426,7 @@ export async function installTimelineStreamProbe( : index >= 0 ? "stream" : "unknown", - tokenSpans: livePart?.querySelectorAll(".shiki span").length ?? 0, + tokenSpans: livePart?.querySelectorAll(".highlights span").length ?? 0, blocks: livePart?.querySelectorAll("[data-markdown-block]").length ?? 0, codeBlocks: livePart?.querySelectorAll('[data-component="markdown-code"]').length ?? 0, height: livePart?.getBoundingClientRect().height ?? 0, diff --git a/packages/app/vite.js b/packages/app/vite.js index 349e5cb3d052..e7ed28e0eb39 100644 --- a/packages/app/vite.js +++ b/packages/app/vite.js @@ -26,7 +26,7 @@ if (tailwindGenerate && typeof tailwindHotUpdate === "function") { // The markdown worker imports these directly, so they are served unbundled to keep worker startup // stable. Vite applies `exclude` to every import inside a pre-bundle too, which would leave a bare // `import "marked"` in mermaid's chunk that the browser cannot resolve from this package. -const workerDeps = ["@shikijs/stream", "marked", "marked-shiki", "remend"] +const workerDeps = ["@pierre/highlights", "marked", "remend"] // The Office previews load their wasm and fonts through `new URL(..., import.meta.url)`, which a pre-bundle would move // away from the files. diff --git a/packages/session-ui/component-tests/markdown.spec.ts b/packages/session-ui/component-tests/markdown.spec.ts index 0ddf7ddf18b8..fc7a2c04d384 100644 --- a/packages/session-ui/component-tests/markdown.spec.ts +++ b/packages/session-ui/component-tests/markdown.spec.ts @@ -33,7 +33,7 @@ story("renders small completed Markdown immediately without skipping sanitizatio await harness.getByLabel("Markdown text").fill("```ts\nconst value = 42\n```") await expect(harness.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "") await expect(harness.locator("pre code")).toHaveText("const value = 42") - await expect(harness.locator("pre.shiki")).toBeVisible() + await expect(harness.locator("pre.highlights")).toBeVisible() }) story("keeps Markdown text selectable inside a container that disables selection", async ({ page }) => { diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index a9db663cec71..15b38499d2ae 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -67,7 +67,7 @@ "@opencode/ui": "workspace:*", "@opencode/util": "workspace:*", "@pierre/diffs": "catalog:", - "@shikijs/stream": "catalog:", + "@pierre/highlights": "catalog:", "@solid-primitives/event-listener": "catalog:", "@solid-primitives/media": "catalog:", "@solid-primitives/resize-observer": "catalog:", @@ -79,7 +79,6 @@ "mermaid": "11.17.2", "motion": "12.34.5", "remend": "catalog:", - "shiki": "catalog:", "solid-js": "catalog:", "strip-ansi": "7.1.2" } diff --git a/packages/session-ui/src/components/markdown.css b/packages/session-ui/src/components/markdown.css index d13b4cb4e759..f9e91123bcb9 100644 --- a/packages/session-ui/src/components/markdown.css +++ b/packages/session-ui/src/components/markdown.css @@ -271,7 +271,7 @@ margin: 32px 0; } - .shiki { + .highlights { background: var(--color-background-stronger); color: var(--text-base); font-size: 13px; @@ -313,7 +313,7 @@ display: none; } - [data-component="markdown-code"][data-code-kind="shell"] .shiki { + [data-component="markdown-code"][data-code-kind="shell"] .highlights { background: var(--markdown-shell-code-background); border-color: var(--markdown-shell-code-border); color: var(--markdown-shell-code-color); diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx index 3403b8ef9a75..befb96daa019 100644 --- a/packages/session-ui/src/components/markdown.tsx +++ b/packages/session-ui/src/components/markdown.tsx @@ -1029,7 +1029,7 @@ function updateCodeBlock( wrapper.setAttribute("data-component", "markdown-code") applyCodeMetadata(wrapper, block.language) const pre = document.createElement("pre") - pre.className = "shiki OpenCode" + pre.className = "highlights" const codeElement = document.createElement("code") codeElement.className = `language-${block.language}` ;[...block.stable, ...block.unstable].map(createTokenSpan).forEach((span) => codeElement.appendChild(span)) diff --git a/packages/session-ui/src/components/markdown.worker.ts b/packages/session-ui/src/components/markdown.worker.ts index 32e4a9f2512b..1d537dc262e6 100644 --- a/packages/session-ui/src/components/markdown.worker.ts +++ b/packages/session-ui/src/components/markdown.worker.ts @@ -1,16 +1,8 @@ /// -import { ShikiStreamTokenizer } from "@shikijs/stream" +import { codeToTokens, isSupportedLanguage, LiveTokenizer, type ThemedToken } from "@pierre/highlights" import { createMarkdownParser } from "@opencode/ui/context/marked-parser" -import { OpenCodeTheme } from "@opencode/ui/context/marked-theme" -import { - bundledLanguages, - createHighlighter, - getTokenStyleObject, - stringifyTokenStyle, - type BundledLanguage, - type ThemedToken, -} from "shiki" +import { OpenCodeTheme } from "@opencode/ui/context/highlight-theme" import type { MarkdownToken, MarkdownWorkerRequest, MarkdownWorkerResponse } from "./markdown-worker-protocol" import { createLatestWorkerQueue } from "./markdown-worker-queue" import { project, type Projection } from "./markdown-stream" @@ -18,19 +10,19 @@ import { project, type Projection } from "./markdown-stream" type Stream = { language: string source: string - tokenizer: ShikiStreamTokenizer + tokenizer: LiveTokenizer } const streams = new Map() const projections = new Map() -let highlighter: ReturnType | undefined +const parser = createMarkdownParser() const highlightQueue = createLatestWorkerQueue>({ run: highlight, supersede: (request) => post({ type: "superseded", id: request.id, key: request.key }), - dispose: (key) => void streams.delete(key), + dispose: closeStream, }) const projectQueue = createLatestWorkerQueue>({ @@ -39,18 +31,6 @@ const projectQueue = createLatestWorkerQueue void projections.delete(key), }) -const parser = createMarkdownParser(async (code, language) => { - const instance = await getHighlighter() - const name = language in bundledLanguages ? language : "text" - - if (!instance.getLoadedLanguages().includes(name)) - await instance.loadLanguage(bundledLanguages[name as BundledLanguage]) - - return instance - .codeToHtml(code, { lang: name as BundledLanguage, theme: "OpenCode", tabindex: false }) - .replace("", ``) -}) - self.onmessage = (event: MessageEvent) => { if (event.data.type === "dispose") { highlightQueue.dispose(event.data.key) @@ -99,15 +79,13 @@ async function runProject(request: Extract) { try { - const instance = await getHighlighter() - const language = request.language in bundledLanguages ? request.language : "text" - - if (!instance.getLoadedLanguages().includes(language)) - await instance.loadLanguage(bundledLanguages[language as BundledLanguage]) + // Report the requested language so the viewer can label the block and spot Mermaid; lex unknown ones as text. + const language = request.language + const lang = isSupportedLanguage(language) ? language : "text" if (request.complete) { - const result = instance.codeToTokens(request.text, { lang: language as BundledLanguage, theme: "OpenCode" }) - streams.delete(request.key) + const result = codeToTokens(request.text, { lang, theme: OpenCodeTheme }) + closeStream(request.key) post({ type: "highlight", id: request.id, @@ -128,25 +106,39 @@ async function highlight(request: Extract token.content.length > 0).map(token), - unstable: result.unstable.filter((token) => token.content.length > 0).map(token), + stable: Array.from({ length: last - start }, (_, index) => [ + ...stream.tokenizer.getLineTokens(start + index).tokens, + { content: "\n", offset: 0 }, + ]) + .flat() + .filter((token) => token.content.length > 0) + .map(token), + unstable: stream.tokenizer + .getLineTokens(last) + .tokens.filter((token) => token.content.length > 0) + .map(token), }) } catch (error) { post({ @@ -158,8 +150,9 @@ async function highlight(request: Extract( - bundledLanguagesInfo.flatMap((info) => - [info.id, ...(info.aliases ?? [])].map((alias): [string, string] => [alias, info.name]), - ), +// Display names for common source extensions; any other extension is shown in upper case. +const LANGUAGE_NAMES = new Map( + Object.entries({ + Assembly: ["asm", "s"], + Astro: ["astro"], + "Batch File": ["bat", "cmd"], + C: ["c", "h"], + "C#": ["cs"], + "C++": ["cpp", "cc", "cxx", "hpp", "hh", "hxx"], + Clojure: ["clj", "cljs", "cljc", "edn"], + CSS: ["css"], + CUDA: ["cu", "cuh"], + Dart: ["dart"], + Diff: ["diff", "patch"], + Elixir: ["ex", "exs"], + Elm: ["elm"], + Erlang: ["erl", "hrl"], + "F#": ["fs", "fsi", "fsx"], + Fortran: ["f", "f77", "f90", "f95", "f03", "f08", "for"], + GDScript: ["gd"], + Gleam: ["gleam"], + GLSL: ["glsl", "vert", "frag", "geom", "comp"], + Go: ["go"], + GraphQL: ["graphql", "gql"], + Groovy: ["groovy", "gradle"], + Haskell: ["hs"], + HCL: ["hcl"], + HLSL: ["hlsl"], + HTML: ["html", "htm"], + Java: ["java"], + JavaScript: ["js", "cjs", "mjs"], + JSON: ["json"], + "JSON Lines": ["jsonl"], + "JSON with Comments": ["jsonc"], + JSON5: ["json5"], + JSX: ["jsx"], + Julia: ["jl"], + Kotlin: ["kt", "kts"], + LaTeX: ["tex", "latex"], + Less: ["less"], + Lua: ["lua"], + Markdown: ["md", "markdown"], + MDX: ["mdx"], + Nim: ["nim"], + Nix: ["nix"], + OCaml: ["ml", "mli"], + Perl: ["pl", "pm"], + PHP: ["php"], + PowerShell: ["ps1", "psm1"], + "Protocol Buffer": ["proto"], + Python: ["py", "pyi"], + R: ["r"], + Ruby: ["rb"], + Rust: ["rs"], + Sass: ["sass"], + Scala: ["scala", "sbt"], + SCSS: ["scss"], + Shell: ["sh", "bash", "zsh"], + Solidity: ["sol"], + SQL: ["sql"], + Svelte: ["svelte"], + SVG: ["svg"], + Swift: ["swift"], + Terraform: ["tf", "tfvars"], + TOML: ["toml"], + TSX: ["tsx"], + TypeScript: ["ts", "cts", "mts"], + Vue: ["vue"], + WebAssembly: ["wat", "wasm"], + WGSL: ["wgsl"], + XML: ["xml"], + YAML: ["yaml", "yml"], + Zig: ["zig"], + }).flatMap(([name, extensions]) => extensions.map((extension) => [extension, name] as const)), ) // attachments carry text/plain for all text files, so the label comes from the extension; diff --git a/packages/session-ui/src/pierre/index.ts b/packages/session-ui/src/pierre/index.ts index 524d1ef9a545..315890d0672b 100644 --- a/packages/session-ui/src/pierre/index.ts +++ b/packages/session-ui/src/pierre/index.ts @@ -1,7 +1,20 @@ -import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs" +import { + DiffLineAnnotation, + FileContents, + FileDiffOptions, + registerCustomTheme, + type SelectedLineRange, +} from "@pierre/diffs" +import { OpenCodeTheme } from "@opencode/ui/context/highlight-theme" import { ComponentProps } from "solid-js" import { lineCommentStyles } from "../components/line-comment-styles" +// Pierre resolves themes on the main thread and hands them to its workers, so register before any viewer renders. +registerCustomTheme(OpenCodeTheme.name, () => Promise.resolve(OpenCodeTheme), "zed") + +// Viewers and worker pools must agree: the pool's options win whenever it renders. +export const highlightOptions = { theme: OpenCodeTheme.name, preferredHighlighter: "highlights" } as const + export type DiffProps = FileDiffOptions & { before: FileContents after: FileContents @@ -250,7 +263,7 @@ ${lineCommentStyles} export function createDefaultOptions(style: FileDiffOptions["diffStyle"]) { return { - theme: "OpenCode", + ...highlightOptions, themeType: "system", disableLineNumbers: false, overflow: "wrap", diff --git a/packages/session-ui/src/pierre/worker.ts b/packages/session-ui/src/pierre/worker.ts index 0dbfb06d0060..3f59e5285cec 100644 --- a/packages/session-ui/src/pierre/worker.ts +++ b/packages/session-ui/src/pierre/worker.ts @@ -1,17 +1,11 @@ import { WorkerPoolManager } from "@pierre/diffs/worker" -import ShikiWorkerUrl from "@pierre/diffs/worker/worker.js?worker&url" -import { registerOpenCodeTheme } from "@opencode/ui/context/marked-theme-register" - -registerOpenCodeTheme() - -export function workerFactory(): Worker { - return new Worker(ShikiWorkerUrl, { type: "module" }) -} +import DiffsWorkerUrl from "@pierre/diffs/worker/worker.js?worker&url" +import { highlightOptions } from "./index" function createPool(lineDiffType: "none" | "word-line") { const pool = new WorkerPoolManager( { - workerFactory, + workerFactory: () => new Worker(DiffsWorkerUrl, { type: "module" }), // poolSize defaults to 8. More workers = more parallelism but // also more memory. Too many can actually slow things down. // NOTE: 2 is probably better for OpenCode, as I think 8 might be @@ -20,12 +14,11 @@ function createPool(lineDiffType: "none" | "word-line") { poolSize: 2, }, { - theme: "OpenCode", + ...highlightOptions, lineDiffType, // Pierre renders with the pool's options, not the viewer's, whenever the pool works. The "none" pool only // serves diffs above the large-file threshold, so it carries the plain-text fallback the viewer requests. ...(lineDiffType === "none" && { maxLineDiffLength: 0, tokenizeMaxLineLength: 1 }), - preferredHighlighter: "shiki-wasm", }, ) @@ -51,12 +44,3 @@ export function getWorkerPool(lineDiffType: "none" | "word-line" = "word-line"): return diff } - -export function getWorkerPools() { - const pool = getWorkerPool() - - return { - unified: pool, - split: pool, - } -} diff --git a/packages/storybook/.storybook/preview.tsx b/packages/storybook/.storybook/preview.tsx index a27fce926dcb..230a5491148a 100644 --- a/packages/storybook/.storybook/preview.tsx +++ b/packages/storybook/.storybook/preview.tsx @@ -8,7 +8,6 @@ import { addons } from "storybook/preview-api" import { GLOBALS_UPDATED } from "storybook/internal/core-events" import { createJSXDecorator, definePreview } from "storybook-solidjs-vite" import { DialogProvider } from "@opencode/ui/context/dialog" -import { MarkedProvider } from "@opencode/ui/context/marked" import { ThemeProvider, useTheme, type ColorScheme } from "@opencode/ui/theme" import { Font } from "@opencode/ui/font" import { LanguageProvider, UiI18nBridge, useLanguage } from "@/runtime/i18n/language" @@ -76,22 +75,20 @@ const frame = createJSXDecorator((Story, context) => { - -
- - - -
-
+
+ + + +
diff --git a/packages/ui/package.json b/packages/ui/package.json index 45181d8aa7a5..19d7ff615f69 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -113,15 +113,14 @@ "@kitlangton/solid-motion": "0.2.2", "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", + "@pierre/highlights": "catalog:", "@solid-primitives/event-listener": "catalog:", "@solid-primitives/resize-observer": "catalog:", "fuzzysort": "catalog:", "katex": "0.16.47", "marked": "catalog:", - "marked-shiki": "catalog:", "motion": "12.34.5", "remeda": "catalog:", - "shiki": "catalog:", "solid-list": "catalog:", "solid-sonner": "catalog:" }, diff --git a/packages/ui/src/context/highlight-theme.tsx b/packages/ui/src/context/highlight-theme.tsx new file mode 100644 index 000000000000..2d7a797e972e --- /dev/null +++ b/packages/ui/src/context/highlight-theme.tsx @@ -0,0 +1,64 @@ +import type { Theme } from "@pierre/highlights" + +// A Highlights palette: each value names an app CSS variable without its `--`, so code follows the active app theme. +// Token names fall back to their parent scope, so `keyword.control` uses `keyword`. +export const OpenCodeTheme = { + name: "OpenCode", + appearance: "dark", + cssVariables: { prefix: "--" }, + style: { + "editor.background": "color-background-stronger", + "editor.foreground": "text-base", + created: "syntax-diff-add", + deleted: "syntax-diff-delete", + modified: "syntax-diff-unknown", + syntax: { + attribute: "syntax-property", + background: "color-background-stronger", + boolean: "syntax-constant", + comment: "syntax-comment", + constant: "syntax-constant", + constructor: "syntax-type", + "diff.minus": "syntax-diff-delete", + "diff.plus": "syntax-diff-add", + embedded: "syntax-punctuation", + emphasis: { color: "text-base", font_style: "italic" }, + "emphasis.strong": { color: "text-strong", font_weight: 700 }, + enum: "syntax-type", + foreground: "text-base", + function: "syntax-primitive", + hint: "syntax-comment", + keyword: "syntax-keyword", + "keyword.operator": "syntax-operator", + label: "syntax-constant", + link_text: "text-base", + link_uri: "text-base", + namespace: "syntax-type", + number: "syntax-constant", + operator: "syntax-operator", + predictive: "syntax-comment", + preproc: "syntax-keyword", + primary: "text-base", + property: "syntax-primitive", + punctuation: "syntax-punctuation", + selector: "syntax-string", + "selector.class": "syntax-property", + "selector.id": "syntax-property", + "selector.pseudo": "syntax-property", + string: "syntax-string", + "string.escape": "syntax-constant", + "string.regex": "syntax-regexp", + "string.special": "syntax-constant", + tag: "syntax-string", + "tag.component.jsx": "syntax-type", + "tag.doctype": "syntax-keyword", + text: "text-base", + title: { color: "syntax-info", font_weight: 700 }, + type: "syntax-type", + "type.builtin": "syntax-primitive", + variable: "syntax-variable", + "variable.special": "syntax-constant", + variant: "syntax-primitive", + }, + }, +} satisfies Theme diff --git a/packages/ui/src/context/marked-parser.test.ts b/packages/ui/src/context/marked-parser.test.ts index 30093e45726f..ec0dcbc60b9b 100644 --- a/packages/ui/src/context/marked-parser.test.ts +++ b/packages/ui/src/context/marked-parser.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test" import { createMarkdownParser } from "./marked-parser" import { parseSmallMarkdown } from "./marked-base" -const parser = createMarkdownParser((code, language) => `
${code}
`) +const parser = createMarkdownParser() test.each([ ["OpenCode", "OpenCode"], @@ -84,12 +84,23 @@ test("lexes dollar-heavy paragraphs in near-linear time", () => { expect(performance.now() - startedAt).toBeLessThan(500) }) -test("uses the configured code highlighter", async () => { - expect(await parser.parse("```ts\nconst value = 1\n```\n")).toBe('
const value = 1
\n') +test("highlights fenced code with the app theme variables", async () => { + const html = await parser.parse("```ts\nconst value = 1\n```\n") + + expect(html).toStartWith('
')
+  expect(html).toContain('const ')
+})
+
+test("keeps the fence language for code without a lexer", async () => {
+  const html = await parser.parse("```mermaid\ngraph TD; A-->B\n```\n")
+
+  expect(html).toContain('graph TD; A-->B')
 })
 
 test.each(["```", "~~~"])("recognizes an empty %s fence at EOF", async (fence) => {
-  expect(await parser.parse(`foo\n${fence}`)).toBe('

foo

\n
\n')
+  expect(await parser.parse(`foo\n${fence}`)).toContain('

foo

\n
')
 })
 
 test("preserves emphasis when rejecting an outer reference link", async () => {
diff --git a/packages/ui/src/context/marked-parser.tsx b/packages/ui/src/context/marked-parser.tsx
index 70869eb544c8..0ade3bd65aef 100644
--- a/packages/ui/src/context/marked-parser.tsx
+++ b/packages/ui/src/context/marked-parser.tsx
@@ -1,10 +1,27 @@
+import { codeToHtml, isSupportedLanguage } from "@pierre/highlights"
 import katex from "katex"
 import type { MarkedExtension, Tokens } from "marked"
-import markedShiki from "marked-shiki"
 import { createMarkdownBase } from "./marked-base"
+import { OpenCodeTheme } from "./highlight-theme"
 
-export function createMarkdownParser(highlight: (code: string, language: string) => string | Promise) {
-  return createMarkdownBase().use(markdownMath, markedShiki({ highlight }))
+export function createMarkdownParser() {
+  return createMarkdownBase().use(markdownMath, markdownCode)
+}
+
+const decoder = new TextDecoder()
+
+// The class keeps the fence's own language, even one without a lexer, so the viewer can label it and render Mermaid.
+const markdownCode: MarkedExtension = {
+  renderer: {
+    code(token) {
+      const language = (token.lang?.match(/^\S*/)?.[0] ?? "").toLowerCase().replace(/[^\w#+.-]/g, "") || "text"
+      const html = decoder.decode(
+        codeToHtml(token.text, { lang: isSupportedLanguage(language) ? language : "text", theme: OpenCodeTheme }),
+      )
+
+      return html.replace("", ``) + "\n"
+    },
+  },
 }
 
 // `$$...$$` renders as display math anywhere in a line.
diff --git a/packages/ui/src/context/marked-theme-register.tsx b/packages/ui/src/context/marked-theme-register.tsx
deleted file mode 100644
index 135381eab8d2..000000000000
--- a/packages/ui/src/context/marked-theme-register.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { registerCustomTheme } from "@pierre/diffs"
-import { OpenCodeTheme } from "./marked-theme"
-
-let registered = false
-
-export function registerOpenCodeTheme() {
-  if (registered) return
-  registered = true
-  registerCustomTheme("OpenCode", () => Promise.resolve(OpenCodeTheme))
-}
diff --git a/packages/ui/src/context/marked-theme.tsx b/packages/ui/src/context/marked-theme.tsx
deleted file mode 100644
index 527a35e678f0..000000000000
--- a/packages/ui/src/context/marked-theme.tsx
+++ /dev/null
@@ -1,372 +0,0 @@
-import type { ThemeRegistrationResolved } from "@pierre/diffs"
-
-export const OpenCodeTheme = {
-  name: "OpenCode",
-  bg: "var(--color-background-stronger)",
-  fg: "var(--text-base)",
-  colors: {
-    "editor.background": "var(--color-background-stronger)",
-    "editor.foreground": "var(--text-base)",
-    "gitDecoration.addedResourceForeground": "var(--syntax-diff-add)",
-    "gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)",
-    "gitDecoration.modifiedResourceForeground": "var(--syntax-diff-unknown)",
-    // "gitDecoration.conflictingResourceForeground": "#ffca00",
-    // "gitDecoration.modifiedResourceForeground": "#1a76d4",
-    // "gitDecoration.untrackedResourceForeground": "#00cab1",
-    // "gitDecoration.ignoredResourceForeground": "#84848A",
-    // "terminal.titleForeground": "#adadb1",
-    // "terminal.titleInactiveForeground": "#84848A",
-    // "terminal.background": "#141415",
-    // "terminal.foreground": "#adadb1",
-    // "terminal.ansiBlack": "#141415",
-    // "terminal.ansiRed": "#ff2e3f",
-    // "terminal.ansiGreen": "#0dbe4e",
-    // "terminal.ansiYellow": "#ffca00",
-    // "terminal.ansiBlue": "#008cff",
-    // "terminal.ansiMagenta": "#c635e4",
-    // "terminal.ansiCyan": "#08c0ef",
-    // "terminal.ansiWhite": "#c6c6c8",
-    // "terminal.ansiBrightBlack": "#141415",
-    // "terminal.ansiBrightRed": "#ff2e3f",
-    // "terminal.ansiBrightGreen": "#0dbe4e",
-    // "terminal.ansiBrightYellow": "#ffca00",
-    // "terminal.ansiBrightBlue": "#008cff",
-    // "terminal.ansiBrightMagenta": "#c635e4",
-    // "terminal.ansiBrightCyan": "#08c0ef",
-    // "terminal.ansiBrightWhite": "#c6c6c8",
-  },
-  tokenColors: [
-    {
-      scope: ["comment", "punctuation.definition.comment", "string.comment"],
-      settings: {
-        foreground: "var(--syntax-comment)",
-      },
-    },
-    {
-      scope: ["entity.other.attribute-name"],
-      settings: {
-        foreground: "var(--syntax-property)", // maybe attribute
-      },
-    },
-    {
-      scope: ["constant", "entity.name.constant", "variable.other.constant", "variable.language", "entity"],
-      settings: {
-        foreground: "var(--syntax-constant)",
-      },
-    },
-    {
-      scope: ["entity.name", "meta.export.default", "meta.definition.variable"],
-      settings: {
-        foreground: "var(--syntax-type)",
-      },
-    },
-    {
-      scope: ["meta.object.member"],
-      settings: {
-        foreground: "var(--syntax-primitive)",
-      },
-    },
-    {
-      scope: [
-        "variable.parameter.function",
-        "meta.jsx.children",
-        "meta.block",
-        "meta.tag.attributes",
-        "entity.name.constant",
-        "meta.embedded.expression",
-        "meta.template.expression",
-        "string.other.begin.yaml",
-        "string.other.end.yaml",
-      ],
-      settings: {
-        foreground: "var(--syntax-punctuation)",
-      },
-    },
-    {
-      scope: ["entity.name.function", "support.type.primitive"],
-      settings: {
-        foreground: "var(--syntax-primitive)",
-      },
-    },
-    {
-      scope: ["support.class.component"],
-      settings: {
-        foreground: "var(--syntax-type)",
-      },
-    },
-    {
-      scope: "keyword",
-      settings: {
-        foreground: "var(--syntax-keyword)",
-      },
-    },
-    {
-      scope: [
-        "keyword.operator",
-        "storage.type.function.arrow",
-        "punctuation.separator.key-value.css",
-        "entity.name.tag.yaml",
-        "punctuation.separator.key-value.mapping.yaml",
-      ],
-      settings: {
-        foreground: "var(--syntax-operator)",
-      },
-    },
-    {
-      scope: ["storage", "storage.type"],
-      settings: {
-        foreground: "var(--syntax-keyword)",
-      },
-    },
-    {
-      scope: ["storage.modifier.package", "storage.modifier.import", "storage.type.java"],
-      settings: {
-        foreground: "var(--syntax-primitive)",
-      },
-    },
-    {
-      scope: [
-        "string",
-        "punctuation.definition.string",
-        "string punctuation.section.embedded source",
-        "entity.name.tag",
-      ],
-      settings: {
-        foreground: "var(--syntax-string)",
-      },
-    },
-    {
-      scope: "support",
-      settings: {
-        foreground: "var(--syntax-primitive)",
-      },
-    },
-    {
-      scope: ["support.type.object.module", "variable.other.object", "support.type.property-name.css"],
-      settings: {
-        foreground: "var(--syntax-object)",
-      },
-    },
-    {
-      scope: "meta.property-name",
-      settings: {
-        foreground: "var(--syntax-property)",
-      },
-    },
-    {
-      scope: "variable",
-      settings: {
-        foreground: "var(--syntax-variable)",
-      },
-    },
-    {
-      scope: "variable.other",
-      settings: {
-        foreground: "var(--syntax-variable)",
-      },
-    },
-    {
-      scope: [
-        "invalid.broken",
-        "invalid.illegal",
-        "invalid.unimplemented",
-        "invalid.deprecated",
-        "message.error",
-        "markup.deleted",
-        "meta.diff.header.from-file",
-        "punctuation.definition.deleted",
-        "brackethighlighter.unmatched",
-        "token.error-token",
-      ],
-      settings: {
-        foreground: "var(--syntax-critical)",
-      },
-    },
-    {
-      scope: "carriage-return",
-      settings: {
-        foreground: "var(--syntax-keyword)",
-      },
-    },
-    {
-      scope: "string source",
-      settings: {
-        foreground: "var(--syntax-variable)",
-      },
-    },
-    {
-      scope: "string variable",
-      settings: {
-        foreground: "var(--syntax-constant)",
-      },
-    },
-    {
-      scope: [
-        "source.regexp",
-        "string.regexp",
-        "string.regexp.character-class",
-        "string.regexp constant.character.escape",
-        "string.regexp source.ruby.embedded",
-        "string.regexp string.regexp.arbitrary-repitition",
-        "string.regexp constant.character.escape",
-      ],
-      settings: {
-        foreground: "var(--syntax-regexp)",
-      },
-    },
-    {
-      scope: "support.constant",
-      settings: {
-        foreground: "var(--syntax-primitive)",
-      },
-    },
-    {
-      scope: "support.variable",
-      settings: {
-        foreground: "var(--syntax-variable)",
-      },
-    },
-    {
-      scope: "meta.module-reference",
-      settings: {
-        foreground: "var(--syntax-info)",
-      },
-    },
-    {
-      scope: "punctuation.definition.list.begin.markdown",
-      settings: {
-        foreground: "var(--syntax-punctuation)",
-      },
-    },
-    {
-      scope: ["markup.heading", "markup.heading entity.name"],
-      settings: {
-        fontStyle: "bold",
-        foreground: "var(--syntax-info)",
-      },
-    },
-    {
-      scope: "markup.quote",
-      settings: {
-        foreground: "var(--syntax-info)",
-      },
-    },
-    {
-      scope: "markup.italic",
-      settings: {
-        fontStyle: "italic",
-        // foreground: "",
-      },
-    },
-    {
-      scope: "markup.bold",
-      settings: {
-        fontStyle: "bold",
-        foreground: "var(--text-strong)",
-      },
-    },
-    {
-      scope: [
-        "markup.raw",
-        "markup.inserted",
-        "meta.diff.header.to-file",
-        "punctuation.definition.inserted",
-        "markup.changed",
-        "punctuation.definition.changed",
-        "markup.ignored",
-        "markup.untracked",
-      ],
-      settings: {
-        foreground: "var(--text-base)",
-      },
-    },
-    {
-      scope: "meta.diff.range",
-      settings: {
-        fontStyle: "bold",
-        foreground: "var(--syntax-unknown)",
-      },
-    },
-    {
-      scope: "meta.diff.header",
-      settings: {
-        foreground: "var(--syntax-unknown)",
-      },
-    },
-    {
-      scope: "meta.separator",
-      settings: {
-        fontStyle: "bold",
-        foreground: "var(--syntax-unknown)",
-      },
-    },
-    {
-      scope: "meta.output",
-      settings: {
-        foreground: "var(--syntax-unknown)",
-      },
-    },
-    {
-      scope: "meta.export.default",
-      settings: {
-        foreground: "var(--syntax-unknown)",
-      },
-    },
-    {
-      scope: [
-        "brackethighlighter.tag",
-        "brackethighlighter.curly",
-        "brackethighlighter.round",
-        "brackethighlighter.square",
-        "brackethighlighter.angle",
-        "brackethighlighter.quote",
-      ],
-      settings: {
-        foreground: "var(--syntax-unknown)",
-      },
-    },
-    {
-      scope: ["constant.other.reference.link", "string.other.link"],
-      settings: {
-        fontStyle: "underline",
-        foreground: "var(--syntax-unknown)",
-      },
-    },
-    {
-      scope: "token.info-token",
-      settings: {
-        foreground: "var(--syntax-info)",
-      },
-    },
-    {
-      scope: "token.warn-token",
-      settings: {
-        foreground: "var(--syntax-warning)",
-      },
-    },
-    {
-      scope: "token.debug-token",
-      settings: {
-        foreground: "var(--syntax-info)",
-      },
-    },
-  ],
-  semanticTokenColors: {
-    comment: "var(--syntax-comment)",
-    string: "var(--syntax-string)",
-    number: "var(--syntax-constant)",
-    regexp: "var(--syntax-regexp)",
-    keyword: "var(--syntax-keyword)",
-    variable: "var(--syntax-variable)",
-    parameter: "var(--syntax-variable)",
-    property: "var(--syntax-property)",
-    function: "var(--syntax-primitive)",
-    method: "var(--syntax-primitive)",
-    type: "var(--syntax-type)",
-    class: "var(--syntax-type)",
-    namespace: "var(--syntax-type)",
-    enumMember: "var(--syntax-primitive)",
-    "variable.constant": "var(--syntax-constant)",
-    "variable.defaultLibrary": "var(--syntax-unknown)",
-  },
-} as unknown as ThemeRegistrationResolved
diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx
deleted file mode 100644
index e06aabc8ec3c..000000000000
--- a/packages/ui/src/context/marked.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { getSharedHighlighter } from "@pierre/diffs"
-import { bundledLanguages, type BundledLanguage } from "shiki"
-import { createSimpleContext } from "./helper"
-import { createMarkdownParser } from "./marked-parser"
-import { registerOpenCodeTheme } from "./marked-theme-register"
-
-export { OpenCodeTheme } from "./marked-theme"
-
-registerOpenCodeTheme()
-
-export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
-  name: "Marked",
-  init: () =>
-    createMarkdownParser(async (code, language) => {
-      const highlighter = await getSharedHighlighter({
-        themes: ["OpenCode"],
-        langs: [],
-        preferredHighlighter: "shiki-wasm",
-      })
-
-      const name = language in bundledLanguages ? language : "text"
-
-      if (!highlighter.getLoadedLanguages().includes(name)) await highlighter.loadLanguage(name as BundledLanguage)
-
-      return highlighter.codeToHtml(code, {
-        lang: name,
-        theme: "OpenCode",
-        tabindex: false,
-      })
-    }),
-})

From 16946c240006ebb6870e756525c07accfdc91de1 Mon Sep 17 00:00:00 2001
From: Luke Parker <10430890+Hona@users.noreply.github.com>
Date: Thu, 22 Oct 2026 08:16:16 +1000
Subject: [PATCH 2/4] fix(app): leave touched files lint-clean

---
 .../timeline/session-timeline-stream-probe.ts | 53 +++++++++++--------
 packages/ui/src/context/marked-parser.tsx     |  1 +
 2 files changed, 33 insertions(+), 21 deletions(-)

diff --git a/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts b/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts
index 4b50dabcbc5a..9d1a5628abcd 100644
--- a/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts
+++ b/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts
@@ -4,6 +4,12 @@ const STREAM_MARKER_PATTERN = "stream-(\\d+)"
 
 const STREAM_FRAGMENT_COUNT = 18
 
+declare global {
+  interface Window {
+    __timelineStreamBenchmark?: TimelineProbeState
+  }
+}
+
 type TimelineProbeState = {
   started: number
   ended: number
@@ -126,18 +132,19 @@ export async function installTimelineStreamProbe(
         start: () => {},
       }
 
-      ;(window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark = state
+      window.__timelineStreamBenchmark = state
       const scrollTo = Element.prototype.scrollTo
+      const scrollToPoint: (this: Element, x: number, y: number) => void = scrollTo
+      const scrollToOptions: (this: Element, options?: ScrollToOptions) => void = scrollTo
       const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
 
       if (profileVisual) {
-        function measuredScrollTo(this: Element, options?: ScrollToOptions): void
-        function measuredScrollTo(this: Element, x: number, y: number): void
-        function measuredScrollTo(this: Element, first?: number | ScrollToOptions, second?: number) {
+        // The two `scrollTo` overloads differ by arity: `(options?)` or `(x, y)`.
+        function measuredScrollTo(this: Element, ...args: [options?: ScrollToOptions] | [x: number, y: number]) {
           state.scroll.calls += 1
-          const top = typeof first === "object" ? first?.top : second
+          const top = args.length === 2 ? args[1] : args[0]?.top
 
-          if (typeof top === "number") {
+          if (top !== undefined) {
             const target = Math.min(top, this.scrollHeight - this.clientHeight)
 
             if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
@@ -145,7 +152,9 @@ export async function installTimelineStreamProbe(
 
           if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
           state.scroll.lastCallFrame = state.scroll.frame
-          scrollTo.apply(this, typeof first === "number" ? [first, second] : [first])
+
+          if (args.length === 2) scrollToPoint.call(this, args[0], args[1])
+          else scrollToOptions.call(this, args[0])
         }
 
         Element.prototype.scrollTo = measuredScrollTo
@@ -164,9 +173,9 @@ export async function installTimelineStreamProbe(
       const recordLongTasks = (entries: PerformanceEntry[]) => {
         if (!state.running) return
         state.longTasks.push(
-          ...entries
-            .filter((entry) => entry.startTime >= state.started && entry.startTime <= state.ended)
-            .map((entry) => entry.duration),
+          ...entries.flatMap((entry) =>
+            entry.startTime >= state.started && entry.startTime <= state.ended ? [entry.duration] : [],
+          ),
         )
       }
 
@@ -176,15 +185,14 @@ export async function installTimelineStreamProbe(
       const recordLayoutShifts = (entries: PerformanceEntry[]) => {
         if (!state.running) return
         state.layoutShifts.push(
-          ...entries
-            .map((entry) => {
-              const shift = entry as LayoutShiftEntry
+          ...entries.flatMap((entry) => {
+            // SAFETY: this observer only subscribes to "layout-shift" entries, which carry `value` and `hadRecentInput`.
+            const shift = entry as LayoutShiftEntry
 
-              if (shift.startTime < state.started || shift.hadRecentInput) return
+            if (shift.startTime < state.started || shift.hadRecentInput) return []
 
-              return shift.value
-            })
-            .filter((value): value is number => value !== undefined),
+            return [shift.value]
+          }),
         )
       }
 
@@ -335,8 +343,11 @@ export async function installTimelineStreamProbe(
 
           if (profileVisual) {
             const visibleRows = [...root.querySelectorAll("[data-timeline-key]")]
-              .map((element) => ({ element, rect: element.getBoundingClientRect() }))
-              .filter((item) => item.rect.bottom > viewport.top && item.rect.top < viewport.bottom)
+              .flatMap((element) => {
+                const rect = element.getBoundingClientRect()
+
+                return rect.bottom > viewport.top && rect.top < viewport.bottom ? [{ element, rect }] : []
+              })
               .sort((a, b) => a.rect.top - b.rect.top)
 
             state.visibleRows = new Set(visibleRows.map((item) => item.element))
@@ -451,7 +462,7 @@ export async function installTimelineStreamProbe(
 
 export function startTimelineStreamProbe(page: Page) {
   return page.evaluate(() => {
-    const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
+    const state = window.__timelineStreamBenchmark
 
     if (!state) throw new Error("missing streaming benchmark state")
     state.start()
@@ -493,7 +504,7 @@ export async function collectTimelineStreamMetrics(
   options: { textPartID: string; finalIndex: number; navigations: string[] },
 ) {
   return page.evaluate(({ textPartID, finalIndex, navigations }) => {
-    const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
+    const state = window.__timelineStreamBenchmark
 
     if (!state) throw new Error(`missing streaming benchmark state after navigation: ${JSON.stringify(navigations)}`)
     state.ended = performance.now()
diff --git a/packages/ui/src/context/marked-parser.tsx b/packages/ui/src/context/marked-parser.tsx
index 0ade3bd65aef..06d9b47909f5 100644
--- a/packages/ui/src/context/marked-parser.tsx
+++ b/packages/ui/src/context/marked-parser.tsx
@@ -15,6 +15,7 @@ const markdownCode: MarkedExtension = {
   renderer: {
     code(token) {
       const language = (token.lang?.match(/^\S*/)?.[0] ?? "").toLowerCase().replace(/[^\w#+.-]/g, "") || "text"
+
       const html = decoder.decode(
         codeToHtml(token.text, { lang: isSupportedLanguage(language) ? language : "text", theme: OpenCodeTheme }),
       )

From 3de0717fdfd13a19922ac8ca3bf30a7a9f46f136 Mon Sep 17 00:00:00 2001
From: Luke Parker <10430890+Hona@users.noreply.github.com>
Date: Thu, 22 Oct 2026 08:20:15 +1000
Subject: [PATCH 3/4] fix(ui): fit the Highlights palette to Shiki's colours on
 real session code

---
 packages/ui/src/context/highlight-theme.tsx | 26 ++++++++++++++-------
 1 file changed, 17 insertions(+), 9 deletions(-)

diff --git a/packages/ui/src/context/highlight-theme.tsx b/packages/ui/src/context/highlight-theme.tsx
index 2d7a797e972e..62323b3f799d 100644
--- a/packages/ui/src/context/highlight-theme.tsx
+++ b/packages/ui/src/context/highlight-theme.tsx
@@ -1,7 +1,8 @@
 import type { Theme } from "@pierre/highlights"
 
 // A Highlights palette: each value names an app CSS variable without its `--`, so code follows the active app theme.
-// Token names fall back to their parent scope, so `keyword.control` uses `keyword`.
+// Token names fall back to their parent scope, so `keyword.control` uses `keyword`. Each token type uses the colour
+// Shiki's TextMate theme most often gave the same characters.
 export const OpenCodeTheme = {
   name: "OpenCode",
   appearance: "dark",
@@ -13,11 +14,13 @@ export const OpenCodeTheme = {
     deleted: "syntax-diff-delete",
     modified: "syntax-diff-unknown",
     syntax: {
-      attribute: "syntax-property",
+      attribute: "syntax-keyword",
+      "attribute.jsx": "syntax-property",
       background: "color-background-stronger",
       boolean: "syntax-constant",
       comment: "syntax-comment",
       constant: "syntax-constant",
+      "constant.builtin": "syntax-primitive",
       constructor: "syntax-type",
       "diff.minus": "syntax-diff-delete",
       "diff.plus": "syntax-diff-add",
@@ -29,18 +32,22 @@ export const OpenCodeTheme = {
       function: "syntax-primitive",
       hint: "syntax-comment",
       keyword: "syntax-keyword",
-      "keyword.operator": "syntax-operator",
-      label: "syntax-constant",
+      label: "syntax-type",
       link_text: "text-base",
       link_uri: "text-base",
       namespace: "syntax-type",
       number: "syntax-constant",
       operator: "syntax-operator",
       predictive: "syntax-comment",
-      preproc: "syntax-keyword",
+      preproc: "syntax-primitive",
       primary: "text-base",
-      property: "syntax-primitive",
-      punctuation: "syntax-punctuation",
+      property: "syntax-variable",
+      "property.json_key": "syntax-primitive",
+      punctuation: "text-base",
+      "punctuation.bracket.jsx": "syntax-punctuation",
+      "punctuation.delimiter.jsx": "syntax-operator",
+      "punctuation.list_marker": "syntax-punctuation",
+      "punctuation.special": "syntax-punctuation",
       selector: "syntax-string",
       "selector.class": "syntax-property",
       "selector.id": "syntax-property",
@@ -51,11 +58,12 @@ export const OpenCodeTheme = {
       "string.special": "syntax-constant",
       tag: "syntax-string",
       "tag.component.jsx": "syntax-type",
-      "tag.doctype": "syntax-keyword",
       text: "text-base",
+      "text.jsx": "syntax-punctuation",
       title: { color: "syntax-info", font_weight: 700 },
       type: "syntax-type",
-      "type.builtin": "syntax-primitive",
+      "type.builtin": "syntax-type",
+      "type.class": "syntax-primitive",
       variable: "syntax-variable",
       "variable.special": "syntax-constant",
       variant: "syntax-primitive",

From ef5551e3a1c6dbe887f60e035fc1a432e300b9f7 Mon Sep 17 00:00:00 2001
From: Luke Parker <10430890+Hona@users.noreply.github.com>
Date: Thu, 22 Oct 2026 08:25:30 +1000
Subject: [PATCH 4/4] feat(ui): group syntax colours the way Pierre's own theme
 does

---
 packages/ui/src/context/highlight-theme.tsx | 54 +++++++++------------
 1 file changed, 24 insertions(+), 30 deletions(-)

diff --git a/packages/ui/src/context/highlight-theme.tsx b/packages/ui/src/context/highlight-theme.tsx
index 62323b3f799d..a432d95244e1 100644
--- a/packages/ui/src/context/highlight-theme.tsx
+++ b/packages/ui/src/context/highlight-theme.tsx
@@ -1,8 +1,9 @@
 import type { Theme } from "@pierre/highlights"
 
 // A Highlights palette: each value names an app CSS variable without its `--`, so code follows the active app theme.
-// Token names fall back to their parent scope, so `keyword.control` uses `keyword`. Each token type uses the colour
-// Shiki's TextMate theme most often gave the same characters.
+// Token types are grouped as in Pierre's own `pierre-dark` theme (for example, properties share the variable colour
+// and all punctuation is one muted colour); each group uses the app syntax role with the same meaning.
+// Token names fall back to their parent scope, so `keyword.control` uses `keyword`.
 export const OpenCodeTheme = {
   name: "OpenCode",
   appearance: "dark",
@@ -14,59 +15,52 @@ export const OpenCodeTheme = {
     deleted: "syntax-diff-delete",
     modified: "syntax-diff-unknown",
     syntax: {
-      attribute: "syntax-keyword",
-      "attribute.jsx": "syntax-property",
+      attribute: "syntax-property",
       background: "color-background-stronger",
       boolean: "syntax-constant",
       comment: "syntax-comment",
       constant: "syntax-constant",
-      "constant.builtin": "syntax-primitive",
       constructor: "syntax-type",
       "diff.minus": "syntax-diff-delete",
       "diff.plus": "syntax-diff-add",
-      embedded: "syntax-punctuation",
-      emphasis: { color: "text-base", font_style: "italic" },
-      "emphasis.strong": { color: "text-strong", font_weight: 700 },
+      embedded: "text-base",
+      emphasis: "text-base",
       enum: "syntax-type",
       foreground: "text-base",
       function: "syntax-primitive",
       hint: "syntax-comment",
       keyword: "syntax-keyword",
-      label: "syntax-type",
-      link_text: "text-base",
-      link_uri: "text-base",
-      namespace: "syntax-type",
+      "keyword.operator": "syntax-operator",
+      label: "syntax-constant",
+      link_text: "syntax-info",
+      link_uri: "syntax-keyword",
+      namespace: "syntax-constant",
       number: "syntax-constant",
       operator: "syntax-operator",
-      predictive: "syntax-comment",
-      preproc: "syntax-primitive",
-      primary: "text-base",
+      predictive: { color: "syntax-comment", font_style: "italic" },
+      preproc: "syntax-keyword",
+      primary: "syntax-info",
       property: "syntax-variable",
-      "property.json_key": "syntax-primitive",
-      punctuation: "text-base",
-      "punctuation.bracket.jsx": "syntax-punctuation",
-      "punctuation.delimiter.jsx": "syntax-operator",
-      "punctuation.list_marker": "syntax-punctuation",
-      "punctuation.special": "syntax-punctuation",
-      selector: "syntax-string",
+      punctuation: "syntax-punctuation",
+      "punctuation.special": "syntax-keyword",
+      selector: "syntax-keyword",
       "selector.class": "syntax-property",
-      "selector.id": "syntax-property",
-      "selector.pseudo": "syntax-property",
+      "selector.id": "syntax-primitive",
+      "selector.pseudo": "syntax-operator",
       string: "syntax-string",
       "string.escape": "syntax-constant",
       "string.regex": "syntax-regexp",
       "string.special": "syntax-constant",
-      tag: "syntax-string",
+      tag: "syntax-keyword",
       "tag.component.jsx": "syntax-type",
       text: "text-base",
-      "text.jsx": "syntax-punctuation",
-      title: { color: "syntax-info", font_weight: 700 },
+      "text.literal": "syntax-string",
+      title: { color: "syntax-keyword", font_weight: 700 },
       type: "syntax-type",
-      "type.builtin": "syntax-type",
-      "type.class": "syntax-primitive",
       variable: "syntax-variable",
+      "variable.parameter": "text-base",
       "variable.special": "syntax-constant",
-      variant: "syntax-primitive",
+      variant: "syntax-constant",
     },
   },
 } satisfies Theme