diff --git a/docs/tools/diffs.md b/docs/tools/diffs.md index 47e59c2fc443..46d769e63fcb 100644 --- a/docs/tools/diffs.md +++ b/docs/tools/diffs.md @@ -8,12 +8,14 @@ read_when: - You need controlled, temporary diff artifacts with secure defaults --- -`diffs` is an optional bundled plugin tool that turns before/after text or a unified patch into a read-only diff artifact. It also prepends short agent guidance into the system prompt and ships a companion skill for fuller instructions. +`diffs` is an optional plugin tool that turns before/after text or a unified patch into a read-only diff artifact. It also prepends short agent guidance into the system prompt and ships a companion skill for fuller instructions. Input: `before` + `after` text, or a unified `patch` (mutually exclusive). Output: a gateway viewer URL for browser presentation, a rendered PNG/PDF file path for message delivery, or both. +The [Control UI](/web/control-ui) already highlights inline tool diffs and session diffs without this plugin. Install `diffs` when agents need standalone viewer links or rendered attachments for other channels. + ## Quick start diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 600c7b4f4b94..3b64583353c1 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -296,7 +296,7 @@ select it to open the owning Approvals page. - Links to `github.com` in chat messages — yours and the agent's — carry a small GitHub mark before their text, whether the message wrote a bare URL, a `[#3434](…)` shorthand, or any other label. The mark is drawn from the bundled icon set, never fetched from the network, and is decorative only: it is skipped for image-only links such as badges, never appears inside code spans or code blocks, is not read by screen readers, and is not part of copied text. - Hovering or keyboard-focusing a public GitHub issue or pull request link shows its state, title, author, recent activity, comments, and change statistics. The connected Gateway fetches and caches public metadata without changing the link target, including when the UI uses a remote Gateway. It uses the explicit Control UI GitHub credential, then the shared Gateway process-environment fallback, after confirming the repository is public; without either, it uses GitHub's anonymous API with a longer cache. It never uses an agent GitHub identity. - Talk through browser realtime sessions. OpenAI supports browser WebRTC and Gateway-relayed provider WebSockets, Google Live uses a constrained one-use browser token over WebSocket, and backend-only realtime voice plugins use Gateway relay. Video-capable browser sessions can choose a device-local camera in Settings or flip cameras from the live preview; the browser captures JPEG frames for the realtime provider without streaming camera video through the Gateway. Client-owned provider sessions start with `talk.client.create`; Gateway relay sessions start with `talk.session.create`. The relay keeps provider credentials on the Gateway while the browser streams microphone PCM through `talk.session.appendAudio`, forwards provider delegations or `openclaw_agent_consult` tool calls through Gateway policy and the larger configured OpenClaw model, and routes active-run voice steering through `talk.client.steer` or `talk.session.steer`. Browser WebRTC GPT-Live delegates on the Gateway-owned sideband, but each delegation has the same spoken-confirmation gate and browser-owned `talk.client.steer` lifecycle; a newer spoken task can also supersede the running delegation. Gateway-relayed GPT-Live uses the normal relay consult and steering path. Configure the realtime provider, model, and speaker voice on **Settings → Talk**, whose pickers come from `talk.catalog` and show whether the selection is ready to use. - - Stream tool calls and live tool output cards in Chat (agent events). Tool activity renders as kind-aware rows: shell commands show the syntax-highlighted command with terminal-style output; supported edit and write calls show bounded inline diffs, line numbers when available, and `+added -removed` stats; and consecutive calls collapse into a summary such as "Ran 13 commands, read 6 files, edited 9 files". While a run is live, the newest running call names the group header. Expand a row to inspect its remaining arguments and raw output. + - Stream tool calls and live tool output cards in Chat (agent events). Tool activity renders as kind-aware rows: shell commands show the syntax-highlighted command with terminal-style output; supported edit and write calls show bounded inline diffs with source syntax highlighting, line numbers when available, and `+added -removed` stats; and consecutive calls collapse into a summary such as "Ran 13 commands, read 6 files, edited 9 files". While a run is live, the newest running call names the group header. Expand a row to inspect its remaining arguments and raw output. - Optional AI purpose titles for complex tool calls (long shell commands, argument-heavy plugin tools), enabled with `gateway.controlUi.toolTitles: true` (default off). Titles come from the batched `chat.toolTitles` method through standard utility-model routing — an explicit `utilityModel` (operator-chosen provider, like other utility tasks), else the session provider's declared small-model default — and cache gateway-side per agent. When the opt-in is off or no cheap model is usable, rows keep their deterministic labels and no model call happens. - Start or dismiss ephemeral model-suggested follow-up tasks; accepted suggestions open a fresh managed-worktree session with the proposed prompt. - Activity tab with browser-local, redaction-first summaries of live tool activity from existing `session.tool` / tool event delivery. @@ -537,7 +537,7 @@ Capability toggles stay disabled until the Gateway, session, and runtime config - Consecutive duplicate text-only messages render as one bubble with a count badge. Messages that carry images, attachments, tool output, or canvas previews are left uncollapsed. - User-message bubbles carry transcript actions: a hover rewind button (confirm popover with a "Don't ask again" option) plus right-click **Rewind to here** and **Fork from here**. Rewind repoints the session to the state just before that message and returns its text to the composer for edit and resend (`sessions.rewind`, `operator.admin`); fork creates a new session from the active-path prefix before the message, opens it, and seeds its composer with the same text (`sessions.fork`, `operator.write`). Both actions disable with an explanatory tooltip while the agent is working, apply only to persisted user messages, and are rejected for sessions whose conversation is owned by an external agent harness. Rewind moves chat context only — files and other tool side effects are not reverted — and the pre-rewind transcript remains preserved in the append-only session store. When that store contains multiple transcript branches, the chat title bar shows a branch menu with each branch's latest message, message count, and recency; selecting an inactive branch switches the current session back to that preserved path (`sessions.branches.list`, `operator.read`; `sessions.branches.switch`, `operator.admin`). Branch switching is also unavailable while the agent is working, and selecting the already-active branch is a typed no-op error at the RPC boundary. - When a session's checkout sits on a non-default branch of a GitHub repository, the chat view pins pull request chips above the composer: PR number, repo, branch, diff counts, a CI pill, and draft/merged/closed state, each linking to the PR. The row shows at most two chips — live (open/draft) PRs first — and a "Show more" button reveals collapsed merged/closed history. The CI pill opens a small CI monitoring popover with passed/failed/running/skipped check counts and a link to the PR's checks page. The Gateway polls only sessions visible in a connected Control UI and pushes changed snapshots through `controlUi.sessionPullRequests.changed`; it uses the explicit Control UI GitHub credential or the shared process-environment fallback for this read-only preview. When the GitHub API rate limit is hit, chips keep the last known status and show a warning that the status may be out of date; dismissing a chip hides it for that session in the current browser profile. Before any PR exists, the row shows the branch itself — repo, branch name, and the +/− size of the diff against the default-branch merge base (committed and uncommitted work). **Publish PR** sends only the session key and bounded presentation text to the Gateway-owned publication broker. The broker derives the repository and branch from session ownership, uses the effective agent GitHub identity rather than the preview credential, and returns the draft pull request URL or an actionable typed failure. The row hides itself while an open or draft PR exists; once the branch's PR is merged and the pushed tip still matches the merged head, the row disappears too. The branch row comes from local git, so it stays available while GitHub is rate limited and carries the same stale-status warning, since "no PR found" cannot be trusted until the limit resets. - - The session diff panel shows what a session's checkout actually changed: the branch button in the workspace rail or chat title bar opens a dense per-file viewer with normalized added/deleted/modified counts, collapsible files, wrapping and unified/split layouts, file copy/open/editor actions, and "N unmodified lines" markers between hunks. The footer switches between all changes, uncommitted work, and individual commits while showing how far the branch is ahead of its merge base; committed branches also provide a copyable local sync command. Diffs are computed server-side through the `sessions.diff` Gateway method (`operator.read` scope); binary and oversized files degrade to stats-only entries, and the button only appears when the connected Gateway advertises `sessions.diff`. + - The session diff panel shows what a session's checkout actually changed: the branch button in the workspace rail or chat title bar opens a dense per-file viewer with normalized added/deleted/modified counts, collapsible files, wrapping and unified/split layouts with source syntax highlighting, file copy/open/editor actions, and "N unmodified lines" markers between hunks. The footer switches between all changes, uncommitted work, and individual commits while showing how far the branch is ahead of its merge base; committed branches also provide a copyable local sync command. Diffs are computed server-side through the `sessions.diff` Gateway method (`operator.read` scope); binary and oversized files degrade to stats-only entries, and the button only appears when the connected Gateway advertises `sessions.diff`. - Every Chat pane has a title bar. Click the session title to rename it; the workspace chip copies the checkout path or branch and can reveal local Gateway workspaces in the host file manager. Remote and exec-node sessions keep copy actions but hide reveal. - The **Files** tab in each Chat pane's unified side panel lists thread files, project files, and artifacts. Reopen it with ⇧⌘B, the files toggle in the title bar, or the panel's **+** menu; the title-bar toggle carries a changed-file count badge. - File paths recognized in chat messages read as their basename with a small glyph for the file type in front — a Markdown page, a `package.json` manifest, a TypeScript source, a `.tsx` component, a config or data file, a shell script, and an image each get their own mark, and anything else falls back to a plain document. When two links in the same message share a basename, each keeps just enough of its trailing path to stay distinct. The full path stays on the link: it is what the tooltip shows, what opens in the file panel, and what the message's **Copy** action returns, since copy hands back the original Markdown. Labels you write yourself in a `[label](path)` link are never rewritten. The glyph is drawn from the bundled icon set, never fetched from the network, and is decorative only: it is not read by screen readers and is not part of copied text. Text that is not a recognizable path — anything carrying spaces, parentheses, a `#` fragment, or a `?` query — stays plain prose. @@ -545,6 +545,7 @@ Capability toggles stay disabled until the Gateway, session, and runtime config - The **Tasks** tab lists the current agent's background tasks and subagents (`tasks.list` scoped by agent, kept live by `task` events): running work shows a live elapsed timer, tool-use count, the tool currently in use, and a stop control, while the collapsible finished section adds run durations. Selecting a task from either a task row or an inline subagent activity row opens its live status and transcript in **Review** without replacing the main conversation; tasks whose session is the current conversation show their prompt and output inspector there instead. Open **Tasks** with the title-bar activity toggle or the panel's **+** menu; the task snapshot loads eagerly, so the title-bar toggle carries a running-count badge without opening the tab first. The Tasks page remains the full cross-agent ledger. - The unified side panel adapts to each pane's own width rather than the window: it can dock right or bottom in a wide pane, falls back to a bottom strip in a narrow pane or compact window, and hides its dock controls until the pane widens. Phone-sized viewports still open review content full-screen. - The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options. + - Diff syntax highlighting uses each file's language and the current theme; unknown file types and oversized previews remain plain text. Inline and session diffs do not require the optional [Diffs plugin](/tools/diffs), which creates standalone viewer links and PNG/PDF attachments. - **Split view:** open it from the chat title bar (beside the thread diff, background tasks, and thread files toggles), then split the active pane right or down for as many panes as fit. Each pane has its own thread, transcript, composer, and tool stream. - Agents with the `screen` tool can request the same pane, sidebar, terminal, browser, focus, and navigation changes while a capable Control UI is connected. Protocol v1 applies the command to every connected capable Control UI; see [Screen](/tools/screen). - Drag a session from the sidebar into chat to open it in a pane. An animated drop preview glides between zones and labels the outcome — "Split" over the exact half a new pane will occupy, "Open here" over a whole pane — and drops also work from single-pane mode. diff --git a/ui/src/components/code-language.ts b/ui/src/components/code-language.ts new file mode 100644 index 000000000000..1a05696da451 --- /dev/null +++ b/ui/src/components/code-language.ts @@ -0,0 +1,15 @@ +import { LanguageDescription } from "@codemirror/language"; +import { languages } from "@codemirror/language-data"; + +export async function loadCodeLanguage(path: string) { + const name = path.replaceAll("\\", "/").split("/").at(-1) ?? path; + const description = LanguageDescription.matchFilename(languages, name); + if (!description) { + return null; + } + try { + return await description.load(); + } catch { + return null; + } +} diff --git a/ui/src/e2e/chat-session-diff.e2e.test.ts b/ui/src/e2e/chat-session-diff.e2e.test.ts index 96dd9caf736c..f53ec25d6cf3 100644 --- a/ui/src/e2e/chat-session-diff.e2e.test.ts +++ b/ui/src/e2e/chat-session-diff.e2e.test.ts @@ -1,4 +1,6 @@ // Control UI tests cover the session diff panel (sessions.diff RPC). +import { mkdir } from "node:fs/promises"; +import path from "node:path"; import { chromium, type Browser, type BrowserContext } from "playwright"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { CONTROL_UI_SESSION_PULL_REQUESTS_CHANGED_EVENT } from "../../../src/gateway/control-ui-contract.js"; @@ -22,6 +24,8 @@ const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium. const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; +const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/diff-highlighting"); let server: ControlUiE2eServer; // Browser contexts preserve test isolation; keep one process warm for this file. @@ -50,20 +54,20 @@ const APP_PATCH = [ "--- a/src/app.ts", "+++ b/src/app.ts", "@@ -30,3 +30,4 @@", - " context line", - "-removed line", - "+replacement line", - "+extra line", - " trailing context", + " // context line", + '-const message = "removed line";', + '+const message = "replacement line";', + '+console.log("extra line");', + " // trailing context", "", ].join("\n"); const APP_FILE_TEXT = [ - ...Array.from({ length: 29 }, (_, index) => `unchanged line ${index + 1}`), - "context line", - "replacement line", - "extra line", - "trailing context", + ...Array.from({ length: 29 }, (_, index) => `// unchanged line ${index + 1}`), + "// context line", + 'const message = "replacement line";', + 'console.log("extra line");', + "// trailing context", "", ].join("\n"); @@ -171,7 +175,7 @@ describeControlUiE2e("session diff panel", () => { afterEach(closeContexts); - it("opens the session diff when Review is added from the panel menu", async () => { + it("opens a renamed session diff when Review is added from the panel menu", async () => { const context = await newBrowserContext(); const page = await context.newPage(); await installMockGateway(page, { @@ -184,7 +188,21 @@ describeControlUiE2e("session diff panel", () => { files: [], browser: { path: "", entries: [] }, }, - "sessions.diff": SESSION_DIFF_RESPONSE, + "sessions.diff": { + ...SESSION_DIFF_RESPONSE, + files: [ + { + path: "example.ts", + oldPath: "example.html", + status: "renamed", + additions: 1, + deletions: 1, + patch: + '@@ -1 +1 @@\n-
Hello
\n+const value = "after";', + }, + ], + additions: 1, + }, }, }); await page.goto(`${server.baseUrl}chat`); @@ -192,7 +210,15 @@ describeControlUiE2e("session diff panel", () => { await openChatSidePanelType(page, "Files"); await openChatSidePanelType(page, "Review"); - await waitForSessionDiff(page); + await expect + .poll(() => page.locator(".session-diff__old-path").textContent()) + .toContain("example.html"); + await expect + .poll(() => page.locator(".chat-diff__row--del .tok-propertyName").textContent()) + .toBe("data-mode"); + await expect + .poll(() => page.locator(".chat-diff__row--add .tok-keyword").textContent()) + .toBe("const"); }); it("requests the default session diff once across subsequent pane renders", async () => { @@ -570,6 +596,20 @@ describeControlUiE2e("session diff panel", () => { await expect .poll(() => modified.locator(".chat-diff__row--add").first().textContent()) .toContain("replacement line"); + await expect + .poll(() => modified.locator(".chat-diff__row--add .tok-keyword").textContent()) + .toBe("const"); + expect( + await modified + .locator(".chat-diff__row--add .tok-keyword") + .evaluate( + (token) => getComputedStyle(token).color !== getComputedStyle(token.parentElement!).color, + ), + ).toBe(true); + if (captureProof) { + await mkdir(artifactDir, { recursive: true }); + await panelSurface.screenshot({ path: path.join(artifactDir, "unified-light.png") }); + } await modified.getByRole("button", { name: "Show next 20 unmodified lines" }).click(); await expect.poll(async () => (await gateway.getRequests("sessions.diff")).length).toBe(2); @@ -600,6 +640,20 @@ describeControlUiE2e("session diff panel", () => { await panel.getByRole("button", { name: "Change view options" }).click(); await page.getByRole("menuitem", { name: "Switch to Split Diff" }).click(); await expect.poll(() => modified.locator(".session-diff-split").count()).toBe(1); + await expect + .poll(() => modified.locator(".session-diff-split__side--right .tok-keyword").textContent()) + .toBe("const"); + if (captureProof) { + await page.emulateMedia({ colorScheme: "dark" }); + await page.evaluate(() => { + document.documentElement.dataset.themeMode = "dark"; + document.documentElement.dataset.themeResolved = "dark"; + }); + await modified + .locator(".session-diff-split__side--right .tok-keyword") + .scrollIntoViewIfNeeded(); + await panelSurface.screenshot({ path: path.join(artifactDir, "split-dark.png") }); + } await panel.getByRole("button", { name: "Change view options" }).click(); await page.getByRole("menuitem", { name: "Switch to Unified Diff" }).click(); await expect.poll(() => modified.locator(".chat-diff").count()).toBe(1); diff --git a/ui/src/e2e/session-owner-first.e2e.test.ts b/ui/src/e2e/session-owner-first.e2e.test.ts index 8d0ce83197d6..6f3c6014e1db 100644 --- a/ui/src/e2e/session-owner-first.e2e.test.ts +++ b/ui/src/e2e/session-owner-first.e2e.test.ts @@ -64,19 +64,21 @@ suite.define(() => { }); try { - await page.goto(`${suite.server?.baseUrl ?? ""}chat`); + // A literal key avoids the independent slug lookup while the roster is deferred. + await page.goto(`${suite.server?.baseUrl ?? ""}chat/main/~key/ada`); const subscribe = await gateway.waitForRequest("sessions.subscribe"); expect(subscribe.params).toEqual(expect.objectContaining({ ownerFirst: true, limit: 60 })); - expect(await gateway.getRequests("sessions.list")).toHaveLength(0); const adaRow = page.locator('[data-session-key="agent:main:ada"]'); const bobRow = page.locator('[data-session-key="agent:main:bob"]'); // The selected session has an optimistic placeholder before roster hydration. await expect.poll(() => adaRow.count()).toBe(1); await expect.poll(() => bobRow.count()).toBe(0); + expect(await gateway.getRequests("sessions.list")).toHaveLength(0); await gateway.resolveDeferred("sessions.subscribe", { subscribed: true, list: sharedRoster }); await adaRow.waitFor(); await bobRow.waitFor(); + expect(await gateway.getRequests("sessions.list")).toHaveLength(0); await captureSidebar(page, "owner-first-bootstrap.png"); } finally { await context.close(); diff --git a/ui/src/lib/chat/tool-call-diff.ts b/ui/src/lib/chat/tool-call-diff.ts index bec5308b01a6..b5def30f0182 100644 --- a/ui/src/lib/chat/tool-call-diff.ts +++ b/ui/src/lib/chat/tool-call-diff.ts @@ -15,8 +15,13 @@ export type DiffLineGap = { count: number; }; +export type DiffFilePaths = { path: string; oldPath?: string }; + export type DiffLine = { kind: DiffLineKind; + /** Source filenames on file separators, for per-side language selection. */ + path?: string; + oldPath?: string; /** 1-based line number in the file (new file for adds/ctx, old file for dels). */ lineNo?: number; /** Session-diff coordinates for an expandable unchanged-lines marker. */ diff --git a/ui/src/lib/chat/tool-call-patch.ts b/ui/src/lib/chat/tool-call-patch.ts index 6cd81286b1f9..7e3a379c447c 100644 --- a/ui/src/lib/chat/tool-call-patch.ts +++ b/ui/src/lib/chat/tool-call-patch.ts @@ -2,6 +2,7 @@ import { asNullableRecord as asRecord } from "@openclaw/normalization-core/recor import { readNonBlankString } from "@openclaw/normalization-core/string-coerce"; import { MAX_DIFF_RENDER_LINES, + type DiffFilePaths, type DiffLine, type DiffLineKind, type DiffStat, @@ -9,9 +10,8 @@ import { type PatchOperation = "add" | "delete" | "update"; -export type PatchFileOperation = { +export type PatchFileOperation = DiffFilePaths & { operation: PatchOperation; - path: string; }; type PatchSection = { @@ -162,7 +162,11 @@ function finish(collector: PatchCollector): PatchViewData | null { return null; } const paths = [...new Set(collector.sections.map((section) => section.path).filter(Boolean))]; - const fileOperations = collector.sections.map(({ operation, path }) => ({ operation, path })); + const fileOperations = collector.sections.map(({ operation, path, sourcePath }) => ({ + operation, + path, + ...(operation === "update" && sourcePath !== path ? { oldPath: sourcePath } : {}), + })); const stat = collector.sections.reduce( (sum, section) => ({ added: sum.added + section.stat.added, @@ -179,12 +183,17 @@ function finish(collector: PatchCollector): PatchViewData | null { clipped = true; } }; - for (const section of collector.sections) { + for (const [index, section] of collector.sections.entries()) { if (collector.sections.length > 1) { if (lines.length > 0 && lines.at(-1)?.kind !== "skip") { append({ kind: "skip", text: "" }); } - append({ kind: "file", text: sectionLabel(section) }); + append({ + kind: "file", + path: section.path, + ...("oldPath" in fileOperations[index]! ? { oldPath: section.sourcePath } : {}), + text: sectionLabel(section), + }); } for (const line of section.lines) { append(line); diff --git a/ui/src/lib/chat/tool-call-view.test.ts b/ui/src/lib/chat/tool-call-view.test.ts index beb99efa79bd..6557b6ec500c 100644 --- a/ui/src/lib/chat/tool-call-view.test.ts +++ b/ui/src/lib/chat/tool-call-view.test.ts @@ -305,14 +305,14 @@ describe("resolveToolCallView", () => { expect(view.targetDetail).toBeUndefined(); expect(view.stat).toEqual({ added: 2, removed: 1 }); expect(view.diff).toEqual([ - { kind: "file", text: "Update src/a.ts" }, + { kind: "file", path: "src/a.ts", text: "Update src/a.ts" }, { kind: "del", text: "old a" }, { kind: "add", text: "new a" }, { kind: "skip", text: "" }, - { kind: "file", text: "Add src/b.ts" }, + { kind: "file", path: "src/b.ts", text: "Add src/b.ts" }, { kind: "add", lineNo: 1, text: "new b" }, { kind: "skip", text: "" }, - { kind: "file", text: "Delete src/c.ts" }, + { kind: "file", path: "src/c.ts", text: "Delete src/c.ts" }, ]); }); @@ -369,14 +369,14 @@ describe("resolveToolCallView", () => { expect(view.target).toBe("2 files"); expect(view.stat).toEqual({ added: 3, removed: 2 }); expect(view.diff).toEqual([ - { kind: "file", text: "Update src/a.ts" }, + { kind: "file", path: "src/a.ts", text: "Update src/a.ts" }, { kind: "ctx", lineNo: 10, text: "context" }, { kind: "del", lineNo: 11, text: "old" }, { kind: "add", lineNo: 11, text: "new" }, { kind: "add", lineNo: 12, text: "extra" }, { kind: "ctx", lineNo: 13, text: "tail" }, { kind: "skip", text: "" }, - { kind: "file", text: "Update src/b.ts" }, + { kind: "file", path: "src/b.ts", text: "Update src/b.ts" }, { kind: "del", lineNo: 1, text: "before" }, { kind: "add", lineNo: 1, text: "after" }, ]); @@ -422,8 +422,8 @@ describe("resolveToolCallView", () => { { operation: "add", path: "src/b.ts" }, ]); expect(view.stat).toEqual({ added: 2, removed: 1 }); - expect(view.diff).toContainEqual({ kind: "file", text: "Update src/a.ts" }); - expect(view.diff).toContainEqual({ kind: "file", text: "Add src/b.ts" }); + expect(view.diff).toContainEqual({ kind: "file", path: "src/a.ts", text: "Update src/a.ts" }); + expect(view.diff).toContainEqual({ kind: "file", path: "src/b.ts", text: "Add src/b.ts" }); }); it("numbers structured Codex update hunks", () => { diff --git a/ui/src/pages/chat/components/chat-diff-highlight.runtime.ts b/ui/src/pages/chat/components/chat-diff-highlight.runtime.ts new file mode 100644 index 000000000000..30a6e6ec1e23 --- /dev/null +++ b/ui/src/pages/chat/components/chat-diff-highlight.runtime.ts @@ -0,0 +1,68 @@ +import { classHighlighter, highlightCode } from "@lezer/highlight"; +import { html } from "lit"; +import { loadCodeLanguage } from "../../../components/code-language.ts"; +import type { DiffLine } from "../../../lib/chat/tool-call-diff.ts"; + +// Syntax is optional decoration; keep oversized/minified diffs readable without +// synchronously parsing an unbounded source string on the UI thread. +const MAX_HIGHLIGHT_CHARS = 120_000; + +export async function highlightDiffLines(lines: readonly DiffLine[], path: string, oldPath = path) { + const highlighted = new Map(); + let size = 0; + for (const line of lines) { + size += line.text.length + 1; + if (size > MAX_HIGHLIGHT_CHARS) { + return highlighted; + } + } + let section: { path: string; oldPath: string; lines: DiffLine[] } = { path, oldPath, lines: [] }; + const sections: (typeof section)[] = []; + for (const line of lines) { + if (line.kind === "file" || line.kind === "skip") { + sections.push(section); + section = + line.kind === "file" + ? { path: line.path ?? "", oldPath: line.oldPath ?? line.path ?? "", lines: [] } + : { ...section, lines: [] }; + } else { + section.lines.push(line); + } + } + sections.push(section); + await Promise.all( + sections.map(async (part) => { + if (!part.lines.length) { + return; + } + // Parse each side independently so deleted comments/strings cannot color + // added code. Renames retain each language; shared context uses the new side. + // A gap starts a new parse: omitted source is unknown. + for (const excluded of ["add", "del"]) { + const support = await loadCodeLanguage(excluded === "add" ? part.oldPath : part.path); + if (!support) { + continue; + } + const side = part.lines.filter((line) => line.kind !== excluded); + const code = side.map((line) => line.text).join("\n"); + const tokens: unknown[][] = [[]]; + highlightCode( + code, + support.language.parser.parse(code), + classHighlighter, + (text, classes) => + tokens.at(-1)!.push(classes ? html`${text}` : text), + () => { + tokens.push([]); + }, + ); + side.forEach((line, index) => { + if (line.text && (excluded === "del" || line.kind === "del")) { + highlighted.set(line, tokens[index]); + } + }); + } + }), + ); + return highlighted; +} diff --git a/ui/src/pages/chat/components/chat-diff-highlight.ts b/ui/src/pages/chat/components/chat-diff-highlight.ts new file mode 100644 index 000000000000..0bf1f81400be --- /dev/null +++ b/ui/src/pages/chat/components/chat-diff-highlight.ts @@ -0,0 +1,47 @@ +import { AsyncDirective, directive } from "lit/async-directive.js"; +import type { DiffFilePaths, DiffLine } from "../../../lib/chat/tool-call-diff.ts"; + +type RenderDiff = (renderLine: (line: DiffLine) => unknown) => unknown; + +class DiffHighlightDirective extends AsyncDirective { + private lines?: readonly DiffLine[]; + private path?: string; + private oldPath?: string; + private highlighted: ReadonlyMap = new Map(); + private renderDiff?: RenderDiff; + private readonly renderLine = (line: DiffLine) => + this.highlighted.get(line) ?? (line.text || " "); + + override render(lines: readonly DiffLine[], file: DiffFilePaths, renderDiff: RenderDiff) { + const { path, oldPath = path } = file; + this.renderDiff = renderDiff; + if (this.lines !== lines || this.path !== path || this.oldPath !== oldPath) { + this.lines = lines; + this.path = path; + this.oldPath = oldPath; + this.highlighted = new Map(); + void import("./chat-diff-highlight.runtime.ts") + .then(({ highlightDiffLines }) => highlightDiffLines(lines, path, oldPath)) + .then((highlighted) => { + // A refreshed patch can replace these rows while a language loads. + if (this.lines !== lines || this.path !== path || this.oldPath !== oldPath) { + return; + } + this.highlighted = highlighted; + if (this.isConnected) { + this.setValue(this.renderDiff?.(this.renderLine)); + } + }) + .catch(() => { + // A failed lazy chunk must leave the original escaped diff visible. + }); + } + return renderDiff(this.renderLine); + } + + protected override reconnected() { + this.setValue(this.renderDiff?.(this.renderLine)); + } +} + +export const renderHighlightedDiff = directive(DiffHighlightDirective); diff --git a/ui/src/pages/chat/components/chat-diff-render.ts b/ui/src/pages/chat/components/chat-diff-render.ts index a3bca36f519a..18b15d1d54a4 100644 --- a/ui/src/pages/chat/components/chat-diff-render.ts +++ b/ui/src/pages/chat/components/chat-diff-render.ts @@ -4,7 +4,8 @@ import { html, nothing } from "lit"; import { t } from "../../../i18n/index.ts"; import type { ToolCardOutcome } from "../../../lib/chat/chat-types.ts"; -import type { DiffLine, DiffStat } from "../../../lib/chat/tool-call-diff.ts"; +import type { DiffFilePaths, DiffLine, DiffStat } from "../../../lib/chat/tool-call-diff.ts"; +import { renderHighlightedDiff } from "./chat-diff-highlight.ts"; export function renderDiffStatChips(stat: DiffStat & { modified?: number }) { // Tool cards omit `modified`; keep their original template byte-for-byte. @@ -28,43 +29,50 @@ export function renderDiffBlock( lines: readonly DiffLine[], outcome: ToolCardOutcome = "succeeded", renderSkip?: (line: DiffLine) => unknown, + file: DiffFilePaths = { path: "" }, ) { const hasLineNumbers = lines.some((line) => line.lineNo !== undefined); - return html` -
- ${lines.map((line) => { - if (line.kind === "skip") { - // Skip rows may carry a caller-formatted gap label ("N unmodified - // lines", session diff panel); tool cards leave text empty. - return html`
- ${hasLineNumbers ? html`` : nothing} - - ${(renderSkip?.(line) ?? line.text) || "⋯"} + return renderHighlightedDiff( + lines, + file, + (renderLine) => html` +
+ ${lines.map((line) => { + if (line.kind === "skip") { + // Skip rows may carry a caller-formatted gap label ("N unmodified + // lines", session diff panel); tool cards leave text empty. + return html`
+ ${hasLineNumbers ? html`` : nothing} + + ${(renderSkip?.(line) ?? line.text) || "⋯"} +
`; + } + const kindClass = + line.kind === "add" + ? "chat-diff__row--add" + : line.kind === "del" + ? "chat-diff__row--del" + : line.kind === "file" + ? "chat-diff__row--file" + : ""; + const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : ""; + return html`
+ ${hasLineNumbers + ? html`${line.lineNo ?? ""}` + : nothing} + ${sign} + ${renderLine(line)}
`; - } - const kindClass = - line.kind === "add" - ? "chat-diff__row--add" - : line.kind === "del" - ? "chat-diff__row--del" - : line.kind === "file" - ? "chat-diff__row--file" - : ""; - const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : ""; - return html`
- ${hasLineNumbers - ? html`${line.lineNo ?? ""}` - : nothing} - ${sign} - ${line.text || " "} -
`; - })} -
- `; + })} +
+ `, + ); } diff --git a/ui/src/pages/chat/components/chat-tool-cards.highlight.test.ts b/ui/src/pages/chat/components/chat-tool-cards.highlight.test.ts new file mode 100644 index 000000000000..d64a735da92c --- /dev/null +++ b/ui/src/pages/chat/components/chat-tool-cards.highlight.test.ts @@ -0,0 +1,140 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderToolCard } from "./chat-tool-cards.ts"; + +describe("tool-card source highlighting", () => { + it.each([ + { + name: "edit", + args: { + path: "example.ts", + oldText: 'const value = "old";', + newText: 'const value = "new";', + }, + outputText: "Updated file", + }, + { + name: "edit", + args: { + path: "example.ts", + oldText: "/* removed comment\ncontinued", + newText: 'const value = "new";', + }, + }, + { name: "write", args: { path: "example.ts", content: 'const value = "new";' } }, + { + name: "apply_patch", + args: { + patch: [ + "*** Begin Patch", + "*** Add File: example.ts", + '+const value = "new";', + "*** Add File: example.py", + "+def greet():", + '+ return "hello"', + "*** End Patch", + ].join("\n"), + }, + }, + ])("highlights $name previews using their file languages", async (card) => { + const container = document.createElement("div"); + render( + renderToolCard({ id: "highlight", ...card }, { expanded: true, onToggleExpanded: vi.fn() }), + container, + ); + await vi.waitFor(() => + expect(container.querySelector(".tok-keyword")?.textContent).toBe("const"), + ); + expect(container.querySelector(".tok-string")?.textContent).toMatch(/"(?:old|new)"/); + if (card.name === "apply_patch") { + expect( + [...container.querySelectorAll(".tok-keyword")].map((node) => node.textContent), + ).toContain("def"); + expect(container.querySelector(".chat-diff__row--file .tok-keyword")).toBeNull(); + } + render(null, container); + }); + + it.each([ + { format: "text", multiple: false }, + { format: "text", multiple: true }, + { format: "structured", multiple: false }, + { format: "structured", multiple: true }, + ])( + "highlights each side of a $format rename with multiple=$multiple", + async ({ format, multiple }) => { + const before = '
Hello
'; + const after = 'const value = "after";'; + const args = + format === "text" + ? { + patch: [ + "*** Begin Patch", + "*** Update File: example.html", + "*** Move to: example.ts", + "@@", + `-${before}`, + `+${after}`, + ...(multiple + ? ["*** Add File: helper.py", "+def greet():", '+ return "hello"'] + : []), + "*** End Patch", + ].join("\n"), + } + : { + changes: [ + { + path: "example.html", + kind: { type: "update", move_path: "example.ts" }, + diff: ["@@ -1 +1 @@", `-${before}`, `+${after}`].join("\n"), + }, + ...(multiple + ? [ + { + path: "helper.py", + kind: { type: "add" }, + diff: 'def greet():\n return "hello"\n', + }, + ] + : []), + ], + }; + const container = document.createElement("div"); + render( + renderToolCard( + { + id: "rename", + name: "apply_patch", + args, + ...(format === "structured" && !multiple ? { outputText: "Applied patch" } : {}), + }, + { expanded: true, onToggleExpanded: vi.fn() }, + ), + container, + ); + + await vi.waitFor(() => { + expect(container.querySelector(".chat-diff__row--del .tok-propertyName")?.textContent).toBe( + "data-mode", + ); + expect(container.querySelector(".chat-diff__row--add .tok-keyword")?.textContent).toBe( + "const", + ); + }); + expect(container.querySelector(".chat-diff__row--del .chat-diff__text")?.textContent).toBe( + before, + ); + expect(container.querySelector(".chat-diff__row--add .chat-diff__text")?.textContent).toBe( + after, + ); + if (multiple) { + expect( + [...container.querySelectorAll(".tok-keyword")].map((node) => node.textContent), + ).toContain("def"); + } + render(null, container); + }, + ); +}); diff --git a/ui/src/pages/chat/components/chat-tool-cards.ts b/ui/src/pages/chat/components/chat-tool-cards.ts index 12db58ceedd6..2e15264943c5 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.ts @@ -14,6 +14,7 @@ import type { ToolCardOutcome, } from "../../../lib/chat/chat-types.ts"; import { readToolApprovalReviews } from "../../../lib/chat/tool-approval-reviews.ts"; +import type { DiffFilePaths } from "../../../lib/chat/tool-call-diff.ts"; import { resolveToolCallView, type ToolCallView } from "../../../lib/chat/tool-call-view.ts"; import { formatDistinctCollapsedToolSummaryText as distinctSummaryText, @@ -673,6 +674,7 @@ function renderToolCardModes( diff: NonNullable, outcome: ToolCardOutcome, isError: boolean, + file: DiffFilePaths, ) { const active = isError ? "raw" : "diff"; const modeLabel = t("chat.toolCards.viewMode"); @@ -692,7 +694,7 @@ function renderToolCardModes( ${t("chat.toolCards.raw")} - ${renderDiffBlock(diff, outcome)} + ${renderDiffBlock(diff, outcome, undefined, file)} ${renderToolDataBlock({ @@ -1010,6 +1012,7 @@ export function renderExpandedToolCardContent( // Edits and writes with a resolvable diff render it inline. When raw output // also exists, the shared tab primitive owns both views and their semantics. if ((view.kind === "edit" || view.kind === "write") && view.diff && view.diff.length > 0) { + const file = view.fileOperations?.[0] ?? { path: view.target ?? "" }; return html`
@@ -1021,8 +1024,8 @@ export function renderExpandedToolCardContent(
${diffCopyAction}${sidebarAction}
${hasOutput - ? renderToolCardModes(card, view.diff, outcome, isError) - : renderDiffBlock(view.diff, outcome)} + ? renderToolCardModes(card, view.diff, outcome, isError, file) + : renderDiffBlock(view.diff, outcome, undefined, file)} ${renderToolOutcome(outcome, card.exitCode)}
`; diff --git a/ui/src/pages/chat/components/file-editor-view.ts b/ui/src/pages/chat/components/file-editor-view.ts index ad4894b02ad9..76ad0b2cb8e1 100644 --- a/ui/src/pages/chat/components/file-editor-view.ts +++ b/ui/src/pages/chat/components/file-editor-view.ts @@ -1,6 +1,5 @@ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; -import { LanguageDescription, syntaxHighlighting } from "@codemirror/language"; -import { languages } from "@codemirror/language-data"; +import { syntaxHighlighting } from "@codemirror/language"; import { Compartment, EditorState, StateEffect, StateField } from "@codemirror/state"; import { Decoration, @@ -11,6 +10,7 @@ import { lineNumbers, } from "@codemirror/view"; import { classHighlighter } from "@lezer/highlight"; +import { loadCodeLanguage } from "../../../components/code-language.ts"; export type FileEditorDecorations = { targetLine?: number | null; @@ -43,18 +43,6 @@ const lineDecorations = StateField.define({ provide: (field) => EditorView.decorations.from(field), }); -async function loadLanguage(name: string) { - const description = LanguageDescription.matchFilename(languages, name); - if (!description) { - return null; - } - try { - return await description.load(); - } catch { - return null; - } -} - // Saves must round-trip the file's original bytes, so CRLF/CR files configure // CodeMirror's line separator instead of silently normalizing to LF on save. function detectLineSeparator(content: string): string | undefined { @@ -70,7 +58,7 @@ export async function createFileEditorView(params: { onSave: () => void; }): Promise { const editable = new Compartment(); - const language = await loadLanguage(params.name); + const language = await loadCodeLanguage(params.name); let docChanged: ((content: string) => void) | null = null; let destroyed = false; let isEditable = params.editable === true; diff --git a/ui/src/pages/chat/components/session-diff-panel.test.ts b/ui/src/pages/chat/components/session-diff-panel.test.ts index 4d604ae7aad9..4dda9d80783b 100644 --- a/ui/src/pages/chat/components/session-diff-panel.test.ts +++ b/ui/src/pages/chat/components/session-diff-panel.test.ts @@ -80,6 +80,79 @@ afterEach(() => { }); describe("SessionDiffPanel", () => { + it.each([false, true])( + "highlights source in split=%s without changing its text", + async (split) => { + setNativeGatewayTestState(null); + localStorage.setItem("openclaw.control.sessionDiff.v1", JSON.stringify({ split })); + const panel = document.createElement("openclaw-session-diff") as SessionDiffElement; + localStorage.removeItem("openclaw.control.sessionDiff.v1"); + const patch = [ + "--- a/example.ts", + "+++ b/example.ts", + "@@ -1,4 +1,4 @@", + " /* comment", + "-old comment", + "+new comment", + " */", + '-const value = "before";', + '+const value = "";', + ].join("\n"); + const data = fileResult(patch); + data.files[0]!.path = "example.ts"; + panel.loader = async () => data; + document.body.append(panel); + await vi.waitFor(() => + expect(panel.querySelector(".tok-string")?.textContent).toContain("before"), + ); + expect([...panel.querySelectorAll(".tok-comment")].map((node) => node.textContent)).toContain( + "new comment", + ); + expect(panel.querySelector(".tok-keyword")?.textContent).toBe("const"); + expect(panel.textContent).toContain(""); + expect(panel.querySelector("img")).toBeNull(); + + // Reusing the panel for an unknown file type must discard the prior language. + panel.loader = async () => ({ ...data, files: [{ ...data.files[0]!, path: "example.txt" }] }); + await vi.waitFor(() => + expect(panel.querySelector(".session-diff__filename")?.textContent).toBe("example.txt"), + ); + expect(panel.querySelector(".tok-keyword")).toBeNull(); + }, + ); + + it.each([false, true])("highlights both languages of a rename in split=%s", async (split) => { + setNativeGatewayTestState(null); + localStorage.setItem("openclaw.control.sessionDiff.v1", JSON.stringify({ split })); + const panel = document.createElement("openclaw-session-diff") as SessionDiffElement; + localStorage.removeItem("openclaw.control.sessionDiff.v1"); + const before = '
Hello
'; + const after = 'const value = "after";'; + const data = fileResult( + ["--- a/example.html", "+++ b/example.ts", "@@ -1 +1 @@", `-${before}`, `+${after}`].join( + "\n", + ), + ); + data.files[0] = { + ...data.files[0]!, + path: "example.ts", + oldPath: "example.html", + status: "renamed", + }; + panel.loader = async () => data; + document.body.append(panel); + + const oldSide = split ? ".session-diff-split__side--left" : ".chat-diff__row--del"; + const newSide = split ? ".session-diff-split__side--right" : ".chat-diff__row--add"; + const text = split ? ".session-diff-split__text" : ".chat-diff__text"; + await vi.waitFor(() => { + expect(panel.querySelector(`${oldSide} .tok-propertyName`)?.textContent).toBe("data-mode"); + expect(panel.querySelector(`${newSide} .tok-keyword`)?.textContent).toBe("const"); + }); + expect(panel.querySelector(`${oldSide} ${text}`)?.textContent).toBe(before); + expect(panel.querySelector(`${newSide} ${text}`)?.textContent).toBe(after); + }); + it.each([ { surface: "file", failed: false, feedback: "Copied!" }, { surface: "file", failed: true, feedback: "Copy failed" }, diff --git a/ui/src/pages/chat/components/session-diff-panel.ts b/ui/src/pages/chat/components/session-diff-panel.ts index ebf245f6d013..15694c9cac74 100644 --- a/ui/src/pages/chat/components/session-diff-panel.ts +++ b/ui/src/pages/chat/components/session-diff-panel.ts @@ -19,10 +19,6 @@ import { splitSessionDiffFileText, type SessionDiffGapDirection, } from "../../../lib/chat/session-diff-gaps.ts"; -import { - pairSessionDiffLines, - type SessionSplitDiffRow, -} from "../../../lib/chat/session-diff-split.ts"; import { parseSessionDiffPatch, type ParsedFilePatch } from "../../../lib/chat/session-diff.ts"; import type { DiffLine } from "../../../lib/chat/tool-call-diff.ts"; import { openEditor } from "../../../lib/editor-links.ts"; @@ -165,7 +161,6 @@ class SessionDiffPanel extends OpenClawLightDomElement { @state() private split = loadPreferences().split; @state() private wrap = loadPreferences().wrap; - private readonly splitCache = new WeakMap(); private readonly fileTextCache = new WeakMap>(); private readonly unavailableFileText = new WeakSet(); private prefetchedDiffResult: SessionsDiffResult | null = null; @@ -335,16 +330,6 @@ class SessionDiffPanel extends OpenClawLightDomElement { `; } - private splitRows(parsed: ParsedFilePatch): SessionSplitDiffRow[] { - const cached = this.splitCache.get(parsed); - if (cached) { - return cached; - } - const rows = pairSessionDiffLines(parsed.lines); - this.splitCache.set(parsed, rows); - return rows; - } - private canExpandGaps(view: FileView): boolean { return ( this.scope.scope !== "commit" && @@ -420,7 +405,6 @@ class SessionDiffPanel extends OpenClawLightDomElement { return; } parsed.lines = expanded; - this.splitCache.delete(parsed); this.requestUpdate(); } @@ -469,8 +453,8 @@ class SessionDiffPanel extends OpenClawLightDomElement { const renderGap = (line: DiffLine) => this.renderGap(view, line); return html` ${this.split - ? renderSessionSplitDiff(this.splitRows(parsed), renderGap) - : renderDiffBlock(parsed.lines, "succeeded", renderGap)} + ? renderSessionSplitDiff(parsed.lines, renderGap, file) + : renderDiffBlock(parsed.lines, "succeeded", renderGap, file)} ${parsed.truncated ? html`
${t("chat.sessionDiff.truncatedFile")}
` : nothing} diff --git a/ui/src/pages/chat/components/session-diff-render.ts b/ui/src/pages/chat/components/session-diff-render.ts index 391828d97310..4629704a4180 100644 --- a/ui/src/pages/chat/components/session-diff-render.ts +++ b/ui/src/pages/chat/components/session-diff-render.ts @@ -1,9 +1,14 @@ import { html } from "lit"; import { t } from "../../../i18n/index.ts"; -import type { SessionSplitDiffRow } from "../../../lib/chat/session-diff-split.ts"; -import type { DiffLine } from "../../../lib/chat/tool-call-diff.ts"; +import { pairSessionDiffLines } from "../../../lib/chat/session-diff-split.ts"; +import type { DiffFilePaths, DiffLine } from "../../../lib/chat/tool-call-diff.ts"; +import { renderHighlightedDiff } from "./chat-diff-highlight.ts"; -function renderSplitSide(line: DiffLine | undefined, side: "left" | "right") { +function renderSplitSide( + line: DiffLine | undefined, + side: "left" | "right", + renderLine: (line: DiffLine) => unknown, +) { const sign = side === "left" ? "-" : "+"; // Tint only sides that carry a line; a lone add/del keeps its counterpart neutral. return html`
${line?.lineNo ?? ""} ${line ? sign : ""} - ${line?.text || (line ? " " : "")} + ${line ? renderLine(line) : ""}
`; } export function renderSessionSplitDiff( - rows: readonly SessionSplitDiffRow[], + lines: readonly DiffLine[], renderSkip?: (line: DiffLine) => unknown, + file: DiffFilePaths = { path: "" }, ) { - return html`
- ${rows.map((row) => { - if (row.kind === "pair") { - return html`
- ${renderSplitSide(row.left, "left")} ${renderSplitSide(row.right, "right")} + const rows = pairSessionDiffLines(lines); + return renderHighlightedDiff( + lines, + file, + (renderLine) => html`
+ ${rows.map((row) => { + if (row.kind === "pair") { + return html`
+ ${renderSplitSide(row.left, "left", renderLine)} + ${renderSplitSide(row.right, "right", renderLine)} +
`; + } + if (row.line.kind === "skip") { + return html`
+ ${(renderSkip?.(row.line) ?? row.line.text) || "⋯"} +
`; + } + return html`
+ ${row.line.lineNo ?? ""} + + ${renderLine(row.line)}
`; - } - if (row.line.kind === "skip") { - return html`
- ${(renderSkip?.(row.line) ?? row.line.text) || "⋯"} -
`; - } - return html`
- ${row.line.lineNo ?? ""} - - ${row.line.text || " "} -
`; - })} -
`; + })} +
`, + ); } diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index baf45b065b37..c6ab4a175687 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -1811,59 +1811,60 @@ openclaw-session-discussion { } /* Code syntax palette is shared with highlighted message code via base tokens. */ -.file-view .tok-comment { +:is(.file-view, .code-highlight) .tok-comment { color: var(--muted); font-style: italic; } -.file-view .tok-keyword { +:is(.file-view, .code-highlight) .tok-keyword { color: var(--hljs-keyword); } -.file-view +:is(.file-view, .code-highlight) :where(.tok-number, .tok-literal, .tok-atom, .tok-bool, .tok-variableName, .tok-variableName2) { color: var(--hljs-number); } -.file-view :where(.tok-string, .tok-string2) { +:is(.file-view, .code-highlight) :where(.tok-string, .tok-string2) { color: var(--hljs-string); } -.file-view :where(.tok-heading, .tok-definition) { +:is(.file-view, .code-highlight) :where(.tok-heading, .tok-definition) { color: var(--hljs-title); } -.file-view :where(.tok-typeName, .tok-className, .tok-namespace, .tok-macroName) { +:is(.file-view, .code-highlight) + :where(.tok-typeName, .tok-className, .tok-namespace, .tok-macroName) { color: var(--hljs-type); } -.file-view :where(.tok-propertyName, .tok-labelName) { +:is(.file-view, .code-highlight) :where(.tok-propertyName, .tok-labelName) { color: var(--hljs-attribute); } -.file-view :where(.tok-link, .tok-url) { +:is(.file-view, .code-highlight) :where(.tok-link, .tok-url) { color: var(--accent-2); } -.file-view .tok-meta { +:is(.file-view, .code-highlight) .tok-meta { color: var(--hljs-meta); } -.file-view .tok-deleted { +:is(.file-view, .code-highlight) .tok-deleted { color: var(--hljs-deletion); background: var(--danger-subtle); } -.file-view .tok-inserted { +:is(.file-view, .code-highlight) .tok-inserted { color: var(--hljs-addition); background: var(--ok-subtle); } -.file-view .tok-emphasis { +:is(.file-view, .code-highlight) .tok-emphasis { font-style: italic; } -.file-view .tok-strong { +:is(.file-view, .code-highlight) .tok-strong { font-weight: 600; }