Skip to content

Commit 684810c

Browse files
秦奇claude
andcommitted
feat(sdk/daemon-ui): 5 additional tool preview kinds — taxonomy complete (PR-F)
Closes the "5 additional preview kinds" item in PR QwenLM#4353's TODO §A (SDK-only work). ## New preview kinds (8 → 13) - `code_block` — `{ language?, code, origin? }` — REPL / formatter / generator output, fenced as `\`\`\`<language>` in markdown - `search` — `{ query, resultCount?, top? }` — grep / ripgrep / find / glob results with up to 5 top hits - `tabular` — `{ columns, rows, totalRows? }` — structured table output (50-row cap with `totalRows` truncation indicator); supports both `columns: string[] + rows: unknown[][]` explicit shape and legacy `data: Array<Record<>>` shape (auto-infers columns from first row) - `image_generation` — `{ prompt, thumbnailUrl?, model? }` — dall-e / diffusion / imagen / flux / sora style tools - `subagent_delegation` — `{ agentName, task, parentDelegationId? }` — Anthropic-style Task tool and similar sub-agent dispatchers ## Detector priority Order matters — most specific wins. New detectors slot in between `mcp_invocation` and `file_diff`: ``` mcp_invocation > subagent_delegation > search > image_generation > file_diff > file_read > web_fetch > code_block > tabular > command > key_value > generic ``` Rationale: subagent / search / image generation are most discriminable (distinct toolName patterns); file ops next; code_block / tabular last because their shapes (`code:`, `columns:`) can appear in other tools. ## Render projections Both `daemonToolPreviewToMarkdown` and the plain-text rendering paths extended with cases for all 5 new kinds: - code_block: fenced markdown code block with language tag - search: bold header + GFM bullet list of top results - tabular: GFM pipe table with header / separator / body / truncation hint - image_generation: bold header + blockquoted prompt + embedded markdown image (URL sanitization respected via `sanitizeUrls` opt) - subagent_delegation: bold delegate-arrow header + blockquoted task + optional parent delegation reference ## Test coverage (91/91 pass, +14 new) - Each detector with positive case - Detector priority verified: subagent_delegation wins over file_diff when toolName='Task' has both subagent + file-edit fields - Tabular row cap (50) + totalRows stamping for truncated data - Legacy data: Array<Record<>> auto-column inference - Each render projection with structural assertions (markdown table format, image embed, bullet lists) ## Roadmap PR-F of the unified follow-up to PR QwenLM#4328. Brings the preview taxonomy to 13 kinds covering: file ops (3), web (1), code/data (2), media (1), agent control (2 — ask_user_question + subagent_delegation), MCP (1), search (1), generic fallbacks (2). Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f40c3e1 commit 684810c

4 files changed

Lines changed: 604 additions & 1 deletion

File tree

