feat(web): render read tool output as a line-numbered code card

Consume the card:'read' result view (path, numbered lines, totalLines, lang)
the read backend PR added. ReadBlock (ui-primitives) draws a per-line gutter
with each line's own file number, shiki highlighting via a new highlightLines
returning per-line token arrays, a 显示 X / Y 行 window note, a height cap
matching TerminalBlock, and a copy control. read-card-model is the single
resultView derivation; a keyed ReadRow registers under read with the card
resident under its path-link summary. The generic fallback and the details
panel are read-aware. Fixture gains a windowed read turn for the built-boot
snapshot.
This commit is contained in:
Chinesezjc
2026-07-30 18:15:38 +08:00
parent eb4cc8efc5
commit 7a60a236bc
20 files changed
+1297 -12

No files matched your search

@@ -0,0 +1,117 @@
/* Geometry mirrors CodeBlock (12px radius, code-block surface + banner row,
markdown code-block font) so a read card and a fenced code block read as one
family. Content keeps `white-space: pre` and scrolls horizontally rather than
folding, because a source line's indentation is part of what a reader is
reading. */
.block {
--dsl-read-radius: 12px;
--dsl-read-line-height: 22px;
/* Fixed-width gutter column for the line numbers, so the content edge stays
put down the whole window regardless of how wide the numbers grow. */
--dsl-read-gutter: 48px;
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-read-radius);
}
.banner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 9px 14px;
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-read-radius);
border-top-right-radius: var(--dsl-read-radius);
}
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
}
.action {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 12px;
}
.count {
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
.lang {
color: var(--dsw-alias-label-tertiary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
}
.copyButton {
background-color: transparent;
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
}
.body {
padding: 12px 0;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
}
/* One row per file line: a fixed gutter column, then the content. No wrapping —
a source line's leading whitespace is meaningful and scrolls sideways. */
.line {
display: flex;
min-height: var(--dsl-read-line-height);
line-height: var(--dsl-read-line-height);
white-space: pre;
}
.gutter {
flex: none;
width: var(--dsl-read-gutter);
padding-right: 14px;
text-align: right;
color: var(--dsw-alias-label-tertiary);
/* The gutter is chrome, not content: keep it out of a text selection so a
copy of the visible rows carries the source, not the line numbers. */
user-select: none;
}
.content {
color: var(--dsw-alias-label-primary);
}
.expand {
display: block;
width: 100%;
padding: 0 0 0 var(--dsl-read-gutter);
border: none;
background-color: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
font: inherit;
text-align: left;
}
.expand:hover {
color: var(--dsw-alias-label-secondary);
}
@@ -0,0 +1,156 @@
// ReadBlock: the file surface for a read tool result — a banner (label +
// language + a "showing N of M" note when the read is a window + a copy
// control) over line-numbered, syntax-highlighted source. Each row carries the
// file's OWN line number in a gutter, so a windowed read past an offset keeps
// its file numbering rather than re-counting from 1. Highlighting reuses the
// CodeBlock shiki path (highlight.ts) at the per-line granularity a gutter
// needs; an unknown or absent language renders plain monospace. Long content is
// height-capped with the same head/tail arithmetic TerminalBlock uses, so the
// two cards collapse a long body at the same place. Colors resolve through
// --shiki-*/--dsw-* tokens.
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import { writeClipboard } from './clipboard.ts'
import { highlightLines, type HighlightSpan } from './markdown/highlight.ts'
import css from './ReadBlock.module.css'
/**
* Content lines shown before the height cap collapses the middle. Matches
* TerminalBlock's default so a long read and a long command output cut at the
* same place in the same flow.
*/
export const DEFAULT_READ_MAX_LINES = 16
/** One line of the read window: its file line number and its text (no trailing newline). */
export interface ReadBlockLine {
/** 1-based line number in the file (a window past an offset keeps the file's own numbering). */
number: number
/** The line's text, already truncated to the read tool's per-line cap. */
text: string
}
export interface ReadBlockProps {
/** Banner label (the file path, or a tool-supplied replacement title); omitted draws no label. */
label?: string | undefined
/** The returned window's lines, in file order, each keeping its file line number. */
lines: readonly ReadBlockLine[]
/** Exact total line count in the file, for the "showing N of M" note when the read is a window. */
totalLines: number
/** Grammar hint (a file-extension-derived language id); unknown or absent = plain monospace. */
lang?: string | undefined
/** Height cap in content lines before the middle collapses (default {@link DEFAULT_READ_MAX_LINES}). */
maxLines?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
/**
* Render one line's highlighted runs. The css-variables theme colors every run,
* so each run is a styled span; a line with no highlighting at all takes the
* bare-text path in the caller instead (an unknown or absent language).
* @param spans - the line's styled runs.
* @returns the line's children.
*/
function renderSpans(spans: readonly HighlightSpan[]) {
return spans.map((span, index) => <span key={index} style={span.style}>{span.text}</span>)
}
/**
* Render a read tool result as a line-numbered, optionally syntax-highlighted
* file view.
* @param props - see {@link ReadBlockProps}.
* @returns the read block element.
*/
export function ReadBlock({
label,
lines,
totalLines,
lang,
maxLines = DEFAULT_READ_MAX_LINES,
className,
}: ReadBlockProps) {
// The raw text the copy control writes and the highlighter tokenizes: the
// window's lines joined by newlines, without the file numbers or any chrome.
// Highlighting the whole window in one call (not line by line) keeps grammar
// context across lines — a multi-line string or comment stays one construct.
const raw = useMemo(() => lines.map(line => line.text).join('\n'), [lines])
// Per-line highlighted runs aligned 1:1 with `lines`; undefined for an
// unknown/absent language, when every line renders as bare text.
const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
// The window's raw text, never the rendered tree: the gutter numbers and the
// banner are chrome the file does not contain.
void writeClipboard(raw).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [copied, raw])
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
const hidden = lines.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as TerminalBlock's height cap, so a long read and a
// long command output slice their head and tail at the same place.
const headLines = Math.ceil(maxLines / 2)
const tailLines = maxLines - headLines
// A read is a window when its returned lines are fewer than the file's total;
// the note states that so a reader is not misled that the file ends here.
const windowed = lines.length < totalLines
/**
* Render a slice of the line array as gutter-numbered rows.
* @param slice - the lines to draw, each with its aligned run array.
* @returns the row elements.
*/
const rows = (slice: readonly (readonly [ReadBlockLine, readonly HighlightSpan[] | undefined])[]) =>
slice.map(([line, spans]) => (
<div key={line.number} className={css.line}>
<span className={css.gutter} aria-hidden>{line.number}</span>
<span className={css.content}>{spans === undefined ? line.text : renderSpans(spans)}</span>
</div>
))
// Pair each line with its aligned run array up front, so head/tail slicing
// keeps the two in step without re-indexing.
const paired = lines.map((line, index): readonly [ReadBlockLine, readonly HighlightSpan[] | undefined] =>
[line, highlighted?.[index]])
return (
<div className={clsx(css.block, className)} data-read="">
<div className={css.banner}>
<div className={css.label}>{label ?? ''}</div>
<div className={css.action}>
{windowed && (
<span className={css.count}>{`显示 ${lines.length} / ${totalLines}`}</span>
)}
<span className={css.lang}>{lang ?? ''}</span>
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
</div>
</div>
<div className={css.body}>
{rows(capped ? paired.slice(0, headLines) : paired)}
{hidden > 0 && (
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起内容' : `展开其余 ${hidden}`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden}`}
</button>
)}
{capped && rows(paired.slice(paired.length - tailLines))}
</div>
</div>
)
}
@@ -22,6 +22,8 @@ export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps } from './JsonTree.tsx'
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
export type { TerminalBlockProps } from './TerminalBlock.tsx'
export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx'
export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
@@ -17,6 +17,7 @@ import langTs from '@shikijs/langs/typescript'
import langBash from '@shikijs/langs/shellscript'
import langJson from '@shikijs/langs/json'
import type { HighlighterCore } from 'shiki/core'
import type { CSSProperties } from 'react'
/**
* Language ids (and aliases) the singleton registers; everything else renders
@@ -80,3 +81,42 @@ export function highlightToHtml(code: string, lang: string | undefined): string
if (resolved === undefined) return undefined
return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
}
/**
* One highlighted run of a line: the text and the inline style shiki assigned
* it. The css-variables theme colors every run through a `--shiki-*` custom
* property, so `style.color` is always present; it is held as a style object
* rather than a bare color so a run spreads onto a `<span style>` uniformly.
*/
export interface HighlightSpan {
text: string
style: CSSProperties
}
/**
* Tokenize `code` into per-line highlighted runs when `lang` maps to a
* registered grammar; `undefined` means the caller renders its plain fallback.
* A line-numbered view needs the token runs split per line (one gutter number
* per line), which the single-`<pre>` {@link highlightToHtml} does not expose,
* so this returns shiki's own 2D line/token structure narrowed to what a run
* renders. Each run's color is a `--shiki-*` custom property, keeping token
* colors on the theme package's sheets exactly as the HTML path does; the
* css-variables theme carries no font-style bits, matching that path's
* color-only output. The trailing newline shiki appends as a final empty line
* is dropped so the run count matches the caller's own line array.
* @param code - the source text.
* @param lang - the language hint (a file-extension-derived language id).
* @returns one entry per source line (each an array of runs), or `undefined` for unknown languages.
*/
export function highlightLines(code: string, lang: string | undefined): HighlightSpan[][] | undefined {
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
if (resolved === undefined) return undefined
const { tokens } = highlighter().codeToTokens(code, { lang: resolved, theme: 'css-variables' })
// shiki tokenizes `a\nb` into two lines; a trailing newline (`a\n`) adds a
// third, empty line the caller's own line array does not carry. Drop that
// one terminator line so the two structures stay in step.
const lines = tokens.length > 1 && tokens[tokens.length - 1]?.length === 0
? tokens.slice(0, -1)
: tokens
return lines.map(line => line.map(token => ({ text: token.content, style: { color: token.color } })))
}