Merge remote-tracking branch 'origin/master' into trajectory-history-source

This commit is contained in:
imccyu
2026-07-29 20:58:04 +08:00
409 files changed
+5432 -2034

No files matched your search

+3 -1
View File
@@ -12,7 +12,9 @@ import css from './Pill.module.css'
*/
export function Pill({ active = false, className, children, onClick, ...rest }: {
active?: boolean
className?: string
// `| undefined` so a caller can forward an optional class straight through
// under exactOptionalPropertyTypes (a CSS-module lookup is string|undefined).
className?: string | undefined
children?: ReactNode
} & ButtonHTMLAttributes<HTMLButtonElement>) {
if (!onClick) {
@@ -23,8 +23,8 @@ const MATRIX_CELLS: readonly (readonly [number, number])[] = [
*/
export function StateDot({ state, size = 10, className }: {
state: StateDotState
size?: number
className?: string
size?: number | undefined
className?: string | undefined
}) {
if (state === 'ongoing') {
return (
@@ -0,0 +1,152 @@
/* Geometry mirrors CodeBlock (12px radius, code-block surface + banner rows,
markdown code-block font) so a terminal card and a fenced code block read as
one family. The one deliberate divergence: output keeps `white-space: pre`
and scrolls horizontally, because folding a column-aligned command's output
destroys its alignment. */
.block {
--dsl-terminal-radius: 12px;
--dsl-terminal-line-height: 22px;
/* The card's own left inset, holding the run-state dot in a column of its own
so it never competes with the commands for horizontal space. */
--dsl-terminal-gutter: 30px;
position: relative;
margin: 16px 0;
/* The gutter is the card's OWN padding, not a margin: every consumer rewrites
`margin` wholesale (each render site sets its own indent), which silently
cancelled the reservation and let the dot fall outside the card into a
container that clips it. Owning the reservation here keeps the invariant
with the component that depends on it. */
padding-left: var(--dsl-terminal-gutter);
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-terminal-radius);
}
/* Top-aligned: the status pill and copy control stay on the first prompt row
however many command lines the card carries. */
.header {
display: flex;
align-items: flex-start;
gap: 12px;
/* Pulled back across the card's gutter padding so the banner background and
its top-left radius span the FULL surface, then re-inset by the same amount
so the prompt text and the dot keep their positions. A plain block child
only reaches the content box, which left the gutter column painted in the
body color and drew the card's top-left corner in it — invisible in the
light theme, where banner and body share a token, and visible in the dark
one, where they do not. */
margin-left: calc(-1 * var(--dsl-terminal-gutter));
padding: 9px 14px 9px var(--dsl-terminal-gutter);
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-terminal-radius);
border-top-right-radius: var(--dsl-terminal-radius);
}
/* One row per command line. The prompt column is the only element allowed to
shrink; the status pill and the copy control keep their intrinsic width. */
.prompt {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
font: var(--dsw-font-markdown-code-block);
}
.promptLine {
position: relative;
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
line-height: var(--dsl-terminal-line-height);
}
/* Out of flow inside the card's own gutter padding, so the reservation and the
dot move together and no consumer margin can pull them apart; the dot neither
indents its command nor depends on the command's text metrics to line up.
Centered against the row's line box, not the code font's baseline. */
.runState {
position: absolute;
left: calc(-1 * var(--dsl-terminal-gutter) + 8px);
top: 50%;
transform: translateY(-50%);
}
/* The dot is aria-hidden; this is its text label for assistive technology. */
.runStateLabel {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cwd {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
/* `pre`, not `nowrap`: the prompt row renders the command verbatim, and
`nowrap` collapses the repeated spaces, tabs, and alignment of an indented
continuation. Both hold the single row and the ellipsis. */
.command {
min-width: 0;
color: var(--dsw-alias-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
}
.status {
flex: none;
color: var(--dsw-alias-state-error-primary);
}
.copyButton {
flex: none;
background-color: transparent;
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
}
.output {
padding: 12px 14px 12px 0;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
}
/* No wrapping, no word-break: alignment is the payload of terminal output. */
.line {
min-height: var(--dsl-terminal-line-height);
white-space: pre;
}
.expand {
display: block;
width: 100%;
padding: 0;
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);
}
.empty {
padding: 12px 14px 12px 0;
font: var(--dsw-font-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
}
@@ -0,0 +1,237 @@
// TerminalBlock: the terminal surface for a shell command and its output —
// prompt line (run-state dot + shortened cwd + command), ANSI-colored output,
// settled exit status, and a copy control for the raw output. Output never soft-wraps:
// column-aligned output (ls, tables, box drawing) keeps its alignment and
// scrolls horizontally instead of folding. Colors resolve through --dsw-*
// tokens; ANSI parsing lives in ansi.ts.
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import { parseAnsiLines, type AnsiLine } from './ansi.ts'
import { writeClipboard } from './clipboard.ts'
import { Pill } from './Pill.tsx'
import { StateDot, type StateDotState } from './StateDot.tsx'
import css from './TerminalBlock.module.css'
/**
* Output lines shown before the height cap collapses the middle. Matches the
* TUI transcript's default tool-output budget so both front ends cut a long
* command's output at the same place.
*/
export const DEFAULT_TERMINAL_MAX_LINES = 16
export interface TerminalBlockProps {
/** The command line, rendered verbatim after the prompt label. */
command: string
/** Working directory for the prompt label; absent renders a plain `$`. */
cwd?: string | undefined
/** Absolute home directory, so a cwd equal to it collapses to `~`; absent disables that collapse. */
home?: string | undefined
/** The command's output text; may contain ANSI escape sequences. */
output?: string | undefined
/** Settled exit code; a non-zero value renders the status pill. */
exitCode?: number | undefined
/** Settled terminating signal name; any value renders the status pill, taking precedence over the exit code. */
signal?: string | undefined
/** The command is still running: the block shows the prompt line alone. */
running?: boolean | undefined
/** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}). */
maxLines?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
/**
* Prompt label for a working directory: `~` for the home directory itself,
* otherwise the path's last segment (both separators accepted, trailing
* separators ignored), falling back to the path itself when it has no
* segment.
* @param cwd - the working directory path.
* @param home - absolute home directory, when the caller knows it.
* @returns the prompt label.
*/
function promptLabel(cwd: string, home: string | undefined): string {
const trimmed = cwd.replace(/[/\\]+$/, '')
if (home !== undefined && trimmed === home.replace(/[/\\]+$/, '')) return '~'
const segment = trimmed.split(/[/\\]/).pop()
return segment === undefined || segment === '' ? cwd : segment
}
/**
* Status pill text for a settled command, or undefined when the command
* settled cleanly (exit 0, no signal) and needs no pill — the same
* distinction the bash tool's own exit-status markers draw.
* @param exitCode - settled exit code, when known.
* @param signal - settled terminating signal name, when known.
* @returns the pill text, or undefined for a clean exit.
*/
function statusText(exitCode: number | undefined, signal: string | undefined): string | undefined {
if (signal !== undefined) return `信号 ${signal}`
if (exitCode !== undefined && exitCode !== 0) return `退出码 ${exitCode}`
return undefined
}
/**
* Run-state indicator for the command, shown at the head of the prompt line so
* the card states whether the command is still running without the reader
* having to infer it from the presence of output. Three of {@link StateDotState}'s
* four states are reachable: the running chase (the same
* indicator a running tool row's leading icon uses, so the row and its card
* never disagree), green for a clean settle, red for a signal or a non-zero
* exit — the same status distinction {@link statusText} draws for the pill. A
* settled command whose exit status never reached the view counts as a clean
* settle: the view says it finished and says nothing went wrong.
* @param running - the command has not settled.
* @param exitCode - settled exit code, when known.
* @param signal - settled terminating signal name, when known.
* @returns the dot's state and its text label, since the dot is aria-hidden.
*/
function runState(
running: boolean,
exitCode: number | undefined,
signal: string | undefined,
): { state: StateDotState; label: string } {
if (running) return { state: 'ongoing', label: '运行中' }
if (statusText(exitCode, signal) !== undefined) return { state: 'error', label: '失败' }
return { state: 'done', label: '已完成' }
}
/**
* Render one parsed output line. Runs without SGR state render as bare text,
* so uncolored output carries no span wrappers.
* @param line - the line's styled runs.
* @returns the line's children.
*/
function renderLine(line: AnsiLine) {
return line.map((span, index) => span.style === undefined
? span.text
: <span key={index} style={span.style}>{span.text}</span>)
}
/**
* Render a shell command as a terminal surface.
* @param props - see {@link TerminalBlockProps}.
* @returns the terminal block element.
*/
export function TerminalBlock({
command,
cwd,
home,
output,
exitCode,
signal,
running = false,
maxLines = DEFAULT_TERMINAL_MAX_LINES,
className,
}: TerminalBlockProps) {
const text = output ?? ''
// A command's output ends with a newline; that terminator is not an extra
// blank line to draw or to count against the height cap. The check runs on the
// PARSED lines rather than on the raw text, because a reset after the final
// newline (`line\n\x1b[0m`) leaves the string not ending in one while still
// producing a last line with nothing visible in it. A genuinely blank final
// line — the double newline — survives, since it has a real empty line before
// the terminator. The copy control still copies `text` untouched.
const lines = useMemo(() => {
const parsed = parseAnsiLines(text)
const last = parsed[parsed.length - 1]
const terminated = parsed.length > 1 && last !== undefined
&& last.every(span => span.text === '')
return terminated ? parsed.slice(0, -1) : parsed
}, [text])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
// The raw output, never the rendered tree: the prompt line and the status
// pill are chrome the user did not run.
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [copied, text])
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
const status = statusText(exitCode, signal)
const state = runState(running, exitCode, signal)
// A multi-line command gets one prompt row per line, so a two-command shell
// snippet reads as the two commands it is instead of collapsing into one
// ellipsized row. A trailing newline is a terminator, not an empty command.
const commandLines = useMemo(() => {
const body = command.endsWith('\n') ? command.slice(0, -1) : command
return body.split('\n')
}, [command])
// Read from the parsed lines the card actually renders, not from the raw text:
// output that is only escapes or control bytes (a lone reset, an OSC title, an
// erase) survives `text.trim()` yet parses to nothing visible. Judging it on
// the raw text drew an output box of blank rows plus a copy control for
// invisible bytes, and hid the placeholder that belongs there.
const empty = lines.every(line => line.every(span => span.text.trim() === ''))
const hidden = lines.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as the TUI transcript's collapsed tool card, so a
// command's head and tail slices agree between the two front ends.
const headLines = Math.ceil(maxLines / 2)
const tailLines = maxLines - headLines
return (
<div className={clsx(css.block, className)} data-terminal="" data-running={running ? '' : undefined}>
<div className={css.header}>
<div className={css.prompt}>
<span className={css.runStateLabel}>{state.label}</span>
{commandLines.map((line, index) => (
<div key={index} className={css.promptLine}>
{/* One dot for the card, on the first row: the exit status the
view carries is the whole call's, and bash reports no
per-command status, so a dot per row would assert a
per-line outcome nothing here knows. */}
{index === 0 && <StateDot state={state.state} className={css.runState} />}
{/* The cwd labels the CALL, so only its first row carries it. The
view knows one working directory — where the call started —
and a later line may well run somewhere else (a `cd` in the
command is enough), so repeating the label down the rows would
assert a directory per line that nothing here knows. Later
rows keep a bare `$` to stay aligned as prompts. */}
<span className={css.cwd}>
{index > 0 || cwd === undefined ? '$' : promptLabel(cwd, home)}
</span>
<span className={css.command}>{line}</span>
</div>
))}
</div>
{status !== undefined && <Pill className={css.status}>{status}</Pill>}
{!running && !empty && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
)}
</div>
{!running && (empty
? <div className={css.empty}></div>
: (
<div className={css.output}>
{(capped ? lines.slice(0, headLines) : lines).map((line, index) => (
<div key={index} className={css.line}>{renderLine(line)}</div>
))}
{hidden > 0 && (
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起输出' : `展开其余 ${hidden} 行输出`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden}`}
</button>
)}
{capped && lines.slice(lines.length - tailLines).map((line, index) => (
<div key={index} className={css.line}>{renderLine(line)}</div>
))}
</div>
))}
</div>
)
}
+447
View File
@@ -0,0 +1,447 @@
// ANSI model behind TerminalBlock: anser splits the SGR runs, this module
// resolves each run's colors and decorations into a plain style record and
// folds the runs into per-line span arrays so a height cap can slice whole
// lines. Sequences anser does not turn into color (OSC, cursor movement,
// other C0 controls) are removed before parsing so they never reach the DOM
// as literal characters.
import Anser from 'anser'
import type { CSSProperties } from 'react'
/**
* The subset of one anser JSON chunk this module reads. anser's own types
* declare `fg`/`bg` as `string`, but its parser leaves them `null` for a run
* that sets no color, so the null is spelled out here.
*/
interface AnsiChunk {
/** Run text with its SGR codes already removed. */
content: string
/** Foreground as an `r, g, b` triple, or null when the run sets none. */
fg: string | null
/** Background as an `r, g, b` triple, or null when the run sets none. */
bg: string | null
/** SGR attributes in effect for the run, in the order they were declared. */
decorations: readonly string[]
}
/** One run of terminal text; `style` is undefined for text that carries no SGR state. */
export interface AnsiSpan {
/** The run's plain text, free of escape sequences and newlines. */
text: string
/** Resolved inline style, or undefined when the run needs no wrapper. */
style: CSSProperties | undefined
}
/** The spans of one output line, in order. */
export type AnsiLine = readonly AnsiSpan[]
/**
* The 8/16 basic ANSI colors, keyed by the whitespace-free `r,g,b` triple
* anser emits for them, mapped onto the theme tokens that carry the same
* semantic. Black and white both resolve to the primary label color so text
* stays legible under either theme instead of matching the surface it sits
* on; bright black takes the tertiary label color (the muted-gray role).
* Magenta and cyan have no token equivalent in this design system and fall
* through to anser's literal rgb, as do all 256-palette and truecolor values.
*/
const TOKEN_BY_BASIC_RGB: Record<string, string> = {
'0,0,0': 'var(--dsw-alias-label-primary)',
'255,255,255': 'var(--dsw-alias-label-primary)',
'85,85,85': 'var(--dsw-alias-label-tertiary)',
'187,0,0': 'var(--dsw-alias-state-error-primary)',
'255,85,85': 'var(--dsw-alias-state-error-secondary)',
'0,187,0': 'var(--dsw-alias-state-success-primary)',
'0,255,0': 'var(--dsw-alias-state-success-secondary)',
'187,187,0': 'var(--dsw-alias-state-warn-primary)',
'255,255,85': 'var(--dsw-alias-state-warn-secondary)',
'0,0,187': 'var(--dsw-alias-state-business-primary)',
'85,85,255': 'var(--dsw-static-blue-400)',
}
/**
* CSS for each SGR attribute anser reports. `blink` is deliberately absent —
* animated text is not reproduced. `reverse` never arrives here: anser
* consumes it by swapping the run's foreground and background. Underline and
* strikethrough share `textDecoration`, so in a run declaring both, the
* later declaration wins.
*/
const STYLE_BY_DECORATION: Record<string, CSSProperties | undefined> = {
bold: { fontWeight: 700 },
dim: { opacity: 0.7 },
italic: { fontStyle: 'italic' },
underline: { textDecoration: 'underline' },
strikethrough: { textDecoration: 'line-through' },
hidden: { visibility: 'hidden' },
}
/** OSC strings (window title, hyperlinks), with or without their terminator. */
const OSC_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g
/** Escape sequences other than CSI: charset selection, single-shift, reset. */
const NON_CSI_ESCAPE = /\u001b(?!\[)[\u0020-\u002f]*[\u0030-\u007e]?/g
/**
* C0 controls with no display meaning here. Tab, newline, backspace and ESC
* survive: the first two for layout, backspace for the cursor replay, ESC
* for anser's CSI split.
*/
const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
/**
* Lines whose cursor movements have to be replayed: a carriage return, a
* backspace, or an erase-in-line. The erase pattern matches the SAME CSI shape
* `replayLine` parses (parameters may carry `;` and intermediate bytes), so a
* form like `\x1b[1;2K` cannot slip past this guard and skip its own erase.
*/
const NEEDS_REPLAY = /\r|\u0008|\u001b\[[\u0030-\u003f]*[\u0020-\u002f]*K/
/** SGR sequences alone, for folding state through a line that needs no replay. */
const SGR_SEQUENCE = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*m/g
/** Terminal tab stop width; a tab advances to the next multiple of this. */
const TAB_WIDTH = 8
/**
* Combining marks and other zero-width code points: a terminal advances no
* column for them, so `e` + U+0301 occupies one cell and a two-column redraw
* covers both code points.
*/
const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}\u200b-\u200f\u2060]$/u
/**
* Characters a terminal advances two columns for: CJK scripts, fullwidth forms,
* CJK punctuation, and characters with emoji presentation. Text-presentation
* symbols (`\u2713`, `\u26a0` and the rest of U+2600-U+27BF) are ONE column and
* must stay out of this set.
*/
const WIDE_CHAR = new RegExp(
'\\p{Script=Han}|\\p{Script=Hiragana}|\\p{Script=Katakana}|\\p{Script=Hangul}'
// Emoji presentation only: the U+2600-U+27BF symbol block is mostly SINGLE
// width — `\u2713` (the check every progress line writes, this fixture
// included) advances one column, verified against a real terminal, so taking
// the whole block as wide misaligned exactly the output this card exists for.
+ '|\\p{Emoji_Presentation}'
+ '|[\\uff01-\\uff60\\u3000-\\u303e]',
'u',
)
/**
* Whether a character occupies two terminal columns (CJK, fullwidth forms,
* emoji). Covers the ranges a command's output realistically carries; a
* narrower guess would misalign the columns this card exists to preserve.
* @param char - one character from the output.
* @returns true when the terminal advances two columns for it.
*/
function isWide(char: string): boolean {
const code = char.codePointAt(0)
if (code === undefined || code < 0x1100) return false
return WIDE_CHAR.test(char)
}
/**
* A cell's graphic state, normalized. Held as fields rather than as the raw
* sequence history because a terminal tracks CURRENT state, not a transcript:
* accumulating sequences made each state boundary re-emit the whole chain, so
* output that switches color without a full reset emitted O(n^2) characters
* (3200 such cells produced 25 MB and eventually a `RangeError`). It also makes
* the attribute closers every chalk-based tool writes — `39`, `49`, `22`, `23`,
* `24`, `27`, `29` — actually close their attribute instead of appending to it.
*/
interface SgrState {
fg: string
bg: string
/** Attribute parameters in force, e.g. `1` (bold) or `4` (underline). */
attrs: readonly string[]
}
/** The default state: no color, no attributes. */
const SGR_NONE: SgrState = { fg: '', bg: '', attrs: [] }
/** Attribute closers, mapped to the opener parameters each one turns off. */
const ATTR_CLOSERS: Record<string, readonly string[]> = {
22: ['1', '2'], 23: ['3'], 24: ['4'], 25: ['5', '6'], 27: ['7'], 28: ['8'], 29: ['9'],
}
/**
* Fold one SGR sequence's parameters into the state it produces.
* @param state - state in force before the sequence.
* @param params - the sequence's raw parameter string (`31`, `1;4`, `38;5;208`).
* @returns the state the sequence leaves in force.
*/
function foldSgr(state: SgrState, params: string): SgrState {
const codes = params === '' ? ['0'] : params.split(';')
let next = state
for (let index = 0; index < codes.length; index++) {
const code = String(codes[index])
if (code === '' || code === '0') { next = SGR_NONE; continue }
// Extended color: `38;5;N` / `38;2;R;G;B` and the `48` background pair
// consume their own arguments, so they are taken whole.
if (code === '38' || code === '48') {
const kind = codes[index + 1] ?? ''
const span = kind === '2' ? 4 : kind === '5' ? 2 : 0
const value = codes.slice(index, index + span + 1).join(';')
next = code === '38' ? { ...next, fg: value } : { ...next, bg: value }
index += span
continue
}
const closes = ATTR_CLOSERS[code]
if (closes !== undefined) {
next = { ...next, attrs: next.attrs.filter(attr => !closes.includes(attr)) }
continue
}
const numeric = Number(code)
if (code === '39') { next = { ...next, fg: '' }; continue }
if (code === '49') { next = { ...next, bg: '' }; continue }
if ((numeric >= 30 && numeric <= 37) || (numeric >= 90 && numeric <= 97)) { next = { ...next, fg: code }; continue }
if ((numeric >= 40 && numeric <= 47) || (numeric >= 100 && numeric <= 107)) { next = { ...next, bg: code }; continue }
if (!next.attrs.includes(code)) next = { ...next, attrs: [...next.attrs, code] }
}
return next
}
/**
* Render a state as the one canonical sequence that establishes it from the
* default, so a boundary emits a bounded string no matter how the state was
* reached.
* @param state - the state to open.
* @returns the SGR sequence, or the empty string for the default state.
*/
function openSgr(state: SgrState): string {
const codes = [...state.attrs]
if (state.fg !== '') codes.push(state.fg)
if (state.bg !== '') codes.push(state.bg)
return codes.length === 0 ? '' : `\u001b[${codes.join(';')}m`
}
/** Whether two states are the same, so a boundary is only emitted on a change. */
function sameSgr(a: SgrState, b: SgrState): boolean {
return a.fg === b.fg && a.bg === b.bg && a.attrs.length === b.attrs.length
&& a.attrs.every((attr, index) => attr === b.attrs[index])
}
/**
* Replay one line's cursor movements the way a terminal paints it, into a
* column buffer. Carriage return and backspace only MOVE the cursor — neither
* erases anything — so what a reader sees is whatever each column last had
* written to it. That distinction is the whole point of doing this as a buffer
* rather than as string surgery: `100%\rOK` shows `OK0%` because the redraw is
* shorter than the frame beneath it, and a trailing `abc\b` still shows `abc`
* because nothing ever overwrote the `c`.
*
* A CSI sequence occupies no column; it changes the state that the NEXT writes
* are stamped with, which is how a terminal stores color per cell. `red bad`
* then three backspaces then `ok` therefore shows `okd` with the `d` still red:
* `ok` overwrote two cells and the third kept the state it was written with.
* The columns are re-emitted as runs, so anser sees that same styling.
* @param line - one output line, still carrying its CSI sequences.
* @param entrySgr - SGR state in force when the line begins, since a newline
* does not reset it.
* @returns the line as the terminal would have it after every movement, plus the
* SGR state at its end for the next line to enter with.
*/
function replayLine(line: string, entrySgr: SgrState): { text: string; sgr: SgrState } {
// Same shape anser splits on, so a sequence is one unit here as well.
const csi = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*([\u0040-\u007e])/g
/** Per column: the state in force when it was written, and its character. */
const columns: (Cell | undefined)[] = []
let cursor = 0
// State is tracked exactly as a terminal tracks it: each cell is stamped with
// whatever was in force at the moment of the write, so a later redraw cannot
// restyle the cells it does not reach. It enters carrying the previous line's
// state, since a newline does not reset it.
let sgr = entrySgr
let at = 0
/** Clear a cell and, for a wide pair, its partner: a terminal erases both. */
const clear = (index: number, fill: string): void => {
const cell = columns[index]
if (cell?.spacer === true && index > 0) columns[index - 1] = { sgr, char: fill }
else if (cell !== undefined && isWide(cell.char) && columns[index + 1]?.spacer === true) {
columns[index + 1] = { sgr, char: fill }
}
columns[index] = { sgr, char: fill }
}
const consume = (chunk: string): void => {
for (const char of chunk) {
if (char === '\r') { cursor = 0; continue }
if (char === '\u0008') { cursor = Math.max(0, cursor - 1); continue }
if (char === '\t') {
// A tab advances to the next 8-column stop, leaving the cells it skips
// as they were — which is how a redraw can leave a tabbed column
// standing. Column alignment is the whole point of this card.
const stop = cursor + TAB_WIDTH - (cursor % TAB_WIDTH)
for (; cursor < stop; cursor++) columns[cursor] ??= { sgr, char: ' ' }
continue
}
if (ZERO_WIDTH.test(char)) {
// No column of its own: it attaches to the cell already written, so a
// redraw that covers that cell covers the mark with it. With no cell to
// attach to (line start, or straight after a redraw to column 0) a
// terminal shows nothing rather than a lone accent.
const base = cursor > 0 ? columns[cursor - 1] : undefined
if (base !== undefined) columns[cursor - 1] = { sgr: base.sgr, char: base.char + char }
continue
}
// Writing over either half of a wide pair blanks the other half, since a
// terminal cannot leave one cell of a two-cell glyph standing.
clear(cursor, ' ')
columns[cursor] = { sgr, char }
cursor++
// A wide character occupies two columns; the trailing one is a spacer,
// marked so that overwriting the lead cell leaves a blank behind instead
// of closing the gap and shifting everything after it left.
if (isWide(char)) { columns[cursor] = { sgr, char: '', spacer: true }; cursor++ }
}
}
for (const match of line.matchAll(csi)) {
consume(line.slice(at, match.index))
at = match.index + match[0].length
// Both groups are mandatory in the pattern, so destructuring types them as
// strings without a fallback that could never run.
const params = String(match[1])
const final = String(match[2])
if (final === 'K') {
// Erase in line: the fixed companion of `\r` in every spinner and progress
// bar. Without it a shorter redraw leaves the previous frame's tail
// standing, which is text the terminal never showed. `1` blanks from the
// line start THROUGH the cursor column (inclusive, per the CSI spec)
// rather than dropping those cells, since the cursor does not move and a
// later write can still land past them. Only the FIRST parameter selects
// the mode; a terminal ignores the rest (`1;2K` erases exactly as `1K`).
const mode = String(params.split(';')[0])
if (mode === '1') for (let index = 0; index <= cursor; index++) clear(index, ' ')
else columns.length = mode === '2' ? 0 : cursor
continue
}
// Only SGR carries graphic state; every other final byte is a cursor or
// erase action that must not affect a cell's style.
if (final !== 'm') continue
sgr = foldSgr(sgr, params)
}
consume(line.slice(at))
// Re-emit the columns, opening a run only where its state changes, so anser
// sees the same styling a terminal shows. Each boundary emits ONE canonical
// sequence for the state it opens, which is what keeps the output linear in
// the number of cells however the state was reached.
let out = ''
let active = entrySgr
for (let index = 0; index < columns.length; index++) {
const column = columns[index] ?? { sgr: SGR_NONE, char: ' ' }
if (!sameSgr(column.sgr, active)) {
if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
out += openSgr(column.sgr)
active = column.sgr
}
// A spacer still holds its column. While its lead cell survives, the wide
// glyph spans both and the spacer emits nothing; once a later write replaced
// that lead, the terminal blanks the spacer instead of closing the gap, so
// emitting nothing would shift everything after it one column left.
const leadIntact = index > 0 && isWide(columns[index - 1]?.char ?? '')
out += column.spacer === true && !leadIntact ? ' ' : column.char
}
// Converge to the state the SCAN ended in, not the last written cell's: a
// sequence after the final write (the `\x1b[0m` closing a colored line) changes
// no cell yet still ends the run, and it has to reach both the DOM and the
// next line. Without this a line ending in a reset leaked its color onward.
if (!sameSgr(active, sgr)) {
if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
out += openSgr(sgr)
}
return { text: out, sgr }
}
/** One replayed column: the state it was written with, and its character. */
interface Cell {
sgr: SgrState
char: string
/** The trailing half of a wide character's two-column pair. */
spacer?: boolean
}
/**
* Replay every line's cursor movements. A `\r` that only terminates a CRLF line
* is dropped first, so those lines keep their text instead of being redrawn onto
* themselves. SGR state threads across lines: a newline does not reset it, so a
* run opened before a redraw still colors the lines after it.
* @param text - output text, already free of OSC and non-CSI escapes.
* @returns the text with each line painted as the terminal would.
*/
function applyCursorMovements(text: string): string {
const replayed: string[] = []
let sgr = SGR_NONE
for (const raw of text.split('\n')) {
const line = raw.replace(/\r+$/, '')
if (NEEDS_REPLAY.test(line)) {
const result = replayLine(line, sgr)
replayed.push(result.text)
sgr = result.sgr
continue
}
// No cursor movement: the line needs no column buffer, and painting one
// would allocate a cell per character of output this card never redraws —
// an `ls -R` or a 5k-line log. Only its own SGR has to be folded, so a later
// line that DOES replay enters with the right state.
replayed.push(line)
for (const match of line.matchAll(SGR_SEQUENCE)) sgr = foldSgr(sgr, String(match[1]))
}
return replayed.join('\n')
}
/**
* Remove every escape sequence and control character that carries no color,
* leaving CSI sequences for anser and `\n`/`\t` for layout. Cursor movements
* (carriage return, backspace) replay first, since their effect on the visible
* text must land before the characters that expressed them are dropped.
* @param text - raw command output.
* @returns text whose only remaining escapes are CSI sequences.
*/
function sanitize(text: string): string {
const escaped = text.replace(OSC_SEQUENCE, '').replace(NON_CSI_ESCAPE, '')
return applyCursorMovements(escaped).replace(INERT_CONTROL, '')
}
/**
* Resolve one run's colors and decorations.
* @param chunk - the anser chunk to style.
* @returns the run's inline style, or undefined when it carries no SGR state.
*/
function resolveStyle(chunk: AnsiChunk): CSSProperties | undefined {
const style: CSSProperties = {}
const background = chunk.bg === null ? undefined : `rgb(${chunk.bg})`
if (background !== undefined) style.backgroundColor = background
if (chunk.fg !== null) {
const literal = `rgb(${chunk.fg})`
// A run that paints its own background keeps anser's literal pair so the
// authored foreground/background contrast survives; a foreground-only run
// maps onto a theme token, which adapts to light and dark surfaces.
style.color = background === undefined
? TOKEN_BY_BASIC_RGB[chunk.fg.replace(/\s+/g, '')] ?? literal
: literal
}
for (const decoration of chunk.decorations) Object.assign(style, STYLE_BY_DECORATION[decoration])
return Object.keys(style).length === 0 ? undefined : style
}
/**
* Parse command output into styled spans grouped by line.
* @param text - raw output text, which may contain ANSI escape sequences.
* @returns one entry per output line (always at least one, possibly empty).
*/
export function parseAnsiLines(text: string): AnsiLine[] {
let current: AnsiSpan[] = []
const lines: AnsiSpan[][] = [current]
for (const chunk of Anser.ansiToJson(sanitize(text), { json: true, remove_empty: true })) {
const style = resolveStyle(chunk)
for (const [index, part] of chunk.content.split('\n').entries()) {
if (index > 0) {
current = []
lines.push(current)
}
if (part !== '') current.push({ text: part, style })
}
}
return lines
}
@@ -0,0 +1,48 @@
// Package-internal clipboard write, shared by every copy control in this
// package (CodeBlock's code copy, TerminalBlock's output copy). Not part of the
// public surface: consumers get the components, not the host detection.
/**
* Write text to the host clipboard, preferring the async Clipboard API and
* falling back to `execCommand('copy')` on hosts (jsdom, insecure contexts)
* that omit it.
* @param text - the exact text to place on the clipboard.
* @returns true only when the host accepted the write.
*/
export async function writeClipboard(text: string): Promise<boolean> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Denied permissions / iframe policy — do not claim success.
return false
}
}
// jsdom and older hosts: best-effort execCommand path when present.
// execCommand('copy') is the only clipboard fallback where the async API
// is missing; deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return false
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
return exec('copy')
} catch {
return false
} finally {
el.remove()
}
/* eslint-enable @typescript-eslint/no-deprecated */
}
@@ -19,6 +19,8 @@ export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
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 { CodeBlock } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
@@ -6,6 +6,7 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { writeClipboard } from '../clipboard.ts'
import { highlightToHtml } from './highlight.ts'
import css from './CodeBlock.module.css'
@@ -18,45 +19,6 @@ export interface CodeBlockProps {
className?: string | undefined
}
/** @returns true only when the host accepted the write. */
async function writeClipboard(text: string): Promise<boolean> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Denied permissions / iframe policy — do not claim success.
return false
}
}
// jsdom and older hosts: best-effort execCommand path when present.
// execCommand('copy') is the only clipboard fallback where the async API
// is missing; deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return false
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
return exec('copy')
} catch {
return false
} finally {
el.remove()
}
/* eslint-enable @typescript-eslint/no-deprecated */
}
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])