Nine findings, one critical. Terminal cases verified in a real terminal first. CRITICAL: cells held the accumulated SGR history, so every state boundary re-emitted the whole chain — output switching color without a full reset emitted O(n^2) characters. Measured: 3200 such cells produced 25 MB, and the reviewer's ~90 KB alternating-color case is well under bash's own output cap. State is now a normalized record (foreground, background, attribute set) with one canonical sequence per boundary, so the emitted text is linear in cells; the 90 KB case parses in 36 ms. That also makes the attribute closers every chalk-based tool writes actually close: `\x1b[1mbold\x1b[22mplain` leaves the following write PLAIN, which a real terminal confirms. Width follows emoji presentation, not the U+2600-U+27BF block: `A✓B` redrawn with `XY` shows `XYB`, so the check every progress line writes is ONE column. Taking the block as wide misaligned exactly the output this card exists for. Writing over either half of a wide pair blanks the other, since a terminal cannot leave one cell of a two-cell glyph standing. `line\n\x1b[0m` does not end in a newline as a string yet its last parsed line holds nothing visible, so the terminator check now reads the parsed lines — it had added a blank row and inflated the collapse count. A line with no cursor movement no longer builds a column buffer at all; only its SGR is folded, so an `ls -R` or a 5k-line log allocates nothing per character. The `.terminalDescription` rule had been inserted into an existing grouped selector, silently giving `.codeBody` description typography and changing its bottom margin from 4px to 0 — a pre-existing surface this PR does not own. Split out, `.codeBody`'s margin restored. Three comments contradicted their code: the fixture's exit-marker line (still claiming recovery from a marker deliberately removed), `bash-sample`'s header (still routing a click to the details panel, and calling the consumer's cap the block's own), and a DetailsPanel comment stacked above the wrong rule. The ui-primitives README documented only the CR/BS half of the replay, so a reader would expect `OK0%` where `100%\r\x1b[KOK` renders `OK`.
238 lines
10 KiB
TypeScript
238 lines
10 KiB
TypeScript
// 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>
|
|
)
|
|
}
|