packages/sdk-typescript/src/daemon/ui/render.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,69 @@ export function daemonToolPreviewToMarkdown(
175175
]
176176
.filter(Boolean)
177177
.join('\n');
178+
case 'code_block':
179+
return [
180+
preview.origin ? `_${preview.origin}_` : null,
181+
'```' + (preview.language ?? ''),
182+
cap(preview.code),
183+
'```',
184+
]
185+
.filter(Boolean)
186+
.join('\n');
187+
case 'search': {
188+
const lines = [
189+
`**Search** \`${preview.query}\``,
190+
preview.resultCount !== undefined
191+
? `_${preview.resultCount} result${preview.resultCount === 1 ? '' : 's'}_`
192+
: null,
193+
];
194+
if (preview.top && preview.top.length > 0) {
195+
for (const result of preview.top) {
196+
lines.push(`- ${result}`);
197+
}
198+
}
199+
return lines.filter(Boolean).join('\n');
200+
}
201+
case 'tabular': {
202+
if (preview.columns.length === 0) return '_(empty table)_';
203+
const headerRow = `| ${preview.columns.join(' | ')} |`;
204+
const sepRow = `| ${preview.columns.map(() => '---').join(' | ')} |`;
205+
const bodyRows = preview.rows.map(
206+
(row) =>
207+
`| ${preview.columns
208+
.map((_, idx) => cap(String(row[idx] ?? '')).replace(/\|/g, '\\|'))
209+
.join(' | ')} |`,
210+
);
211+
const lines = [headerRow, sepRow, ...bodyRows];
212+
if (preview.totalRows !== undefined && preview.totalRows > preview.rows.length) {
213+
lines.push(
214+
`_… ${preview.totalRows - preview.rows.length} more row(s) not shown_`,
215+
);
216+
}
217+
return lines.join('\n');
218+
}
219+
case 'image_generation':
220+
return [
221+
`**Image generation**`,
222+
`> ${cap(preview.prompt)}`,
223+
preview.model ? `_model: ${preview.model}_` : null,
224+
preview.thumbnailUrl
225+
? `![image](${opts.sanitizeUrls ? sanitizeUrl(preview.thumbnailUrl) : preview.thumbnailUrl})`
226+
: null,
227+
]
228+
.filter(Boolean)
229+
.join('\n');
230+
case 'subagent_delegation':
231+
return [
232+
`**Delegate → \`${preview.agentName}\`**`,
233+
'',
234+
`> ${cap(preview.task)}`,
235+
preview.parentDelegationId
236+
? `_(chained from ${preview.parentDelegationId})_`
237+
: null,
238+
]
239+
.filter(Boolean)
240+
.join('\n');
178241
case 'key_value':
179242
return preview.rows
180243
.map((row) => `- **${row.label}:** ${cap(row.value)}`)
@@ -273,6 +336,33 @@ function daemonToolPreviewToPlainText(preview: DaemonToolPreview): string {
273336
return `${preview.method ?? 'GET'} ${preview.url}`;
274337
case 'mcp_invocation':
275338
return `${preview.serverId}::${preview.toolName}${preview.argsSummary ? ` (${preview.argsSummary})` : ''}`;
339+
case 'code_block':
340+
return preview.origin ? `[${preview.origin}]\n${preview.code}` : preview.code;
341+
case 'search':
342+
return [
343+
`search: ${preview.query}`,
344+
preview.resultCount !== undefined ? `(${preview.resultCount} results)` : null,
345+
...(preview.top ?? []).map((r) => ` ${r}`),
346+
]
347+
.filter(Boolean)
348+
.join('\n');
349+
case 'tabular': {
350+
if (preview.columns.length === 0) return '(empty table)';
351+
const lines = [preview.columns.join('\t')];
352+
for (const row of preview.rows) {
353+
lines.push(
354+
preview.columns.map((_, idx) => String(row[idx] ?? '')).join('\t'),
355+
);
356+
}
357+
if (preview.totalRows !== undefined && preview.totalRows > preview.rows.length) {
358+
lines.push(`... ${preview.totalRows - preview.rows.length} more row(s)`);
359+
}
360+
return lines.join('\n');
361+
}
362+
case 'image_generation':
363+
return `image: "${preview.prompt}"${preview.model ? ` (${preview.model})` : ''}`;
364+
case 'subagent_delegation':
365+
return `delegate to ${preview.agentName}: ${preview.task}`;
276366
case 'key_value':
277367
return preview.rows.map((r) => `${r.label}: ${r.value}`).join('\n');
278368
case 'generic':

packages/sdk-typescript/src/daemon/ui/toolPreview.ts

Lines changed: 247 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,24 @@ export function createDaemonToolPreview(
5050
return { kind: 'ask_user_question', questions: askUserQuestions };
5151
}
5252

53-
// PR-C: try specific tool-shape detectors before falling back to
53+
// PR-C / PR-F: try specific tool-shape detectors before falling back to
5454
// generic command / key_value detection. Detector order matters —
5555
// most specific wins.
5656
const mcpPreview = detectMcpInvocation(input, opts);
5757
if (mcpPreview) return mcpPreview;
5858

59+
// PR-F detectors (subagent_delegation / search / image_generation
60+
// before file_diff because some sub-agent tool calls embed file ops
61+
// in their payload — provenance still wins for MCP though).
62+
const subagent = detectSubagentDelegation(input, opts);
63+
if (subagent) return subagent;
64+
65+
const search = detectSearch(input, opts);
66+
if (search) return search;
67+
68+
const imageGeneration = detectImageGeneration(input, opts);
69+
if (imageGeneration) return imageGeneration;
70+
5971
const fileDiff = detectFileDiff(input);
6072
if (fileDiff) return fileDiff;
6173

@@ -65,6 +77,12 @@ export function createDaemonToolPreview(
6577
const webFetch = detectWebFetch(input);
6678
if (webFetch) return webFetch;
6779

80+
const codeBlock = detectCodeBlock(input, opts);
81+
if (codeBlock) return codeBlock;
82+
83+
const tabular = detectTabular(input);
84+
if (tabular) return tabular;
85+
6886
if (isRecord(input)) {
6987
const command = getFirstString(input, ['command', 'cmd']);
7088
if (command) {
@@ -292,3 +310,231 @@ function collectPreviewRows(
292310
}
293311
return rows;
294312
}
313+
314+
/* ──────────────────────────────────────────────────────────────────────────
315+
* PR-F detectors — long-tail preview kinds
316+
* ──────────────────────────────────────────────────────────────────────── */
317+
318+
const MAX_TABULAR_ROWS = 50;
319+
const MAX_SEARCH_TOP_RESULTS = 5;
320+
321+
/**
322+
* Detect sub-agent delegation. Matches toolName containing "delegate" /
323+
* "subagent" / "spawn-task" / "Task" (Anthropic-style) plus an explicit
324+
* agent name or prompt-like field.
325+
*/
326+
function detectSubagentDelegation(
327+
input: unknown,
328+
opts: { title?: string; toolName?: string; toolKind?: string },
329+
): DaemonToolPreview | undefined {
330+
const toolName = opts.toolName ?? '';
331+
const looksLikeDelegate =
332+
/(?:^|_)(?:delegate|subagent|spawn[_-]?task|task)$/i.test(toolName) ||
333+
/agent/i.test(opts.toolKind ?? '');
334+
if (!looksLikeDelegate) return undefined;
335+
if (!isRecord(input)) return undefined;
336+
const agentName = getFirstString(input, [
337+
'subagent_type',
338+
'agent',
339+
'agentName',
340+
'agent_name',
341+
'subagent',
342+
]);
343+
const task = getFirstString(input, [
344+
'prompt',
345+
'task',
346+
'description',
347+
'instruction',
348+
'query',
349+
]);
350+
if (!agentName && !task) return undefined;
351+
const parentDelegationId = getFirstString(input, [
352+
'parentDelegationId',
353+
'parent_delegation_id',
354+
'parent_id',
355+
]);
356+
return {
357+
kind: 'subagent_delegation',
358+
agentName: agentName ?? 'subagent',
359+
task: task ?? '(no task description)',
360+
...(parentDelegationId ? { parentDelegationId } : {}),
361+
};
362+
}
363+
364+
/**
365+
* Detect search / grep tools. Requires a `query` / `pattern` / `search`
366+
* field plus tool name hinting at search, OR an explicit result count.
367+
*/
368+
function detectSearch(
369+
input: unknown,
370+
opts: { title?: string; toolName?: string; toolKind?: string },
371+
): DaemonToolPreview | undefined {
372+
if (!isRecord(input)) return undefined;
373+
const query = getFirstString(input, ['query', 'pattern', 'search', 'q']);
374+
if (!query) return undefined;
375+
const toolName = opts.toolName ?? '';
376+
const looksLikeSearch =
377+
/(grep|search|find|ripgrep|rg|glob|lookup)/i.test(toolName) ||
378+
typeof input['resultCount'] === 'number' ||
379+
Array.isArray(input['results']) ||
380+
Array.isArray(input['matches']);
381+
if (!looksLikeSearch) return undefined;
382+
const resultCount =
383+
typeof input['resultCount'] === 'number'
384+
? (input['resultCount'] as number)
385+
: typeof input['total'] === 'number'
386+
? (input['total'] as number)
387+
: Array.isArray(input['results'])
388+
? (input['results'] as unknown[]).length
389+
: Array.isArray(input['matches'])
390+
? (input['matches'] as unknown[]).length
391+
: undefined;
392+
let top: string[] | undefined;
393+
const rawResults =
394+
(input['results'] as unknown) ?? (input['matches'] as unknown);
395+
if (Array.isArray(rawResults)) {
396+
top = rawResults
397+
.slice(0, MAX_SEARCH_TOP_RESULTS)
398+
.map((item) => {
399+
if (typeof item === 'string') return item;
400+
if (isRecord(item)) {
401+
return (
402+
getFirstString(item, ['path', 'file', 'name', 'title', 'text']) ??
403+
stringifyJson(item).slice(0, 120)
404+
);
405+
}
406+
return String(item);
407+
})
408+
.filter(Boolean);
409+
if (top.length === 0) top = undefined;
410+
}
411+
return {
412+
kind: 'search',
413+
query,
414+
...(resultCount !== undefined ? { resultCount } : {}),
415+
...(top ? { top } : {}),
416+
};
417+
}
418+
419+
/**
420+
* Detect image generation tools. Matches toolName like `image` / `diffusion`
421+
* / `dalle` / `imagen` / `flux` plus a `prompt` field.
422+
*/
423+
function detectImageGeneration(
424+
input: unknown,
425+
opts: { title?: string; toolName?: string; toolKind?: string },
426+
): DaemonToolPreview | undefined {
427+
const toolName = opts.toolName ?? '';
428+
const looksLikeImageGen =
429+
/(image[_-]?gen|generate[_-]?image|diffusion|dalle|imagen|flux|stable[_-]?diffusion|midjourney|sora)/i.test(
430+
toolName,
431+
);
432+
if (!looksLikeImageGen) return undefined;
433+
if (!isRecord(input)) return undefined;
434+
const prompt = getFirstString(input, ['prompt', 'description', 'query']);
435+
if (!prompt) return undefined;
436+
const thumbnailUrl = getFirstString(input, [
437+
'thumbnailUrl',
438+
'thumbnail',
439+
'url',
440+
'imageUrl',
441+
'preview',
442+
]);
443+
const model = getFirstString(input, ['model', 'modelId', 'model_name']);
444+
return {
445+
kind: 'image_generation',
446+
prompt,
447+
...(thumbnailUrl ? { thumbnailUrl } : {}),
448+
...(model ? { model } : {}),
449+
};
450+
}
451+
452+
/**
453+
* Detect code-block style output. Matches an explicit `code` / `language`
454+
* pair (often used by REPL / formatter / generator tools), or a `language`
455+
* + `text` combo. Heuristic-only — falls through when ambiguous.
456+
*/
457+
function detectCodeBlock(
458+
input: unknown,
459+
opts: { title?: string; toolName?: string; toolKind?: string },
460+
): DaemonToolPreview | undefined {
461+
if (!isRecord(input)) return undefined;
462+
const code = getFirstString(input, ['code', 'snippet', 'source']);
463+
if (!code) return undefined;
464+
const language = getFirstString(input, [
465+
'language',
466+
'lang',
467+
'codeLanguage',
468+
'syntax',
469+
]);
470+
// Require either an explicit language OR a tool name that suggests
471+
// code (formatter/repl/generator) to avoid grabbing every `code: '...'`
472+
// field on unrelated tools.
473+
const toolName = opts.toolName ?? '';
474+
const codeTool = /(repl|format|prettier|eslint|tsc|compile|exec[_-]?code)/i.test(
475+
toolName,
476+
);
477+
if (!language && !codeTool) return undefined;
478+
const origin = getFirstString(input, ['origin', 'source_location', 'path']);
479+
return {
480+
kind: 'code_block',
481+
code,
482+
...(language ? { language } : {}),
483+
...(origin ? { origin } : {}),
484+
};
485+
}
486+
487+
/**
488+
* Detect tabular output. Matches `columns: string[]` + `rows: unknown[][]`
489+
* exact shape, or `data: Array<Record<string, unknown>>` legacy shape.
490+
*/
491+
function detectTabular(input: unknown): DaemonToolPreview | undefined {
492+
if (!isRecord(input)) return undefined;
493+
// Strict shape: columns + rows
494+
const explicitColumns = input['columns'];
495+
const explicitRows = input['rows'];
496+
if (Array.isArray(explicitColumns) && Array.isArray(explicitRows)) {
497+
const columns = explicitColumns
498+
.filter((c): c is string => typeof c === 'string')
499+
.slice(0, 30);
500+
if (columns.length === 0) return undefined;
501+
const rows = explicitRows
502+
.slice(0, MAX_TABULAR_ROWS)
503+
.map((row) =>
504+
Array.isArray(row)
505+
? row.map((cell) =>
506+
typeof cell === 'string' ? cell : stringifyJson(cell).slice(0, 80),
507+
)
508+
: [],
509+
);
510+
return {
511+
kind: 'tabular',
512+
columns,
513+
rows,
514+
...(explicitRows.length > rows.length
515+
? { totalRows: explicitRows.length }
516+
: {}),
517+
};
518+
}
519+
// Legacy shape: array of objects (each row a record). Infer columns from
520+
// the first row's keys.
521+
const data = input['data'] ?? input['records'];
522+
if (Array.isArray(data) && data.length > 0 && isRecord(data[0])) {
523+
const columns = Object.keys(data[0] as Record<string, unknown>).slice(0, 30);
524+
if (columns.length === 0) return undefined;
525+
const rows = data.slice(0, MAX_TABULAR_ROWS).map((row) => {
526+
const r = row as Record<string, unknown>;
527+
return columns.map((col) => {
528+
const v = r[col];
529+
return typeof v === 'string' ? v : stringifyJson(v).slice(0, 80);
530+
});
531+
});
532+
return {
533+
kind: 'tabular',
534+
columns,
535+
rows,
536+
...(data.length > rows.length ? { totalRows: data.length } : {}),
537+
};
538+
}
539+
return undefined;
540+
}

0 commit comments

Comments
 (0)