feat(web): add line numbers and wrap toggle for code blocks (#2933)

* feat(web): add line numbers and wrap toggle for code blocks

* fix(web): preserve markdown code block copy semantics
This commit is contained in:
LC 2026-05-26 14:57:52 +08:00 committed by GitHub
parent c5a016ccc6
commit 28ec5793a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 402 additions and 33 deletions

View file

@ -13,6 +13,10 @@ import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm" import remarkGfm from "remark-gfm"
import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills" import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills"
import {
MarkdownCodeBlock,
MessageCodeBlock,
} from "@/components/chat/message-code-block"
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -176,6 +180,9 @@ export function DetailSheet({
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]} rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
components={{
pre: MarkdownCodeBlock,
}}
> >
{selectedSkillDetail.content} {selectedSkillDetail.content}
</ReactMarkdown> </ReactMarkdown>
@ -183,11 +190,12 @@ export function DetailSheet({
) : null} ) : null}
{detailView === "raw" ? ( {detailView === "raw" ? (
<div className="border-border/50 overflow-x-auto rounded-xl border bg-zinc-950 p-5 shadow-sm"> <MessageCodeBlock
<pre className="font-mono text-[13px] leading-relaxed break-words whitespace-pre-wrap text-zinc-100/90"> code={selectedSkillDetail.content}
<code>{selectedSkillDetail.content}</code> label={t("pages.agent.skills.detail_tabs.raw")}
</pre> className="my-0"
</div> bodyClassName="text-[13px] leading-relaxed"
/>
) : null} ) : null}
{detailView === "meta" ? ( {detailView === "meta" ? (

View file

@ -1,4 +1,5 @@
import type { ChannelConfig } from "@/api/channels" import type { ChannelConfig } from "@/api/channels"
import { MessageCodeBlock } from "@/components/chat/message-code-block"
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput } from "@/components/shared-form" import { Field, KeyInput } from "@/components/shared-form"
import { import {
@ -180,9 +181,12 @@ export function MqttForm({
{t("channels.mqtt.uplink")} {t("channels.mqtt.uplink")}
</p> </p>
<CodeLine>{`${topicBase}/request`}</CodeLine> <CodeLine>{`${topicBase}/request`}</CodeLine>
<pre className="bg-muted text-foreground rounded px-3 py-2 font-mono text-xs leading-relaxed"> <MessageCodeBlock
{`{\n "text": "your message"\n}`} code={`{\n "text": "your message"\n}`}
</pre> language="json"
className="my-0"
bodyClassName="px-3 py-2 text-xs leading-relaxed"
/>
<div className="text-muted-foreground space-y-1 text-xs"> <div className="text-muted-foreground space-y-1 text-xs">
<p> <p>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">
@ -199,9 +203,12 @@ export function MqttForm({
{t("channels.mqtt.downlink")} {t("channels.mqtt.downlink")}
</p> </p>
<CodeLine>{`${topicBase}/response`}</CodeLine> <CodeLine>{`${topicBase}/response`}</CodeLine>
<pre className="bg-muted text-foreground rounded px-3 py-2 font-mono text-xs leading-relaxed"> <MessageCodeBlock
{`{\n "text": "agent response"\n}`} code={`{\n "text": "agent response"\n}`}
</pre> language="json"
className="my-0"
bodyClassName="px-3 py-2 text-xs leading-relaxed"
/>
<div className="text-muted-foreground space-y-1 text-xs"> <div className="text-muted-foreground space-y-1 text-xs">
<p> <p>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">

View file

@ -197,7 +197,6 @@ export function AssistantMessage({
label={toolName || t("chat.toolCallArgumentsLabel")} label={toolName || t("chat.toolCallArgumentsLabel")}
className="my-0 shadow-none" className="my-0 shadow-none"
bodyClassName="px-3 py-2 text-[12px] leading-relaxed" bodyClassName="px-3 py-2 text-[12px] leading-relaxed"
wrapLongLines
/> />
)} )}
</div> </div>

View file

@ -3,17 +3,30 @@ import {
IconChevronDown, IconChevronDown,
IconCopy, IconCopy,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { useAtom } from "jotai"
import hljs from "highlight.js/lib/core" import hljs from "highlight.js/lib/core"
import json from "highlight.js/lib/languages/json" import json from "highlight.js/lib/languages/json"
import { type ComponentProps, type ReactNode, useState } from "react" import {
type ComponentProps,
type CSSProperties,
type ReactNode,
useState,
} from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { codeBlockWrapAtom } from "@/store/code-block"
import { import {
extractCodeBlockFromPreNode, extractCodeBlockFromPreNode,
extractCodeBlockRenderState,
type MarkdownNode, type MarkdownNode,
splitCodeIntoLines,
splitHighlightedHtmlIntoLines,
splitRenderedCodeContentIntoLines,
trimTrailingEmptyRenderedCodeLine,
trimTrailingEmptyStringLine,
} from "./message-code-block.utils" } from "./message-code-block.utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -27,10 +40,10 @@ interface MessageCodeBlockProps {
code: string code: string
language?: string | null language?: string | null
label?: string label?: string
children?: ReactNode
className?: string className?: string
bodyClassName?: string bodyClassName?: string
wrapLongLines?: boolean children?: ReactNode
trimTrailingEmptyLine?: boolean
} }
interface MarkdownCodeBlockProps extends ComponentProps<"pre"> { interface MarkdownCodeBlockProps extends ComponentProps<"pre"> {
@ -53,13 +66,14 @@ export function MessageCodeBlock({
code, code,
language = null, language = null,
label, label,
children,
className, className,
bodyClassName, bodyClassName,
wrapLongLines = false, children,
trimTrailingEmptyLine = false,
}: MessageCodeBlockProps) { }: MessageCodeBlockProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { copy, isCopied } = useCopyToClipboard() const { copy, isCopied } = useCopyToClipboard()
const [wrapLongLines, setWrapLongLines] = useAtom(codeBlockWrapAtom)
const [isExpanded, setIsExpanded] = useState(true) const [isExpanded, setIsExpanded] = useState(true)
const blockLabel = const blockLabel =
label ?? label ??
@ -68,7 +82,31 @@ export function MessageCodeBlock({
: t("chat.codeLabel").toLocaleLowerCase()) : t("chat.codeLabel").toLocaleLowerCase())
const copyLabel = isCopied ? t("chat.copiedLabel") : t("chat.copyCode") const copyLabel = isCopied ? t("chat.copiedLabel") : t("chat.copyCode")
const expandLabel = isExpanded ? t("chat.collapseCode") : t("chat.expandCode") const expandLabel = isExpanded ? t("chat.collapseCode") : t("chat.expandCode")
const wrapLabel = wrapLongLines
? t("chat.disableCodeWrap")
: t("chat.enableCodeWrap")
const renderedCodeState = children
? extractCodeBlockRenderState(children)
: {
renderedContent: null,
className: undefined,
}
const highlightedHtml = !children ? getHighlightedHtml(code, language) : null const highlightedHtml = !children ? getHighlightedHtml(code, language) : null
const highlightedLines = highlightedHtml
? splitHighlightedHtmlIntoLines(highlightedHtml)
: null
const codeLines = children
? (trimTrailingEmptyLine
? trimTrailingEmptyRenderedCodeLine(
splitRenderedCodeContentIntoLines(renderedCodeState.renderedContent),
)
: splitRenderedCodeContentIntoLines(renderedCodeState.renderedContent))
: (trimTrailingEmptyLine
? trimTrailingEmptyStringLine(
highlightedLines ?? splitCodeIntoLines(code),
)
: (highlightedLines ?? splitCodeIntoLines(code)))
const lineNumberWidth = `${String(codeLines.length).length + 1}ch`
return ( return (
<div <div
@ -102,6 +140,18 @@ export function MessageCodeBlock({
)} )}
<span className="hidden sm:inline">{copyLabel}</span> <span className="hidden sm:inline">{copyLabel}</span>
</Button> </Button>
<Button
type="button"
variant="ghost"
size="xs"
className="h-7 px-2 text-[11px] text-zinc-600 hover:bg-zinc-300/70 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
onClick={() => setWrapLongLines((current) => !current)}
aria-pressed={wrapLongLines}
aria-label={wrapLabel}
title={wrapLabel}
>
{wrapLabel}
</Button>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@ -123,23 +173,56 @@ export function MessageCodeBlock({
{isExpanded && ( {isExpanded && (
<pre <pre
className={cn( className={cn(
"m-0 overflow-x-auto bg-transparent px-4 py-3 font-mono text-[13px] leading-6 [&_code]:block [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-inherit", "m-0 overflow-x-auto bg-transparent px-4 py-3 font-mono text-[13px] leading-6",
wrapLongLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
bodyClassName, bodyClassName,
)} )}
> >
{children ?? ( <code
highlightedHtml ? ( className={cn(
<code "block bg-transparent p-0 text-inherit",
className={cn("hljs", language && `language-${language}`)} children
dangerouslySetInnerHTML={{ __html: highlightedHtml }} ? renderedCodeState.className
/> : cn(highlightedHtml && "hljs", language && `language-${language}`),
) : ( )}
<code className={language ? `language-${language}` : undefined}> >
{code} {codeLines.map((line, index) => (
</code> <span
) key={`${index}-${line.length}`}
)} className="grid grid-cols-[var(--code-line-number-width)_minmax(0,1fr)] items-start gap-x-3"
style={
{
"--code-line-number-width": lineNumberWidth,
} as CSSProperties
}
>
<span className="sticky left-0 z-1 select-none bg-[#f6f8fa] text-right text-zinc-500/80 dark:bg-[#0d1117] dark:text-zinc-500">
{index + 1}
</span>
{!children && highlightedLines ? (
<span
className={cn(
"min-w-0",
wrapLongLines
? "break-words whitespace-pre-wrap"
: "whitespace-pre",
)}
dangerouslySetInnerHTML={{ __html: line }}
/>
) : (
<span
className={cn(
"min-w-0",
wrapLongLines
? "break-words whitespace-pre-wrap"
: "whitespace-pre",
)}
>
{line}
</span>
)}
</span>
))}
</code>
</pre> </pre>
)} )}
</div> </div>
@ -158,6 +241,7 @@ export function MarkdownCodeBlock({
code={code} code={code}
language={language} language={language}
bodyClassName={className} bodyClassName={className}
trimTrailingEmptyLine
> >
{children} {children}
</MessageCodeBlock> </MessageCodeBlock>

View file

@ -1,3 +1,11 @@
import {
Children,
cloneElement,
Fragment,
isValidElement,
type ReactNode,
} from "react"
export interface MarkdownNode { export interface MarkdownNode {
type?: string type?: string
value?: string value?: string
@ -6,7 +14,7 @@ export interface MarkdownNode {
children?: MarkdownNode[] children?: MarkdownNode[]
} }
function toClassNameTokens(className: unknown): string[] { export function toClassNameTokens(className: unknown): string[] {
if (typeof className === "string") { if (typeof className === "string") {
return className.split(/\s+/).filter(Boolean) return className.split(/\s+/).filter(Boolean)
} }
@ -72,6 +80,10 @@ export function extractCodeBlockLanguage(className: unknown): string | null {
return languageToken ? languageToken.slice("language-".length) : null return languageToken ? languageToken.slice("language-".length) : null
} }
export function stripSingleTrailingLineBreak(value: string): string {
return value.replace(/\r?\n$/, "")
}
export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): { export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): {
code: string code: string
language: string | null language: string | null
@ -79,7 +91,248 @@ export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): {
const codeNode = findFirstDescendantByTagName(node, "code") const codeNode = findFirstDescendantByTagName(node, "code")
return { return {
code: extractTextFromMarkdownNode(codeNode ?? node), code: stripSingleTrailingLineBreak(extractTextFromMarkdownNode(codeNode ?? node)),
language: extractCodeBlockLanguage(codeNode?.properties?.className), language: extractCodeBlockLanguage(codeNode?.properties?.className),
} }
} }
export function extractCodeBlockRenderState(children: ReactNode): {
renderedContent: ReactNode
className: string | undefined
} {
const childNodes = Children.toArray(children)
const codeChild = childNodes.find(
(child) =>
isValidElement<{ children?: ReactNode; className?: unknown }>(child) &&
typeof child.type === "string" &&
child.type === "code",
)
if (
isValidElement<{ children?: ReactNode; className?: unknown }>(codeChild)
) {
const classNameTokens = toClassNameTokens(codeChild.props.className)
return {
renderedContent: codeChild.props.children,
className:
classNameTokens.length > 0 ? classNameTokens.join(" ") : undefined,
}
}
return {
renderedContent: children,
className: undefined,
}
}
function mergeNodeLineGroups(
currentLines: Node[][],
nextLines: Node[][],
): Node[][] {
if (nextLines.length === 0) {
return currentLines
}
const mergedLines = currentLines.map((line) => [...line])
mergedLines[mergedLines.length - 1].push(...nextLines[0])
for (const line of nextLines.slice(1)) {
mergedLines.push([...line])
}
return mergedLines
}
function splitDomNodeIntoLines(node: Node, ownerDocument: Document): Node[][] {
if (node.nodeType === Node.TEXT_NODE) {
return (node.textContent ?? "").split("\n").map((line) =>
line.length > 0 ? [ownerDocument.createTextNode(line)] : [],
)
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return [[]]
}
const element = node as Element
if (element.tagName.toLowerCase() === "br") {
return [
[],
[],
]
}
const childLines = splitHighlightedHtmlIntoNodeLines(
Array.from(element.childNodes),
ownerDocument,
)
return childLines.map((lineChildren) => {
const clonedElement = element.cloneNode(false)
for (const child of lineChildren) {
clonedElement.appendChild(child)
}
return [clonedElement]
})
}
function splitHighlightedHtmlIntoNodeLines(
nodes: Node[],
ownerDocument: Document,
): Node[][] {
let lines: Node[][] = [[]]
for (const node of nodes) {
lines = mergeNodeLineGroups(
lines,
splitDomNodeIntoLines(node, ownerDocument),
)
}
return lines
}
export function splitCodeIntoLines(code: string): string[] {
return code.split("\n")
}
export function splitHighlightedHtmlIntoLines(highlightedHtml: string): string[] {
if (typeof document === "undefined") {
return splitCodeIntoLines(highlightedHtml)
}
const container = document.createElement("div")
container.innerHTML = highlightedHtml
return splitHighlightedHtmlIntoNodeLines(
Array.from(container.childNodes),
document,
).map((lineNodes) => {
const lineContainer = document.createElement("div")
for (const node of lineNodes) {
lineContainer.appendChild(node)
}
return lineContainer.innerHTML
})
}
export function trimTrailingEmptyStringLine(lines: string[]): string[] {
if (lines.length > 1 && lines[lines.length - 1] === "") {
return lines.slice(0, -1)
}
return lines
}
function isEmptyRenderedCodeNode(node: ReactNode): boolean {
if (node === null || node === undefined || typeof node === "boolean") {
return true
}
if (typeof node === "string" || typeof node === "number") {
return String(node).length === 0
}
if (Array.isArray(node)) {
return node.every(isEmptyRenderedCodeNode)
}
if (!isValidElement<{ children?: ReactNode }>(node)) {
return false
}
return Children.toArray(node.props.children).every(isEmptyRenderedCodeNode)
}
export function trimTrailingEmptyRenderedCodeLine(
lines: ReactNode[][],
): ReactNode[][] {
if (
lines.length > 1 &&
lines[lines.length - 1].every(isEmptyRenderedCodeNode)
) {
return lines.slice(0, -1)
}
return lines
}
function mergeReactLineGroups(
currentLines: ReactNode[][],
nextLines: ReactNode[][],
): ReactNode[][] {
if (nextLines.length === 0) {
return currentLines
}
const mergedLines = currentLines.map((line) => [...line])
mergedLines[mergedLines.length - 1].push(...nextLines[0])
for (const line of nextLines.slice(1)) {
mergedLines.push([...line])
}
return mergedLines
}
function splitTextNodeIntoLines(value: string | number): ReactNode[][] {
return String(value).split("\n").map((line) => (line.length > 0 ? [line] : []))
}
function splitReactNodeIntoLines(node: ReactNode): ReactNode[][] {
if (node === null || node === undefined || typeof node === "boolean") {
return [[]]
}
if (typeof node === "string" || typeof node === "number") {
return splitTextNodeIntoLines(node)
}
if (Array.isArray(node)) {
return splitRenderedCodeContentIntoLines(node)
}
if (!isValidElement<{ children?: ReactNode }>(node)) {
return [[node]]
}
if (node.type === Fragment) {
return splitRenderedCodeContentIntoLines(Children.toArray(node.props.children))
}
if (typeof node.type === "string" && node.type === "br") {
return [
[],
[],
]
}
const childLines = splitRenderedCodeContentIntoLines(
Children.toArray(node.props.children),
)
return childLines.map((lineChildren, lineIndex) => [
cloneElement(
node,
{
key: `${node.key ?? "code-line"}-${lineIndex}`,
},
...lineChildren,
),
])
}
export function splitRenderedCodeContentIntoLines(
content: ReactNode,
): ReactNode[][] {
const contentNodes = Array.isArray(content) ? content : [content]
let lines: ReactNode[][] = [[]]
for (const node of contentNodes) {
lines = mergeReactLineGroups(lines, splitReactNodeIntoLines(node))
}
return lines
}

View file

@ -76,6 +76,8 @@
"copyMessage": "Copy message", "copyMessage": "Copy message",
"copyCode": "Copy code", "copyCode": "Copy code",
"copiedLabel": "Copied", "copiedLabel": "Copied",
"enableCodeWrap": "Wrap lines",
"disableCodeWrap": "Disable wrap",
"expandCode": "Expand code", "expandCode": "Expand code",
"collapseCode": "Collapse code", "collapseCode": "Collapse code",
"history": "History", "history": "History",

View file

@ -76,6 +76,8 @@
"copyMessage": "Copiar mensagem", "copyMessage": "Copiar mensagem",
"copyCode": "Copiar código", "copyCode": "Copiar código",
"copiedLabel": "Copiado", "copiedLabel": "Copiado",
"enableCodeWrap": "Quebrar linhas",
"disableCodeWrap": "Desativar quebra",
"expandCode": "Expandir código", "expandCode": "Expandir código",
"collapseCode": "Recolher código", "collapseCode": "Recolher código",
"history": "Histórico", "history": "Histórico",

View file

@ -76,6 +76,8 @@
"copyMessage": "复制消息", "copyMessage": "复制消息",
"copyCode": "复制代码", "copyCode": "复制代码",
"copiedLabel": "已复制", "copiedLabel": "已复制",
"enableCodeWrap": "开启换行",
"disableCodeWrap": "关闭换行",
"expandCode": "展开代码", "expandCode": "展开代码",
"collapseCode": "折叠代码", "collapseCode": "折叠代码",
"history": "历史记录", "history": "历史记录",

View file

@ -0,0 +1,11 @@
import { atomWithStorage } from "jotai/utils"
export const CODE_BLOCK_WRAP_STORAGE_KEY = "picoclaw:code-block-wrap"
export const DEFAULT_CODE_BLOCK_WRAP = false
export const codeBlockWrapAtom = atomWithStorage<boolean>(
CODE_BLOCK_WRAP_STORAGE_KEY,
DEFAULT_CODE_BLOCK_WRAP,
undefined,
{ getOnInit: true },
)

View file

@ -1,3 +1,4 @@
export * from "./gateway" export * from "./gateway"
export * from "./chat" export * from "./chat"
export * from "./code-block"
export * from "./tour" export * from "./tour"