feat(web): render bash tool output as a terminal card
The bash tool already declares the `card: 'terminal'` render intent for both its call and its result, and host/connection/runtime already deliver it to the browser as callView/resultView. The Web client ignored it: rows derived from raw args, and the details panel flattened every tool's content into one soft-wrapping `<pre>`. Column-aligned output folded into a paragraph and a long listing stretched the panel without bound. `TerminalBlock` (ui-primitives) renders a command as a terminal surface: a shortened-cwd prompt line, output at `white-space: pre` in a horizontally scrolling box, a head/tail height cap with an expand control, an exit-code/signal status pill, and a copy control for the raw output. ANSI SGR runs are parsed with `anser` and resolved onto `--dsw-*` theme tokens, with literal rgb kept for values the design system has no token for. Geometry and fonts mirror CodeBlock; the clipboard write both need moved into a package-internal `clipboard.ts`. Both Web render sites for a bash call consume the intent through one derivation (`terminal-card-model.ts`), so they cannot disagree about a command, its cwd, or its exit status: the keyed BashRow carries the card resident below its summary row, and the render-site fallback row keeps it behind its existing expand control. Rows cap at 8 lines against the panel's 16. Inline output in the chat row reverses this package's stated no-inline-output convention, on the owner's explicit decision; the Agent Note records the reversal and its bound. Tests: TerminalBlock/ansi/clipboard unit specs, ui-conversation wiring specs at every render site, a built-client-graph snapshot covering both chat-row shapes, and a real-browser e2e asserting the no-wrap layout and the page's own Clipboard API.
This commit is contained in:
@@ -71,6 +71,11 @@ describe('tool-call-model', () => {
|
||||
expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
|
||||
})
|
||||
|
||||
it('a code row with an empty program falls back to the args JSON envelope', () => {
|
||||
expect(toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' })).body)
|
||||
.toBe('{\n "code": ""\n}')
|
||||
})
|
||||
|
||||
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
|
||||
expect(toolRowModel('cordis_inspect', running({
|
||||
name: 'cordis_inspect',
|
||||
@@ -139,6 +144,22 @@ describe('ToolRow', () => {
|
||||
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
|
||||
const view = render(<ToolRow {...rowProps} expandOnRowClick />)
|
||||
const row = view.getByRole('button')
|
||||
fireEvent.keyDown(row, { key: 'Tab' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(row, { key: ' ' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('a non-expandable expandOnRowClick row exposes no row button', () => {
|
||||
const view = render(<ToolRow {...rowProps} body={null} expandOnRowClick />)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('row click hands off to onOpenDetails; the expand toggle does not', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(<ToolRow {...rowProps} onOpenDetails={open} />)
|
||||
|
||||
359
packages/client/ui-conversation/tests/terminal-card.spec.tsx
Normal file
359
packages/client/ui-conversation/tests/terminal-card.spec.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
// @vitest-environment jsdom
|
||||
// The terminal render intent on the web side: the pure terminalCardModel
|
||||
// derivation over callView/resultView, and both conversation render sites that
|
||||
// consume it — the chat tool row's expanded body (GenericToolCard / BashRow)
|
||||
// and the details panel's Output section.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/**
|
||||
* Match an output line with its interior whitespace intact: the column
|
||||
* alignment this card exists to preserve is exactly what the default
|
||||
* whitespace-collapsing matcher would hide.
|
||||
*/
|
||||
const RAW = { normalizer: (text: string) => text }
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
const ARGS = '{"command":"ls -la","description":"List files"}'
|
||||
|
||||
/** The bash tool's own call view for a foreground command. */
|
||||
const callTerminal = (over?: Partial<Extract<ToolCallView, { card: 'terminal' }>>): ToolCallView => ({
|
||||
card: 'terminal', title: 'ls -la', description: 'List files', ...over,
|
||||
})
|
||||
|
||||
/** The bash tool's own result view for a settled foreground command. */
|
||||
const resultTerminal = (over?: Partial<Extract<ToolResultView, { card: 'terminal' }>>): ToolResultView => ({
|
||||
card: 'terminal', output: 'a.ts b.ts\nc.ts d.ts\n', exitCode: 0, ...over,
|
||||
})
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'bash', argsRaw: ARGS,
|
||||
turn: 1, step: 1, time: 1_000, callView: callTerminal(), ...over,
|
||||
})
|
||||
|
||||
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false,
|
||||
callView: callTerminal(), resultView: resultTerminal(), ...over,
|
||||
})
|
||||
|
||||
describe('terminalCardModel', () => {
|
||||
it('derives a running card from the call view alone', () => {
|
||||
expect(terminalCardModel(running({ callView: callTerminal({ cwd: '/projects/app' }) }))).toEqual({
|
||||
command: 'ls -la', cwd: '/projects/app', output: undefined,
|
||||
exitCode: undefined, signal: undefined, running: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('derives a settled card from both sides, carrying the exit status', () => {
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '/projects/app' }),
|
||||
resultView: resultTerminal({ output: 'boom\n', exitCode: 2 }),
|
||||
}))).toEqual({
|
||||
command: 'ls -la', cwd: '/projects/app', output: 'boom\n',
|
||||
exitCode: 2, signal: undefined, running: false,
|
||||
})
|
||||
expect(terminalCardModel(settled({
|
||||
resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
|
||||
}))?.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('a window-truncated call side falls back to the result title, then to an empty command', () => {
|
||||
// Truncation drops both the call head and its view (conversation.ts).
|
||||
const truncated = { call: null, callView: null }
|
||||
expect(terminalCardModel(settled({
|
||||
...truncated, resultView: resultTerminal({ title: 'ls -la' }),
|
||||
}))).toMatchObject({ command: 'ls -la', cwd: undefined, running: false })
|
||||
expect(terminalCardModel(settled(truncated))).toMatchObject({ command: '', cwd: undefined })
|
||||
})
|
||||
|
||||
it('returns null for every non-terminal call: no views, generic views, unknown cards', () => {
|
||||
expect(terminalCardModel(running({ callView: null }))).toBeNull()
|
||||
expect(terminalCardModel(settled({ callView: null, resultView: null }))).toBeNull()
|
||||
expect(terminalCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull()
|
||||
// A generic result settles a terminal call as a generic card (the bash
|
||||
// tool's own execution-error and background paths).
|
||||
expect(terminalCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
|
||||
// A card tag this UI version does not know arrives over the wire; the
|
||||
// documented generic-card default takes it, not a crash.
|
||||
const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView
|
||||
expect(terminalCardModel(running({ callView: future }))).toBeNull()
|
||||
expect(terminalCardModel(settled({
|
||||
callView: future, resultView: { card: 'chart' } as unknown as ToolResultView,
|
||||
}))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('chat row terminal body', () => {
|
||||
const ownerProps = (block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
|
||||
})
|
||||
|
||||
it('the expanded body is the command output, capped tighter than the panel', () => {
|
||||
expect(CHAT_TERMINAL_MAX_LINES).toBeLessThan(16)
|
||||
const view = render(<GenericToolCard {...ownerProps(settled())} />)
|
||||
// Collapsed: the one-line summary row only, no output.
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
// The args JSON body the generic path would have shown is gone.
|
||||
expect(view.queryByText(/"command"/)).toBeNull()
|
||||
})
|
||||
|
||||
it('the cap collapses a long output inside the row, expandable in place', () => {
|
||||
const lines = Array.from({ length: CHAT_TERMINAL_MAX_LINES + 3 }, (_, i) => `line-${i}`)
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
resultView: resultTerminal({ output: `${lines.join('\n')}\n` }),
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('… 其余 3 行')).toBeTruthy()
|
||||
expect(view.queryByText('line-5')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: '展开其余 3 行输出' }))
|
||||
expect(view.getByText('line-5')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running terminal call expands to the prompt line with no output yet', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(running())} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
expect(view.queryByText('复制')).toBeNull()
|
||||
})
|
||||
|
||||
it('a non-terminal call keeps the args-JSON text body', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: null, resultView: null,
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText(/"command"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a terminal call with no args still expands, through its terminal body alone', () => {
|
||||
// Empty args make the text body null; the terminal material carries the row.
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
call: { name: 'bash', argsRaw: '' },
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashRow terminal card', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode, openDetails = vi.fn()): ToolRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openDetails,
|
||||
sessionId: SID, useSessions: bindSnapshotSelector(list()),
|
||||
} as unknown as ToolRowProps)
|
||||
|
||||
it('renders the command output under the summary row, without an expand gesture', () => {
|
||||
const openDetails = vi.fn()
|
||||
const view = render(<BashRow {...rowProps(settled(), openDetails)} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
// The terminal card sits outside the row's click target: copying does not
|
||||
// open the details panel.
|
||||
fireEvent.click(view.getByText('复制'))
|
||||
expect(openDetails).not.toHaveBeenCalled()
|
||||
fireEvent.click(view.getByText('List files'))
|
||||
expect(openDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a non-terminal bash call (background start) renders the summary row alone', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
callView: { card: 'generic', title: 'sleep 30', kind: 'execute' },
|
||||
resultView: { card: 'generic' },
|
||||
}))} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel Output section', () => {
|
||||
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) {
|
||||
localStorage.clear()
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(sessions)}
|
||||
useWorkspaces={bindSnapshotSelector(workspaces)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, ...over,
|
||||
}
|
||||
}
|
||||
|
||||
const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'bash' }
|
||||
|
||||
it('renders the terminal card at full height, keeping the JSON Input section', () => {
|
||||
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
|
||||
}), target)
|
||||
expect(view.getByText(/"command"/)).toBeTruthy()
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
// The panel takes the primitive's own default cap (16), not the row's.
|
||||
expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy()
|
||||
expect(view.getByText('row-0')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running terminal call shows the prompt line, not the 运行中… placeholder', () => {
|
||||
const view = mount(snapshot({ runningCalls: [running()] }), target)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
expect(view.queryByText('运行中…')).toBeNull()
|
||||
})
|
||||
|
||||
it('a running non-terminal call keeps the 运行中… placeholder', () => {
|
||||
const view = mount(snapshot({ runningCalls: [running({ callView: null })] }), target)
|
||||
expect(view.getByText('运行中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a non-terminal result keeps the flattened pre with its error styling', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null, isError: true,
|
||||
content: [{ type: 'text', text: 'permission denied' }],
|
||||
})],
|
||||
}), target)
|
||||
const pre = view.container.querySelector('pre[data-error]')
|
||||
expect(pre?.textContent).toBe('permission denied')
|
||||
})
|
||||
|
||||
it('a run_code sub-dispatch resolves to its own terminal card', () => {
|
||||
const view = mount(snapshot({
|
||||
codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]),
|
||||
}), target)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running run_code sub-dispatch resolves through the running material', () => {
|
||||
const view = mount(snapshot({
|
||||
// The leading non-matching sub-call exercises the scan's skip.
|
||||
codeDispatches: new Map([['p1', [running({ callId: 'other' }), running()]]]),
|
||||
}), target)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a window-truncated call head titles the panel by callId and drops the Input section', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }) })],
|
||||
}), target)
|
||||
expect(view.getByText('c1')).toBeTruthy()
|
||||
expect(view.queryByText('Input')).toBeNull()
|
||||
expect(view.getByText('Output')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('scans past other nodes and other calls before reporting the call out of window', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [
|
||||
{ kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [] },
|
||||
settled({ callId: 'elsewhere' }),
|
||||
],
|
||||
runningCalls: [running({ callId: 'also-elsewhere' })],
|
||||
}), target)
|
||||
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('no selection at all renders the guidance line and the default title', () => {
|
||||
const view = mount(snapshot(), null)
|
||||
expect(view.getByText('详情')).toBeTruthy()
|
||||
expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a step selection without a callId renders the guidance line too', () => {
|
||||
const view = mount(snapshot(), { turnSeq: 3, stepSeq: 1 })
|
||||
expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the close button reaches closeDetails', () => {
|
||||
localStorage.clear()
|
||||
const chat = createChatStore().create()
|
||||
const closeDetails = vi.fn()
|
||||
const snap = snapshot()
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' }))}
|
||||
useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}))}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={closeDetails}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '关闭详情' }))
|
||||
expect(closeDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a non-text result block renders as JSON, and an empty result falls back to its error', () => {
|
||||
const nonText = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null,
|
||||
content: [{ type: 'reasoning', text: 'why' }],
|
||||
})],
|
||||
}), target)
|
||||
// Scope to the Output section: the Input section's CodeBlock renders a
|
||||
// <pre> of its own, and it comes first in document order.
|
||||
expect(nonText.getByText('Output').closest('section')?.querySelector('pre')?.textContent)
|
||||
.toBe('{\n "type": "reasoning",\n "text": "why"\n}')
|
||||
cleanup()
|
||||
const empty = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null, content: [], isError: true,
|
||||
error: { name: 'ToolError', code: 'interrupted' },
|
||||
})],
|
||||
}), target)
|
||||
expect(empty.getByText('ToolError: interrupted')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user