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`.
97 lines
4.3 KiB
TypeScript
97 lines
4.3 KiB
TypeScript
// Bash toolview registrant: third-party posture over the keyed toolview hole
|
|
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
|
|
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
|
|
// Child sessions keep a scoped badge so session-dimension differentiation stays
|
|
// observable inside the component (no parallel registry).
|
|
//
|
|
// A bash call declares the terminal render intent, so this row also renders
|
|
// the command's own output through TerminalBlock. This row has no expand
|
|
// control and is not a details-panel target either (tool rows stopped being
|
|
// one), so its terminal body is resident rather than expand-gated as in
|
|
// ToolRow, and the card's own copy and expand controls are the row's only
|
|
// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat
|
|
// flow's tighter cap over the block's own default of 16 — and the block's
|
|
// internal expander keeps a long output from taking over the message flow.
|
|
|
|
import type { Context } from 'cordis'
|
|
import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
import type { ToolRowProps } from '../contract/slots.ts'
|
|
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../contract/terminal-card-model.ts'
|
|
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
|
import css from './bash-sample.module.css'
|
|
|
|
function leadingFor(state: ToolRowState) {
|
|
switch (state) {
|
|
case 'error': return <StateDot state="error" />
|
|
case 'stopped': return <StateDot state="warning" />
|
|
// Running keeps the icon — the row sweep carries the in-flight signal.
|
|
default: return <IconApiOutline14 size={14} />
|
|
}
|
|
}
|
|
|
|
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
|
|
function stateStatus(state: ToolRowState): string | null {
|
|
switch (state) {
|
|
case 'running': return '运行中'
|
|
case 'error': return '失败'
|
|
case 'stopped': return '已停止'
|
|
default: return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the
|
|
* command's terminal card resident below it. The summary row is not a
|
|
* details-panel control (tool rows stopped being one), so the card's copy and
|
|
* expand controls are the row's only interactions.
|
|
*/
|
|
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
|
|
const model = toolRowModel(toolName, block)
|
|
// Session workspace root: the terminal view's cwd resolves against it (an
|
|
// omitted workdir IS the workspace), which the pure presenter cannot do.
|
|
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
|
|
const terminal = terminalCardModel(block, cwd)
|
|
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
|
const status = stateStatus(model.state)
|
|
return (
|
|
<div className={css.card}>
|
|
<div
|
|
className={css.root}
|
|
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
|
data-variant="bash"
|
|
data-state={model.state}
|
|
>
|
|
<span className={css.leading}>{leadingFor(model.state)}</span>
|
|
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
|
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
|
<span className={css.title}>{model.title}</span>
|
|
<span className={css.sep} aria-hidden />
|
|
{/* The terminal presenter's description is the contractual
|
|
above-card summary; it outranks the args-derived one. */}
|
|
<span className={css.summary}>{terminal?.description ?? model.summary}</span>
|
|
</div>
|
|
{terminal !== null && (
|
|
<TerminalBlock {...terminal.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The sample as a plain registrant plugin. `inject` carries the load-order
|
|
* seam: requiring the conversation service guarantees the chat entry (and
|
|
* with it the 'conversation.chat.toolview' declaration) is registered —
|
|
* ui-conversation's apply mounts the service after the chat entry.
|
|
*/
|
|
export const bashToolviewSample = {
|
|
name: 'bash-toolview-sample',
|
|
inject: ['slots', 'conversation'],
|
|
/**
|
|
* Register the bash row into the chat view's keyed toolview hole.
|
|
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
|
*/
|
|
apply(ctx: Context): void {
|
|
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash' }, BashRow)
|
|
},
|
|
}
|