", ``)
-})
-
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