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