Skip to content

Commit 0856a2f

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 86cd62f commit 0856a2f

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
@@ -45,12 +45,24 @@ export function createDaemonToolPreview(
4545
return { kind: 'ask_user_question', questions: askUserQuestions };
4646
}
4747

48-
// PR-C: try specific tool-shape detectors before falling back to
48+
// PR-C / PR-F: try specific tool-shape detectors before falling back to
4949
// generic command / key_value detection. Detector order matters —
5050
// most specific wins.
5151
const mcpPreview = detectMcpInvocation(input, opts);
5252
if (mcpPreview) return mcpPreview;
5353

54+
// PR-F detectors (subagent_delegation / search / image_generation
55+
// before file_diff because some sub-agent tool calls embed file ops
56+
// in their payload — provenance still wins for MCP though).
57+
const subagent = detectSubagentDelegation(input, opts);
58+
if (subagent) return subagent;
59+
60+
const search = detectSearch(input, opts);
61+
if (search) return search;
62+
63+
const imageGeneration = detectImageGeneration(input, opts);
64+
if (imageGeneration) return imageGeneration;
65+
5466
const fileDiff = detectFileDiff(input);
5567
if (fileDiff) return fileDiff;
5668

@@ -60,6 +72,12 @@ export function createDaemonToolPreview(
6072
const webFetch = detectWebFetch(input);
6173
if (webFetch) return webFetch;
6274

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

0 commit comments

Comments
 (0)