Files
qwen-code/packages/web-shell/client/components/artifacts/tool-call-content.tsx
T
Sergey BessonovandShaojin Wen 588db8b137 feat(web-shell): show tool arguments and result in expanded cards (#15435)
* feat(web-shell): show tool arguments and result in expanded cards

Cards outside the built-in detail renderers expanded to the same 80-character result summary shown while collapsed, so the chevron promised details it never delivered. Render the call's arguments and result instead, pretty-printing a JSON object or array and falling back to plain text, with the trajectory inspector's 4000/40 000 caps and a Show more action.

Fixes #15280

* fix(web-shell): address tool card details review findings

* test(web-shell): cover tool card review findings

* fix(web-shell): preserve tool card JSON payload fidelity

* fix(web-shell): sanitize tool payloads and cancelled results

* fix(web-shell): bound tool payloads by render mode

* fix(web-shell): memoize tool card payload formatting

* fix(web-shell): size tool card JSON by render cap and scope its rules

* fix(web-templates): raise document export budget for tool cards

* fix(web-shell): address round 3 tool card review suggestions

* fix(web-shell): gate Show more on escaped payload length

* fix(web-shell): stop rendering the cancelled input dump as a result

A cancelled call that recorded no output carries the daemon's redacted input dump in rawOutput.text, written there as the same string as reason. cancelledOutputText accepted it as output, so the expanded card showed the call's own arguments twice, once as Result. Skip a text candidate that equals reason while keeping output/stdout.

* test(web-shell): pin the arguments escape guard and the inline cap

Both behaviours were unpinned: replacing the arguments-side sanitized-length comparison with the raw length, or lowering MAX_TOOL_PAYLOAD_LENGTH to 400, left the suites green. JSON.stringify escapes C0 bytes but not the bidi controls, so an object argument carrying U+202E is the reachable shape where escaping moves the payload past the cap.

---------

Co-authored-by: Shaojin Wen <[email protected]>
2026-11-07 23:38:51 +08:00

132 lines
3.8 KiB
TypeScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useMemo } from 'react';
import type { DaemonToolTranscriptBlock } from '@qwen-code/sdk/daemon';
import { isShellResultDisplay } from '@qwen-code/sdk/daemon';
import type { ACPToolCall } from '../../adapters/types';
import {
extractText,
isShellToolName,
MAX_TOOL_PAYLOAD_LENGTH,
sanitizeControlChars,
stringifyValue,
truncateText,
} from '../messages/toolFormatting';
import { extractDiff, fencedCodeBlock } from '../messages/ToolGroup';
import { Markdown } from '../messages/Markdown';
import { parseShellLiveOutput } from '../messages/tools/shellLiveOutput';
import styles from './tool-call-content.module.css';
/** The longest stretch of one argument or result put in the DOM by default. */
export const MAX_DETAIL_LENGTH = MAX_TOOL_PAYLOAD_LENGTH;
export { stringifyValue };
const EDIT_TOOL = /^(edit|editfile|write|write_file|writefile)$/;
/** Valid JSON renders as a code block; anything else stays plain text. */
export function ToolCallDetail({
text,
limit = MAX_DETAIL_LENGTH,
}: {
text: string;
limit?: number;
}) {
const json = useMemo(() => {
try {
JSON.parse(text);
return text;
} catch {
return undefined;
}
}, [text]);
const displayed = truncateText(json ?? text, limit);
return json !== undefined ? (
<div className={styles.json}>
<Markdown content={fencedCodeBlock('json', displayed)} />
</div>
) : (
<pre className={styles.detailBody}>{displayed}</pre>
);
}
export function shellDetails(tool: ACPToolCall) {
const raw = tool.rawOutput;
const result = isShellResultDisplay(raw) ? raw : undefined;
const live = parseShellLiveOutput(raw);
const command =
typeof tool.args?.command === 'string' ? tool.args.command : '';
const unknownVersion =
raw &&
typeof raw === 'object' &&
'type' in raw &&
raw.type === 'shell_result' &&
!result;
const output =
result?.output ??
(live
? (live.segments?.map((segment) => segment.text).join('') ?? '')
: typeof raw === 'string'
? raw
: unknownVersion
? 'text' in raw && typeof raw.text === 'string'
? raw.text
: stringifyValue(raw)
: (extractText(tool) ?? stringifyValue(raw)));
const args = Object.fromEntries(
Object.entries(tool.args ?? {}).filter(([key]) => key !== 'command'),
);
const metadata = result
? Object.fromEntries(
Object.entries(result).filter(
([key]) => !['type', 'version', 'text', 'output'].includes(key),
),
)
: live
? Object.fromEntries(
Object.entries(live).filter(
([key, value]) => key !== 'segments' && value !== undefined,
),
)
: undefined;
const other = {
...(Object.keys(args).length ? { arguments: args } : {}),
...(metadata && Object.keys(metadata).length ? { result: metadata } : {}),
};
return {
command: sanitizeControlChars(command),
output: sanitizeControlChars(output),
other: Object.keys(other).length
? truncateText(stringifyValue(other), MAX_DETAIL_LENGTH)
: '',
};
}
/**
* What a tool call's arguments and result read as: a shell call as its command
* and output, an edit or write as its diff, anything else as recorded.
*/
export function toolCallContent(
tool: ACPToolCall,
block: Pick<DaemonToolTranscriptBlock, 'rawInput' | 'rawOutput' | 'content'>,
) {
const diff = EDIT_TOOL.test(tool.toolName.toLowerCase())
? extractDiff(tool)
: '';
const shell = isShellToolName(tool.toolName) ? shellDetails(tool) : undefined;
return {
diff,
shell,
argumentsText: shell ? shell.command : stringifyValue(block.rawInput),
resultText: diff
? ''
: shell
? shell.output
: stringifyValue(block.rawOutput ?? block.content),
};
}