Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,6 @@ html.dark {
@supports (animation-timeline: scroll()) {
.session-tree-scroll,
.sidebar-files-scroll,
.markdown-code-block > pre,
.markdown-table-wrap {
animation-name: scroll-fade-start, scroll-fade-end;
animation-duration: auto;
Expand All @@ -417,8 +416,7 @@ html.dark {
mask-size: calc(100% - var(--scroll-fade-gutter)) 100%, var(--scroll-fade-gutter) 100%;
mask-position: left top, right top;
}
/* Code and tables that scroll sideways. */
.markdown-code-block > pre,
/* Tables that scroll sideways (code fences wrap instead). */
.markdown-table-wrap {
--scroll-fade-size: 28px;
animation-timeline: scroll(self inline);
Expand Down Expand Up @@ -1418,6 +1416,25 @@ pre, code {
background: var(--bg);
box-shadow: 0 1px 0 color-mix(in srgb, var(--border) 42%, transparent);
}
/* Code fences wrap long lines, as pi's TUI does: a line is a row of its number
and its text (CodeBlock's renderer), so a wrapped line goes on under its
text, and `anywhere` breaks a URL or path that has no space to break at. */
.markdown-code-line {
display: flex;
}
/* A zero basis: an `auto` one measures every line's unwrapped width first,
which nearly doubled the layout of a reply's fences. */
.markdown-code-line-text {
flex: 1 1 0;
min-width: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* An extension dialog measures its fences unwrapped (ChatWindow), so it still
grows to show them whole where the screen allows. */
.extension-dialog-measuring .markdown-code-line-text {
white-space: pre;
}
.markdown-code-header {
padding: 5px 10px;
background: var(--bg-panel);
Expand Down
11 changes: 7 additions & 4 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1739,10 +1739,13 @@ function ExtensionDialog({
if (disposed) return;
const blocks = body.querySelectorAll<HTMLElement>("pre, .markdown-table-wrap");
if (blocks.length === 0) return;
const needed = fitExtensionDialogWidth(
dialog.offsetWidth,
Array.from(blocks, (block) => block.scrollWidth - block.clientWidth),
);
// Code fences wrap their long lines; measured unwrapped, they still widen
// the dialog as far as the screen allows and wrap only past that. The
// class is an attribute change, which the observer below does not watch.
body.classList.add("extension-dialog-measuring");
const overflows = Array.from(blocks, (block) => block.scrollWidth - block.clientWidth);
body.classList.remove("extension-dialog-measuring");
const needed = fitExtensionDialogWidth(dialog.offsetWidth, overflows);
// Only ever grow: shrinking again would make the dialog jump while it is read.
if (needed !== null) setFitWidth((prev) => (prev !== null && prev >= needed ? prev : needed));
};
Expand Down
46 changes: 46 additions & 0 deletions components/MermaidBlock.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,31 @@ function renderCode(props) {
);
}

/** Each line's number, read off the row that pairs it with its text. */
function lineRows(html) {
return [...html.matchAll(/<span class="markdown-code-line"><span class="linenumber react-syntax-highlighter-line-number"[^>]*>(\d+)<\/span><span class="(?:token )?markdown-code-line-text"/g)]
.map((match) => match[1]);
}

/** The code as the page shows it: the lines' text without their numbers. */
function shownCode(html) {
return html.match(/<code\b[^>]*>([\s\S]*)<\/code>/)[1]
.replace(/<span class="linenumber[^>]*>\d+<\/span>/g, "")
.replace(/<[^>]+>/g, "")
.replaceAll("&quot;", "\"").replaceAll("&#x27;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
}

const wrapSample = "const x = 1;\n indented(\"https://example.invalid/a/b/c\");\n\nlast();";

test("CodeBlock renders the same numbered lines without tokens until the Prism languages load", () => {
const html = renderCode({ code: "const x = 1;\nlet y = 2;", lang: "javascript" });

assert.doesNotMatch(html, /class="token/);
assert.equal(html.match(/react-syntax-highlighter-line-number/g)?.length, 2);
assert.match(html, /const x = 1;/);
const fallback = renderCode({ code: wrapSample, lang: "javascript" });
assert.deepEqual(lineRows(fallback), ["1", "2", "3", "4"]);
assert.equal(shownCode(fallback), wrapSample);
});

test("CodeBlock highlights code when not streaming", async () => {
Expand All @@ -85,6 +104,28 @@ test("CodeBlock highlights code when not streaming", async () => {
assert.match(html, /const/);
});

test("CodeBlock pairs each number with its line's text, so a long line wraps under its text (#1360)", () => {
const html = renderCode({ code: wrapSample, lang: "javascript" });

assert.match(html, /class="token/);
// A row per line: the number, then the text that wraps (app/globals.css),
// indentation and all, and nothing between the lines but their own text.
assert.deepEqual(lineRows(html), ["1", "2", "3", "4"]);
assert.equal(shownCode(html), wrapSample);
assert.match(html, /flex-shrink:0/);
});

test("CodeBlock keeps a block comment's color on every line it spans", () => {
const html = renderCode({ code: "/* one\n two\n three */\nlet x;", lang: "javascript" });

// The comment's middle line is a row of its own, styled by the row's classes.
const first = html.match(/<span class="token" style="([^"]*)">\/\* one\n<\/span>/)?.[1];
const middle = html.match(/<span class="token markdown-code-line-text" style="([^"]*)"> two\n<\/span>/)?.[1];
assert.ok(first);
assert.equal(middle, first);
assert.deepEqual(lineRows(html), ["1", "2", "3", "4"]);
});

test("CodeBlock owns the pre background without a competing shorthand", () => {
for (const isStreaming of [false, true]) {
const html = renderCode({ code: "const x = 1;", lang: "javascript", isStreaming });
Expand All @@ -100,6 +141,11 @@ test("CodeBlock renders plain text without tokenization while streaming", () =>

assert.doesNotMatch(html, /class="token/);
assert.match(html, /const x = 1;/);
// A streamed block wraps its long lines too, instead of scrolling sideways.
const pre = html.match(/<pre\b[^>]*>/)?.[0];
assert.match(pre, /white-space:pre-wrap/);
assert.match(pre, /overflow-wrap:anywhere/);
assert.doesNotMatch(pre, /overflow-x/);
});

test("MermaidBlock handles Chinese characters in diagram", () => {
Expand Down
35 changes: 33 additions & 2 deletions components/MermaidBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { SyntaxHighlighterProps } from "react-syntax-highlighter";
import renderSyntaxNode from "react-syntax-highlighter/dist/esm/create-element";
import SyntaxHighlighter from "react-syntax-highlighter/dist/esm/prism-light";
import { vs } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism";
Expand Down Expand Up @@ -270,6 +272,33 @@ const codeBlockDarkTheme = {
};
delete codeBlockDarkTheme['pre[class*="language-"]'].background;

type CodeLinesProps = Parameters<NonNullable<SyntaxHighlighterProps["renderer"]>>[0];

/**
* A fence's lines, each a row of its number and its text (`.markdown-code-line`
* in app/globals.css): long lines wrap, as in pi's TUI, and a wrapped line goes
* on under its own text, never under the number. The text keeps the row's own
* classes, which color the later lines of a token that spans several (a block
* comment).
*/
function renderCodeLines({ rows, stylesheet, useInlineStyles }: CodeLinesProps) {
return rows.map((row, index) => {
const children = row.children ?? [];
const number = children[0]?.properties?.className.includes("react-syntax-highlighter-line-number") ? children[0] : null;
const text = {
...row,
properties: { ...row.properties, className: [...(row.properties?.className ?? []), "markdown-code-line-text"] },
children: number ? children.slice(1) : children,
};
return (
<span key={index} className="markdown-code-line">
{number && renderSyntaxNode({ node: number, stylesheet, useInlineStyles, key: "number" })}
{renderSyntaxNode({ node: text, stylesheet, useInlineStyles, key: "text" })}
</span>
);
});
}

/**
* Syntax-highlighted code block with copy button.
* Used as the "source" view for mermaid blocks and for all non-mermaid code fences.
Expand Down Expand Up @@ -328,7 +357,8 @@ export const CodeBlock = memo(function CodeBlock({ code, lang, headerAction, isS
padding: "11px 13px",
fontSize: "calc(12.5px + var(--chat-font-size-offset, 0px))",
lineHeight: 1.62,
overflowX: "auto",
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
backgroundColor: "color-mix(in srgb, var(--bg) 92%, var(--bg-panel))",
}}
>
Expand All @@ -339,7 +369,8 @@ export const CodeBlock = memo(function CodeBlock({ code, lang, headerAction, isS
language={highlighted ? lang || "text" : "text"}
style={isDark ? codeBlockDarkTheme : vs}
showLineNumbers
lineNumberStyle={{ color: "var(--text-dim)", fontStyle: "normal" }}
lineNumberStyle={{ color: "var(--text-dim)", fontStyle: "normal", flexShrink: 0 }}
renderer={renderCodeLines}
customStyle={{
margin: 0,
padding: "11px 13px",
Expand Down
19 changes: 19 additions & 0 deletions demo/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,25 @@ pre, code {
background: var(--bg);
box-shadow: 0 1px 0 color-mix(in srgb, var(--border) 42%, transparent);
}
/* Code fences wrap long lines, as pi's TUI does: a line is a row of its number
and its text (CodeBlock's renderer), so a wrapped line goes on under its
text, and `anywhere` breaks a URL or path that has no space to break at. */
.markdown-code-line {
display: flex;
}
/* A zero basis: an `auto` one measures every line's unwrapped width first,
which nearly doubled the layout of a reply's fences. */
.markdown-code-line-text {
flex: 1 1 0;
min-width: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* An extension dialog measures its fences unwrapped (ChatWindow), so it still
grows to show them whole where the screen allows. */
.extension-dialog-measuring .markdown-code-line-text {
white-space: pre;
}
.markdown-code-header {
padding: 5px 10px;
background: var(--bg-panel);
Expand Down
11 changes: 7 additions & 4 deletions demo/components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1566,10 +1566,13 @@ function ExtensionDialog({
if (disposed) return;
const blocks = body.querySelectorAll<HTMLElement>("pre, .markdown-table-wrap");
if (blocks.length === 0) return;
const needed = fitExtensionDialogWidth(
dialog.offsetWidth,
Array.from(blocks, (block) => block.scrollWidth - block.clientWidth),
);
// Code fences wrap their long lines; measured unwrapped, they still widen
// the dialog as far as the screen allows and wrap only past that. The
// class is an attribute change, which the observer below does not watch.
body.classList.add("extension-dialog-measuring");
const overflows = Array.from(blocks, (block) => block.scrollWidth - block.clientWidth);
body.classList.remove("extension-dialog-measuring");
const needed = fitExtensionDialogWidth(dialog.offsetWidth, overflows);
// Only ever grow: shrinking again would make the dialog jump while it is read.
if (needed !== null) setFitWidth((prev) => (prev !== null && prev >= needed ? prev : needed));
};
Expand Down
39 changes: 36 additions & 3 deletions demo/components/MermaidBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
"use client";

import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
Prism as SyntaxHighlighter,
createElement as renderSyntaxNode,
type SyntaxHighlighterProps,
} from "react-syntax-highlighter";
import { vs } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { useTheme } from "@/hooks/useTheme";
Expand Down Expand Up @@ -267,6 +271,33 @@ const codeBlockDarkTheme = {
};
delete codeBlockDarkTheme['pre[class*="language-"]'].background;

type CodeLinesProps = Parameters<NonNullable<SyntaxHighlighterProps["renderer"]>>[0];

/**
* A fence's lines, each a row of its number and its text (`.markdown-code-line`
* in app/globals.css): long lines wrap, as in pi's TUI, and a wrapped line goes
* on under its own text, never under the number. The text keeps the row's own
* classes, which color the later lines of a token that spans several (a block
* comment).
*/
function renderCodeLines({ rows, stylesheet, useInlineStyles }: CodeLinesProps) {
return rows.map((row, index) => {
const children = row.children ?? [];
const number = children[0]?.properties?.className.includes("react-syntax-highlighter-line-number") ? children[0] : null;
const text = {
...row,
properties: { ...row.properties, className: [...(row.properties?.className ?? []), "markdown-code-line-text"] },
children: number ? children.slice(1) : children,
};
return (
<span key={index} className="markdown-code-line">
{number && renderSyntaxNode({ node: number, stylesheet, useInlineStyles, key: "number" })}
{renderSyntaxNode({ node: text, stylesheet, useInlineStyles, key: "text" })}
</span>
);
});
}

/**
* Syntax-highlighted code block with copy button.
* Used as the "source" view for mermaid blocks and for all non-mermaid code fences.
Expand Down Expand Up @@ -310,7 +341,8 @@ export const CodeBlock = memo(function CodeBlock({ code, lang, headerAction, isS
padding: "11px 13px",
fontSize: "calc(12.5px + var(--chat-font-size-offset, 0px))",
lineHeight: 1.62,
overflowX: "auto",
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
backgroundColor: "color-mix(in srgb, var(--bg) 92%, var(--bg-panel))",
}}
>
Expand All @@ -321,7 +353,8 @@ export const CodeBlock = memo(function CodeBlock({ code, lang, headerAction, isS
language={lang || "text"}
style={isDark ? codeBlockDarkTheme : vs}
showLineNumbers
lineNumberStyle={{ color: "var(--text-dim)", fontStyle: "normal" }}
lineNumberStyle={{ color: "var(--text-dim)", fontStyle: "normal", flexShrink: 0 }}
renderer={renderCodeLines}
customStyle={{
margin: 0,
padding: "11px 13px",
Expand Down
Loading
Loading