返回 DeepSeek-Reasonix
TerminalBlock.tsx
根目录 / desktop / frontend / src / components / harness-chat / TerminalBlock.tsx
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
262 lines Plain Text