diff --git a/web/frontend/src/components/agent/skills/detail-sheet.tsx b/web/frontend/src/components/agent/skills/detail-sheet.tsx
index 4579926d8..9ec0c5a0c 100644
--- a/web/frontend/src/components/agent/skills/detail-sheet.tsx
+++ b/web/frontend/src/components/agent/skills/detail-sheet.tsx
@@ -13,6 +13,10 @@ import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills"
+import {
+ MarkdownCodeBlock,
+ MessageCodeBlock,
+} from "@/components/chat/message-code-block"
import {
Sheet,
SheetContent,
@@ -176,6 +180,9 @@ export function DetailSheet({
{selectedSkillDetail.content}
@@ -183,11 +190,12 @@ export function DetailSheet({
) : null}
{detailView === "raw" ? (
-
@@ -199,9 +203,12 @@ export function MqttForm({
{t("channels.mqtt.downlink")}
{`${topicBase}/response`}
-
- {`{\n "text": "agent response"\n}`}
-
+
diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx
index 6527562fe..0a197b25e 100644
--- a/web/frontend/src/components/chat/assistant-message.tsx
+++ b/web/frontend/src/components/chat/assistant-message.tsx
@@ -197,7 +197,6 @@ export function AssistantMessage({
label={toolName || t("chat.toolCallArgumentsLabel")}
className="my-0 shadow-none"
bodyClassName="px-3 py-2 text-[12px] leading-relaxed"
- wrapLongLines
/>
)}
diff --git a/web/frontend/src/components/chat/message-code-block.tsx b/web/frontend/src/components/chat/message-code-block.tsx
index 0da081194..79a3706a2 100644
--- a/web/frontend/src/components/chat/message-code-block.tsx
+++ b/web/frontend/src/components/chat/message-code-block.tsx
@@ -3,17 +3,30 @@ import {
IconChevronDown,
IconCopy,
} from "@tabler/icons-react"
+import { useAtom } from "jotai"
import hljs from "highlight.js/lib/core"
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 { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { cn } from "@/lib/utils"
+import { codeBlockWrapAtom } from "@/store/code-block"
import {
extractCodeBlockFromPreNode,
+ extractCodeBlockRenderState,
type MarkdownNode,
+ splitCodeIntoLines,
+ splitHighlightedHtmlIntoLines,
+ splitRenderedCodeContentIntoLines,
+ trimTrailingEmptyRenderedCodeLine,
+ trimTrailingEmptyStringLine,
} from "./message-code-block.utils"
import { Button } from "@/components/ui/button"
@@ -27,10 +40,10 @@ interface MessageCodeBlockProps {
code: string
language?: string | null
label?: string
- children?: ReactNode
className?: string
bodyClassName?: string
- wrapLongLines?: boolean
+ children?: ReactNode
+ trimTrailingEmptyLine?: boolean
}
interface MarkdownCodeBlockProps extends ComponentProps<"pre"> {
@@ -53,13 +66,14 @@ export function MessageCodeBlock({
code,
language = null,
label,
- children,
className,
bodyClassName,
- wrapLongLines = false,
+ children,
+ trimTrailingEmptyLine = false,
}: MessageCodeBlockProps) {
const { t } = useTranslation()
const { copy, isCopied } = useCopyToClipboard()
+ const [wrapLongLines, setWrapLongLines] = useAtom(codeBlockWrapAtom)
const [isExpanded, setIsExpanded] = useState(true)
const blockLabel =
label ??
@@ -68,7 +82,31 @@ export function MessageCodeBlock({
: t("chat.codeLabel").toLocaleLowerCase())
const copyLabel = isCopied ? t("chat.copiedLabel") : t("chat.copyCode")
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 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 (
{copyLabel}
+
@@ -158,6 +241,7 @@ export function MarkdownCodeBlock({
code={code}
language={language}
bodyClassName={className}
+ trimTrailingEmptyLine
>
{children}
diff --git a/web/frontend/src/components/chat/message-code-block.utils.ts b/web/frontend/src/components/chat/message-code-block.utils.ts
index 2133ec638..40e76a2a8 100644
--- a/web/frontend/src/components/chat/message-code-block.utils.ts
+++ b/web/frontend/src/components/chat/message-code-block.utils.ts
@@ -1,3 +1,11 @@
+import {
+ Children,
+ cloneElement,
+ Fragment,
+ isValidElement,
+ type ReactNode,
+} from "react"
+
export interface MarkdownNode {
type?: string
value?: string
@@ -6,7 +14,7 @@ export interface MarkdownNode {
children?: MarkdownNode[]
}
-function toClassNameTokens(className: unknown): string[] {
+export function toClassNameTokens(className: unknown): string[] {
if (typeof className === "string") {
return className.split(/\s+/).filter(Boolean)
}
@@ -72,6 +80,10 @@ export function extractCodeBlockLanguage(className: unknown): string | 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): {
code: string
language: string | null
@@ -79,7 +91,248 @@ export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): {
const codeNode = findFirstDescendantByTagName(node, "code")
return {
- code: extractTextFromMarkdownNode(codeNode ?? node),
+ code: stripSingleTrailingLineBreak(extractTextFromMarkdownNode(codeNode ?? node)),
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
+}
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index 1f15cee11..cb97f0a5e 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -76,6 +76,8 @@
"copyMessage": "Copy message",
"copyCode": "Copy code",
"copiedLabel": "Copied",
+ "enableCodeWrap": "Wrap lines",
+ "disableCodeWrap": "Disable wrap",
"expandCode": "Expand code",
"collapseCode": "Collapse code",
"history": "History",
diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json
index 8e27078fc..ca1f9ed32 100644
--- a/web/frontend/src/i18n/locales/pt-br.json
+++ b/web/frontend/src/i18n/locales/pt-br.json
@@ -76,6 +76,8 @@
"copyMessage": "Copiar mensagem",
"copyCode": "Copiar código",
"copiedLabel": "Copiado",
+ "enableCodeWrap": "Quebrar linhas",
+ "disableCodeWrap": "Desativar quebra",
"expandCode": "Expandir código",
"collapseCode": "Recolher código",
"history": "Histórico",
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index bd027fee4..5590adab2 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -76,6 +76,8 @@
"copyMessage": "复制消息",
"copyCode": "复制代码",
"copiedLabel": "已复制",
+ "enableCodeWrap": "开启换行",
+ "disableCodeWrap": "关闭换行",
"expandCode": "展开代码",
"collapseCode": "折叠代码",
"history": "历史记录",
diff --git a/web/frontend/src/store/code-block.ts b/web/frontend/src/store/code-block.ts
new file mode 100644
index 000000000..612e45fca
--- /dev/null
+++ b/web/frontend/src/store/code-block.ts
@@ -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
(
+ CODE_BLOCK_WRAP_STORAGE_KEY,
+ DEFAULT_CODE_BLOCK_WRAP,
+ undefined,
+ { getOnInit: true },
+)
diff --git a/web/frontend/src/store/index.ts b/web/frontend/src/store/index.ts
index a13b7b161..2ef7631fe 100644
--- a/web/frontend/src/store/index.ts
+++ b/web/frontend/src/store/index.ts
@@ -1,3 +1,4 @@
export * from "./gateway"
export * from "./chat"
+export * from "./code-block"
export * from "./tour"