feat(web): render bash tool output as a terminal card
The bash tool already declares the `card: 'terminal'` render intent for both its call and its result, and host/connection/runtime already deliver it to the browser as callView/resultView. The Web client ignored it: rows derived from raw args, and the details panel flattened every tool's content into one soft-wrapping `<pre>`. Column-aligned output folded into a paragraph and a long listing stretched the panel without bound. `TerminalBlock` (ui-primitives) renders a command as a terminal surface: a shortened-cwd prompt line, output at `white-space: pre` in a horizontally scrolling box, a head/tail height cap with an expand control, an exit-code/signal status pill, and a copy control for the raw output. ANSI SGR runs are parsed with `anser` and resolved onto `--dsw-*` theme tokens, with literal rgb kept for values the design system has no token for. Geometry and fonts mirror CodeBlock; the clipboard write both need moved into a package-internal `clipboard.ts`. Both Web render sites for a bash call consume the intent through one derivation (`terminal-card-model.ts`), so they cannot disagree about a command, its cwd, or its exit status: the keyed BashRow carries the card resident below its summary row, and the render-site fallback row keeps it behind its existing expand control. Rows cap at 8 lines against the panel's 16. Inline output in the chat row reverses this package's stated no-inline-output convention, on the owner's explicit decision; the Agent Note records the reversal and its bound. Tests: TerminalBlock/ansi/clipboard unit specs, ui-conversation wiring specs at every render site, a built-client-graph snapshot covering both chat-row shapes, and a real-browser e2e asserting the no-wrap layout and the page's own Clipboard API.
This commit is contained in:
170
packages/client/ui-primitives/src/TerminalBlock.tsx
Normal file
170
packages/client/ui-primitives/src/TerminalBlock.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
// TerminalBlock: the terminal surface for a shell command and its output —
|
||||
// prompt line (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 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 copy control
|
||||
// still copies `text` untouched.
|
||||
const lines = useMemo(() => parseAnsiLines(text.endsWith('\n') ? text.slice(0, -1) : text), [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 empty = 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.cwd}>{cwd === undefined ? '$' : promptLabel(cwd, home)}</span>
|
||||
<span className={css.command}>{command}</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user