TerminalBlock.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. // TerminalBlock: the terminal surface for a shell command and its output —
  2. // prompt line (run-state dot + shortened cwd + command), ANSI-colored output,
  3. // settled exit status, and a copy control for the raw output. Output never soft-wraps:
  4. // column-aligned output (ls, tables, box drawing) keeps its alignment and
  5. // scrolls horizontally instead of folding. Colors resolve through --dsw-*
  6. // tokens; ANSI parsing lives in ansi.ts.
  7. import { useCallback, useMemo, useState } from 'react'
  8. import clsx from 'clsx'
  9. import { parseAnsiLines, type AnsiLine } from './ansi.ts'
  10. import { headTailCap } from './head-tail-cap.ts'
  11. import { useCopyFeedback } from './use-copy-feedback.ts'
  12. import { Pill } from './Pill.tsx'
  13. import { StateDot, type StateDotState } from './StateDot.tsx'
  14. import css from './TerminalBlock.module.css'
  15. /**
  16. * Output lines shown before the height cap collapses the middle. Matches the
  17. * TUI transcript's default tool-output budget so both front ends cut a long
  18. * command's output at the same place.
  19. */
  20. export const DEFAULT_TERMINAL_MAX_LINES = 16
  21. /**
  22. * Display copy for the terminal surface; the owner passes localized labels
  23. * (this package is cordis-free, so copy arrives via props). Every field
  24. * defaults to the current built-in value, so existing consumers render
  25. * unchanged.
  26. */
  27. export interface TerminalBlockLabels {
  28. /** Status pill text for a signal-terminated command. */
  29. signal: (signal: string) => string
  30. /** Status pill text for a non-zero exit code. */
  31. exitCode: (exitCode: number) => string
  32. /** Run-state text while the command is still running. */
  33. running: string
  34. /** Run-state text for a signal or non-zero-exit settle. */
  35. failed: string
  36. /** Run-state text for a clean settle. */
  37. done: string
  38. /** Copy-button idle label. */
  39. copy: string
  40. /** Copy-button label during the post-copy confirmation window. */
  41. copied: string
  42. /** Placeholder when a settled command produced no visible output. */
  43. noOutput: string
  44. /** Collapse-toggle aria label while expanded. */
  45. collapseAria: string
  46. /** Collapse-toggle text while expanded. */
  47. collapse: string
  48. /** Expand-toggle aria label while capped, given the hidden line count. */
  49. expandAria: (hidden: number) => string
  50. /** Expand-toggle text while capped, given the hidden line count. */
  51. expand: (hidden: number) => string
  52. }
  53. const DEFAULT_LABELS: TerminalBlockLabels = {
  54. signal: signal => `信号 ${signal}`,
  55. exitCode: exitCode => `退出码 ${exitCode}`,
  56. running: '运行中',
  57. failed: '失败',
  58. done: '已完成',
  59. copy: '复制',
  60. copied: '复制成功',
  61. noOutput: '无输出',
  62. collapseAria: '收起输出',
  63. collapse: '收起',
  64. expandAria: hidden => `展开其余 ${hidden} 行输出`,
  65. expand: hidden => `… 其余 ${hidden} 行`,
  66. }
  67. export interface TerminalBlockProps {
  68. /** The command line, rendered verbatim after the prompt label. */
  69. command: string
  70. /** Working directory for the prompt label; absent renders a plain `$`. */
  71. cwd?: string | undefined
  72. /** Absolute home directory, so a cwd equal to it collapses to `~`; absent disables that collapse. */
  73. home?: string | undefined
  74. /** The command's output text; may contain ANSI escape sequences. */
  75. output?: string | undefined
  76. /** Settled exit code; a non-zero value renders the status pill. */
  77. exitCode?: number | undefined
  78. /** Settled terminating signal name; any value renders the status pill, taking precedence over the exit code. */
  79. signal?: string | undefined
  80. /** The command is still running: the block shows the prompt line alone. */
  81. running?: boolean | undefined
  82. /** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */
  83. maxLines?: number | undefined
  84. /** Extra class merged onto the wrapper (callers position; this component draws). */
  85. className?: string | undefined
  86. /** Localized display copy; omitted fields keep the built-in defaults. */
  87. labels?: Partial<TerminalBlockLabels> | undefined
  88. }
  89. /**
  90. * Prompt label for a working directory: `~` for the home directory itself,
  91. * otherwise the path's last segment (both separators accepted, trailing
  92. * separators ignored), falling back to the path itself when it has no
  93. * segment.
  94. * @param cwd - the working directory path.
  95. * @param home - absolute home directory, when the caller knows it.
  96. * @returns the prompt label.
  97. */
  98. function promptLabel(cwd: string, home: string | undefined): string {
  99. const trimmed = cwd.replace(/[/\\]+$/, '')
  100. if (home !== undefined && trimmed === home.replace(/[/\\]+$/, '')) return '~'
  101. const segment = trimmed.split(/[/\\]/).pop()
  102. return segment === undefined || segment === '' ? cwd : segment
  103. }
  104. /**
  105. * Status pill text for a settled command, or undefined when the command
  106. * settled cleanly (exit 0, no signal) and needs no pill — the same
  107. * distinction the bash tool's own exit-status markers draw.
  108. * @param exitCode - settled exit code, when known.
  109. * @param signal - settled terminating signal name, when known.
  110. * @param labels - display copy for the pill text.
  111. * @returns the pill text, or undefined for a clean exit.
  112. */
  113. function statusText(
  114. exitCode: number | undefined,
  115. signal: string | undefined,
  116. labels: TerminalBlockLabels,
  117. ): string | undefined {
  118. if (signal !== undefined) return labels.signal(signal)
  119. if (exitCode !== undefined && exitCode !== 0) return labels.exitCode(exitCode)
  120. return undefined
  121. }
  122. /**
  123. * Run-state indicator for the command, shown at the head of the prompt line so
  124. * the card states whether the command is still running without the reader
  125. * having to infer it from the presence of output. Three of {@link StateDotState}'s
  126. * four states are reachable: the running chase (the same
  127. * indicator a running tool row's leading icon uses, so the row and its card
  128. * never disagree), green for a clean settle, red for a signal or a non-zero
  129. * exit — the same status distinction {@link statusText} draws for the pill. A
  130. * settled command whose exit status never reached the view counts as a clean
  131. * settle: the view says it finished and says nothing went wrong.
  132. * @param running - the command has not settled.
  133. * @param exitCode - settled exit code, when known.
  134. * @param signal - settled terminating signal name, when known.
  135. * @param labels - display copy for the text label.
  136. * @returns the dot's state and its text label, since the dot is aria-hidden.
  137. */
  138. function runState(
  139. running: boolean,
  140. exitCode: number | undefined,
  141. signal: string | undefined,
  142. labels: TerminalBlockLabels,
  143. ): { state: StateDotState; label: string } {
  144. if (running) return { state: 'ongoing', label: labels.running }
  145. if (statusText(exitCode, signal, labels) !== undefined) return { state: 'error', label: labels.failed }
  146. return { state: 'done', label: labels.done }
  147. }
  148. /**
  149. * Render one parsed output line. Runs without SGR state render as bare text,
  150. * so uncolored output carries no span wrappers.
  151. * @param line - the line's styled runs.
  152. * @returns the line's children.
  153. */
  154. function renderLine(line: AnsiLine) {
  155. return line.map((span, index) => span.style === undefined
  156. ? span.text
  157. : <span key={index} style={span.style}>{span.text}</span>)
  158. }
  159. /**
  160. * Render a shell command as a terminal surface.
  161. * @param props - see {@link TerminalBlockProps}.
  162. * @returns the terminal block element.
  163. */
  164. export function TerminalBlock({
  165. command,
  166. cwd,
  167. home,
  168. output,
  169. exitCode,
  170. signal,
  171. running = false,
  172. maxLines = DEFAULT_TERMINAL_MAX_LINES,
  173. className,
  174. labels,
  175. }: TerminalBlockProps) {
  176. const copy = useMemo<TerminalBlockLabels>(
  177. () => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }),
  178. [labels],
  179. )
  180. const text = output ?? ''
  181. // A command's output ends with a newline; that terminator is not an extra
  182. // blank line to draw or to count against the height cap. The check runs on the
  183. // PARSED lines rather than on the raw text, because a reset after the final
  184. // newline (`line\n\x1b[0m`) leaves the string not ending in one while still
  185. // producing a last line with nothing visible in it. A genuinely blank final
  186. // line — the double newline — survives, since it has a real empty line before
  187. // the terminator. The copy control still copies `text` untouched.
  188. const lines = useMemo(() => {
  189. const parsed = parseAnsiLines(text)
  190. const last = parsed[parsed.length - 1]
  191. const terminated = parsed.length > 1 && last !== undefined
  192. && last.every(span => span.text === '')
  193. return terminated ? parsed.slice(0, -1) : parsed
  194. }, [text])
  195. const [expanded, setExpanded] = useState(false)
  196. // The raw output, never the rendered tree: the prompt line and the status pill
  197. // are chrome the user did not run.
  198. const { copied, onCopy } = useCopyFeedback(text)
  199. const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
  200. const status = statusText(exitCode, signal, copy)
  201. const state = runState(running, exitCode, signal, copy)
  202. // A multi-line command gets one prompt row per line, so a two-command shell
  203. // snippet reads as the two commands it is instead of collapsing into one
  204. // ellipsized row. A trailing newline is a terminator, not an empty command.
  205. const commandLines = useMemo(() => {
  206. const body = command.endsWith('\n') ? command.slice(0, -1) : command
  207. return body.split('\n')
  208. }, [command])
  209. // Read from the parsed lines the card actually renders, not from the raw text:
  210. // output that is only escapes or control bytes (a lone reset, an OSC title, an
  211. // erase) survives `text.trim()` yet parses to nothing visible. Judging it on
  212. // the raw text would draw an output box of blank rows plus a copy control
  213. // for invisible bytes, and hide the placeholder that belongs there.
  214. const empty = lines.every(line => line.every(span => span.text.trim() === ''))
  215. const { hidden, capped, headLines, tailLines } = headTailCap(lines.length, maxLines, expanded)
  216. return (
  217. <div className={clsx(css.block, className)} data-terminal="" data-running={running ? '' : undefined}>
  218. <div className={css.header}>
  219. <div className={css.prompt}>
  220. <span className={css.runStateLabel}>{state.label}</span>
  221. {commandLines.map((line, index) => (
  222. <div key={index} className={css.promptLine}>
  223. {/* One dot for the card, on the first row: the exit status the
  224. view carries is the whole call's, and bash reports no
  225. per-command status, so a dot per row would assert a
  226. per-line outcome nothing here knows. */}
  227. {index === 0 && <StateDot state={state.state} className={css.runState} />}
  228. {/* The cwd labels the CALL, so only its first row carries it. The
  229. view knows one working directory — where the call started —
  230. and a later line may well run somewhere else (a `cd` in the
  231. command is enough), so repeating the label down the rows would
  232. assert a directory per line that nothing here knows. Later
  233. rows keep a bare `$` to stay aligned as prompts. */}
  234. <span className={css.cwd}>
  235. {index > 0 || cwd === undefined ? '$' : promptLabel(cwd, home)}
  236. </span>
  237. <span className={css.command}>{line}</span>
  238. </div>
  239. ))}
  240. </div>
  241. {status !== undefined && <Pill className={css.status}>{status}</Pill>}
  242. {!running && !empty && (
  243. <button type="button" className={css.copyButton} onClick={onCopy}>
  244. {copied ? copy.copied : copy.copy}
  245. </button>
  246. )}
  247. </div>
  248. {!running && (empty
  249. ? <div className={css.empty}>{copy.noOutput}</div>
  250. : (
  251. <div className={css.output}>
  252. {(capped ? lines.slice(0, headLines) : lines).map((line, index) => (
  253. <div key={index} className={css.line}>{renderLine(line)}</div>
  254. ))}
  255. {hidden > 0 && (
  256. <button
  257. type="button"
  258. className={css.expand}
  259. aria-expanded={expanded}
  260. aria-label={expanded ? copy.collapseAria : copy.expandAria(hidden)}
  261. onClick={onToggle}
  262. >
  263. {expanded ? copy.collapse : copy.expand(hidden)}
  264. </button>
  265. )}
  266. {capped && lines.slice(lines.length - tailLines).map((line, index) => (
  267. <div key={index} className={css.line}>{renderLine(line)}</div>
  268. ))}
  269. </div>
  270. ))}
  271. </div>
  272. )
  273. }