Files
deepseek-harness/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx
2026-07-31 20:47:55 +08:00

153 lines
6.5 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}).
//
// A bash call declares the terminal render intent, so this row renders the
// command's own output through TerminalBlock — expand-gated exactly like
// ToolRow's unified interaction: collapsed by default, the whole summary row
// is the toggle (click / Enter / Space, icon→chevron hover preview; the
// summary stays inline while open),
// and the expanded card max-height-scrolls inside its own surface with the
// full output (maxLines Infinity — no middle collapse). An error row's
// collapsed summary is the failure's first line in the error color.
import { useState, type KeyboardEvent } from 'react'
import type { Context } from 'cordis'
import clsx from 'clsx'
import {
IconApiOutline14, IconChevronDownOutline14, StateDot, TerminalBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts'
import { terminalBlockLabels, terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import { NS } from '../locales.ts'
import css from './bash-sample.module.css'
/** Bash row props: the toolview runtime share plus the standard locale seat. */
type BashRowProps = ToolRowProps & PropsLocale<'conversation'>
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, t: BashRowProps['t']): string | null {
switch (state) {
case 'running': return t('bash.running')
case 'error': return t('bash.failed')
case 'stopped': return t('bash.stopped')
default: return null
}
}
/**
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, the
* whole row toggling the command's terminal card (ToolRow's unified
* expand interaction, replicated locally per the registrant posture).
*/
export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) {
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)
// A failing exit status is the terminal card's own error signal (the call
// itself settles isError:false), surfaced as the row's red state dot.
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
? 'error'
: model.state
const status = stateStatus(state, t)
const [expanded, setExpanded] = useState(false)
const expandable = terminal !== null
const open = expanded && expandable
const failureLine = model.state === 'error' ? model.errorSummary : null
const toggleExpand = () => {
setExpanded(v => !v)
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
toggleExpand()
}
const leading = open
? <IconChevronDownOutline14 className={css.chevron} />
: expandable
? (
<>
<span className={css.iconIdle}>{leadingFor(state)}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
</>
)
: leadingFor(state)
return (
<div className={css.card}>
<div
className={css.root}
data-sample="bash"
data-variant="bash"
data-state={state}
data-expandable={expandable || undefined}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
aria-expanded={expandable ? open : undefined}
onClick={expandable ? toggleExpand : undefined}
onKeyDown={expandable ? toggleFromKeyboard : undefined}
>
<span className={css.leading}>{leading}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</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; a failure's first line outranks both. */}
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
{failureLine ?? terminal?.description ?? model.summary}
</span>
</div>
{terminal !== null && open && (
/* Same hover-Inspect posture as ToolRow's expanded body, replicated
locally per the registrant posture. */
<div className={css.bodyWrap}>
<TerminalBlock
{...terminal.card}
maxLines={Infinity}
labels={terminalBlockLabels(t)}
className={css.terminal}
/>
{inspect !== undefined && (
<button type="button" className={css.inspectButton} onClick={inspect}>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
</svg>
Inspect
</button>
)}
</div>
)}
</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', locale: NS }, BashRow)
},
}