diff --git a/docs/images/pr/capability-loading-after.png b/docs/images/pr/capability-loading-after.png new file mode 100644 index 0000000000..c68bf6afe0 Binary files /dev/null and b/docs/images/pr/capability-loading-after.png differ diff --git a/docs/images/pr/capability-loading-before.png b/docs/images/pr/capability-loading-before.png new file mode 100644 index 0000000000..602b4e1014 Binary files /dev/null and b/docs/images/pr/capability-loading-before.png differ diff --git a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts index 74a0ee272e..88741551a0 100644 --- a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts @@ -217,7 +217,14 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' const capabilityToolNames = [tool.name, rejectedTool.name].sort((left, right) => left.localeCompare(right), ); - assert.deepEqual(loaded, { loaded: capabilityToolNames }); + assert.deepEqual(loaded, { + loaded: capabilityToolNames, + group: { + id: group.id, + label: 'Unknown fixture', + description: 'A capability the Host source does not enumerate.', + }, + }); assert.deepEqual( availability.projectActiveTools?.({ completedSteps: [ diff --git a/packages/runtime/src/__tests__/tool-availability.test.ts b/packages/runtime/src/__tests__/tool-availability.test.ts index 1c4fcbf7e4..26edfb3478 100644 --- a/packages/runtime/src/__tests__/tool-availability.test.ts +++ b/packages/runtime/src/__tests__/tool-availability.test.ts @@ -119,6 +119,38 @@ describe('ToolAvailabilityRuntime — economy mode', () => { assert.ok(connector); await assert.rejects(async () => connector!.impl({ group: 'nope' }, ctx), /Unknown tool group/); }); + + test('connector returns presentation metadata with the loaded tool ids', async () => { + const connector = runtime(true) + .prepare([]) + .providerTools.find((t) => t.name === LOAD_TOOLS_NAME); + assert.ok(connector); + assert.deepEqual(await connector.impl({ group: 'rive' }, ctx), { + loaded: ['rive_run'], + group: { + id: 'rive', + label: 'Rive', + }, + }); + assert.deepEqual(await connector.impl({ group: 'docs' }, ctx), { + loaded: ['docs_edit', 'docs_read'], + group: { + id: 'docs', + description: 'Document tools', + }, + }); + assert.deepEqual( + await connector.toModelOutput?.({ + toolCallId: 'tc', + input: { group: 'rive' }, + output: await connector.impl({ group: 'rive' }, ctx), + }), + { + type: 'json', + value: { loaded: ['rive_run'] }, + }, + ); + }); }); describe('ToolAvailabilityRuntime — durable ledger seed', () => { diff --git a/packages/runtime/src/tool-availability.ts b/packages/runtime/src/tool-availability.ts index 0bde180a5b..1298acebda 100644 --- a/packages/runtime/src/tool-availability.ts +++ b/packages/runtime/src/tool-availability.ts @@ -69,6 +69,17 @@ export interface ToolAvailabilityConfig { groups?: readonly ToolGroup[]; } +export interface LoadedToolGroup { + readonly id: string; + readonly label?: string; + readonly description?: string; +} + +export interface LoadToolsResult { + readonly loaded: string[]; + readonly group: LoadedToolGroup; +} + export function toolAvailabilityHash(config: ToolAvailabilityConfig): `sha256:${string}` { return stableHash({ economy: config.economy, @@ -272,7 +283,7 @@ export class ToolAvailabilityRuntime { return out; } - private buildConnector(): MakaTool<{ group: string }, { loaded: string[] }> { + private buildConnector(): MakaTool<{ group: string }, LoadToolsResult> { // Only reached when economy is on, which requires at least one gated group, // so `ids` is always non-empty — a plain enum, no empty fallback. const ids = this.groups.map((group) => group.id); @@ -289,8 +300,23 @@ export class ToolAvailabilityRuntime { if (!found) { throw new Error(`Unknown tool group "${group}". Available: ${ids.join(', ')}.`); } - return { loaded: [...found.toolNames] }; + return { + loaded: [...found.toolNames], + group: { + id: found.id, + ...(found.label ? { label: found.label } : {}), + ...(found.description ? { description: found.description } : {}), + }, + }; }, + // Keep presentation metadata in the durable result without spending + // provider context on copy the model already received in the catalog. + toModelOutput: ({ output }) => ({ + type: 'json', + value: { + loaded: [...(output as LoadToolsResult).loaded], + }, + }), }; } diff --git a/packages/ui/src/__tests__/tool-activity-presentation.test.ts b/packages/ui/src/__tests__/tool-activity-presentation.test.ts index de0fd00de7..e80279a09e 100644 --- a/packages/ui/src/__tests__/tool-activity-presentation.test.ts +++ b/packages/ui/src/__tests__/tool-activity-presentation.test.ts @@ -99,6 +99,143 @@ describe('tool activity presentation', () => { ); }); + it('renders a client capability activation as a localized capability summary', () => { + const item: ToolActivityItem = { + toolUseId: 'load-computer-use', + toolName: 'load_tools', + activityKind: 'tool', + status: 'completed', + args: { group: 'client_a5b9af66b60c5f5c_desktop_computer_use' }, + result: { + kind: 'json', + value: { + loaded: ['mcp__desktop_computer_use__maka_computer'], + group: { + id: 'client_a5b9af66b60c5f5c_desktop_computer_use', + label: 'Computer Use', + description: 'Observe and operate the desktop through this Desktop client.', + }, + }, + }, + }; + + const row = renderToStaticMarkup(createElement(ToolTrow, { items: [item] })); + assert.match(row, /启用桌面操作/); + const detail = renderToStaticMarkup(createElement(ToolCallDetail, { item })); + assert.match(detail, /桌面操作已启用/); + assert.match(detail, /可以查看和操作已授权的本地应用/); + assert.match(detail, /Computer Use/); + assert.match(detail, /1 项能力可用/); + assert.match(detail, /技术详情/); + assert.match(detail, /client_a5b9af66b60c5f5c_desktop_computer_use/); + }); + + it('keeps legacy Computer Use activations friendly without result metadata', () => { + const item: ToolActivityItem = { + toolUseId: 'legacy-load-computer-use', + toolName: 'load_tool', + status: 'completed', + args: { namespace: 'client_legacy_desktop_computer_use' }, + result: { + kind: 'json', + value: { loaded: ['mcp__desktop_computer_use__maka_computer'] }, + }, + }; + + const markup = renderToStaticMarkup(createElement(ToolCallDetail, { item })); + assert.match(markup, /桌面操作已启用/); + assert.doesNotMatch(markup, /已加载 client_legacy_desktop_computer_use 工具组/); + }); + + it('uses supplied labels for third-party capability groups', () => { + const item: ToolActivityItem = { + toolUseId: 'load-third-party', + toolName: 'load_tools', + status: 'completed', + args: { group: 'client_external_notionsuite' }, + result: { + kind: 'json', + value: { + loaded: ['mcp__notion__search', 'mcp__notion__create_page'], + group: { + id: 'client_external_notionsuite', + label: 'Notion', + description: 'Search and update the connected workspace.', + }, + }, + }, + }; + + const row = renderToStaticMarkup(createElement(ToolTrow, { items: [item] })); + assert.match(row, /启用 Notion/); + const detail = renderToStaticMarkup(createElement(ToolCallDetail, { item })); + assert.match(detail, /Notion 已启用/); + assert.match(detail, /Search and update the connected workspace/); + assert.match(detail, /2 项能力可用/); + }); + + it('uses one localized presentation model for every first-party capability group', () => { + const cases = [ + { + id: 'browser', + label: 'Browser', + tool: 'browser_navigate', + row: '启用浏览器操作', + title: '浏览器操作已启用', + }, + { + id: 'client_desktop_mcp', + label: 'MCP', + tool: 'mcp__desktop_mcp__list', + row: '连接 MCP', + title: 'MCP 工具已连接', + }, + { + id: 'rive', + label: 'Rive', + tool: 'RiveWorkflow', + row: '启用 Rive 工作流', + title: 'Rive 工作流已启用', + }, + { + id: 'agent', + label: 'Agent', + tool: 'agent_spawn', + row: '启用子智能体', + title: '子智能体协作已启用', + }, + { + id: 'client_desktop_settings', + label: 'Client settings', + tool: 'mcp__desktop_settings__MakaSettingsGet', + row: '启用设置工具', + title: '设置工具已启用', + }, + ] as const; + + for (const capability of cases) { + const item: ToolActivityItem = { + toolUseId: `load-${capability.id}`, + toolName: 'load_tools', + status: 'completed', + args: { group: capability.id }, + result: { + kind: 'json', + value: { + loaded: [capability.tool], + group: { id: capability.id, label: capability.label }, + }, + }, + }; + + const row = renderToStaticMarkup(createElement(ToolTrow, { items: [item] })); + assert.match(row, new RegExp(capability.row)); + const detail = renderToStaticMarkup(createElement(ToolCallDetail, { item })); + assert.match(detail, new RegExp(capability.title)); + assert.doesNotMatch(detail, new RegExp(`>${capability.id}

`)); + } + }); + it('contains a malformed persisted terminal result instead of crashing the renderer', () => { const malformed = { kind: 'terminal', diff --git a/packages/ui/src/primitives/chat.tsx b/packages/ui/src/primitives/chat.tsx index 1d5e7aa70b..8091b11eaa 100644 --- a/packages/ui/src/primitives/chat.tsx +++ b/packages/ui/src/primitives/chat.tsx @@ -244,10 +244,6 @@ const PREVIEW_PART_CLASSES = { "load-tool-title": "maka-load-tool-title", // `.maka-load-tool-count` "load-tool-count": "maka-load-tool-count", - // `.maka-load-tool-tools` - "load-tool-tools": "maka-load-tool-tools", - // `.maka-load-tool-footer` - "load-tool-footer": "maka-load-tool-footer", } as const; type PreviewPart = keyof typeof PREVIEW_PART_CLASSES; diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index fe9719eeab..a1a04d9b89 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -1006,20 +1006,93 @@ .maka-web-search-error-repair { color: var(--foreground-secondary); } .maka-load-tool-preview { - font: var(--maka-text-supporting); - display: grid; gap: var(--space-0-5); margin: var(--space-1) 0 0; padding: var(--space-1) var(--space-2); border-radius: var(--radius-control); background: var(--background); box-shadow: var(--ring-soft); } + display: grid; + grid-template-columns: var(--space-8) minmax(0, 1fr); + gap: var(--space-2-5); + align-items: start; + margin: var(--space-1) 0 0; + padding: var(--space-2) var(--space-2) var(--space-2-5); +} .maka-load-tool-preview p { margin: 0; } +.maka-load-tool-icon { + display: grid; + width: var(--space-8); + height: var(--space-8); + place-items: center; + border-radius: var(--radius-element); + color: var(--foreground-secondary); + background: var(--foreground-5); +} +.maka-load-tool-summary { + min-width: 0; + padding-top: var(--space-0-5); +} .maka-load-tool-title { font: var(--maka-text-heading-5); - } -.maka-load-tool-count, -.maka-load-tool-footer { color: var(--muted-foreground); } -.maka-load-tool-tools { +} +.maka-load-tool-description { font: var(--maka-text-supporting); - word-break: break-word; } -.maka-load-tool-footer { + margin-top: var(--space-0-5) !important; + color: var(--foreground-secondary); + overflow-wrap: anywhere; +} +.maka-load-tool-count { font: var(--maka-text-supporting); - } + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-1); + margin-top: var(--space-1) !important; + color: var(--muted-foreground); +} +.maka-load-tool-separator { + width: 3px; + height: 3px; + border-radius: 50%; + background: currentColor; + opacity: 0.55; +} +.maka-load-tool-technical { + grid-column: 1 / -1; + margin-left: calc(var(--space-8) + var(--space-2-5)); + color: var(--muted-foreground); +} +.maka-load-tool-technical > summary { + width: fit-content; + padding: var(--space-0-5) 0; + font: var(--maka-text-supporting); + cursor: pointer; +} +.maka-load-tool-technical > summary:focus-visible { + outline: none; + box-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring); + border-radius: var(--radius-control); +} +.maka-load-tool-technical dl { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: var(--space-1) var(--space-2); + margin: var(--space-1) 0 0; + padding-top: var(--space-2); + border-top: 1px solid var(--border-soft); + font: var(--maka-text-supporting); +} +.maka-load-tool-technical:not([open]) > dl { + display: none; +} +.maka-load-tool-technical dt { + color: var(--muted-foreground); +} +.maka-load-tool-technical dd { + min-width: 0; + margin: 0; + color: var(--foreground-secondary); +} +.maka-load-tool-technical code { + font: var(--maka-text-code); + white-space: pre-wrap; + overflow-wrap: anywhere; +} /* Turn-footer action rings go inset (T5 geometry probe): the Astryx chat layout's scrollport clips an outward ring on the first visible row's diff --git a/packages/ui/src/tool-activity.tsx b/packages/ui/src/tool-activity.tsx index fa521d945f..637711eb40 100644 --- a/packages/ui/src/tool-activity.tsx +++ b/packages/ui/src/tool-activity.tsx @@ -23,11 +23,19 @@ import { isInFlightToolStatus } from '@maka/core/tool-result-status'; import { type ToolResultContent } from '@maka/core/events'; import { type UiLocale } from '@maka/core/ui-locale'; import { + Blocks, ICON_SIZE, Check, ChevronRight, Copy, + GitBranch, + Globe, + Monitor, + Plug, + Settings, ShieldAlert, + Workflow, + type LucideIcon, } from './icons.js'; import { useClipboardCopyFeedback } from './clipboard-feedback.js'; import { useUiLocale } from './locale-context.js'; @@ -62,7 +70,11 @@ import { } from '@astryxdesign/core'; import { ToolCodeBlock, ToolDetailReveal } from './tool-activity/tool-code-block.js'; import { cn } from './ui.js'; -import { describeLoadToolResult, formatToolIntent } from './tool-format.js'; +import { + describeLoadToolResult, + formatToolIntent, + type LoadToolGroupKind, +} from './tool-format.js'; import { formatDuration, formatUserVisibleToolText, @@ -97,16 +109,64 @@ function LoadToolResultPreview(props: { /> ); } + const Icon = loadToolGroupIcon(desc.kind); + const copy = getToolActivityCopy(locale).loadTools; return (
-

{desc.title}

-

{desc.countLabel}

-

{desc.toolsText}

-

{desc.footer}

+ +
+

{desc.title}

+

{desc.description}

+

+ {desc.label} +

+
+ {(desc.groupId || desc.toolIds.length > 0) && ( +
+ {copy.technicalDetails} +
+ {desc.groupId && ( + <> +
{copy.groupId}
+
{desc.groupId}
+ + )} + {desc.toolIds.length > 0 && ( + <> +
{copy.toolIds}
+
{desc.toolIds.join('\n')}
+ + )} +
+
+ )}
); } +function loadToolGroupIcon(kind: LoadToolGroupKind): LucideIcon { + switch (kind) { + case 'browser': + return Globe; + case 'computer_use': + return Monitor; + case 'mcp': + return Plug; + case 'rive': + return Workflow; + case 'agent': + return GitBranch; + case 'settings': + return Settings; + default: + return Blocks; + } +} + /** * What a tool row reveals when expanded: the sandbox banner, the invocation, * live output or the settled result preview. Exported because Astryx owns the diff --git a/packages/ui/src/tool-activity/copy.ts b/packages/ui/src/tool-activity/copy.ts index 899956b05a..81d56762d2 100644 --- a/packages/ui/src/tool-activity/copy.ts +++ b/packages/ui/src/tool-activity/copy.ts @@ -100,9 +100,22 @@ export interface ToolActivityCopy { }; loadTools: { displayName: string; - loaded: (namespace?: string) => string; + genericAction: string; + genericTitle: string; + genericDescription: string; count: (count: number) => string; - footer: string; + technicalDetails: string; + groupId: string; + toolIds: string; + groups: Record< + 'browser' | 'computer_use' | 'mcp' | 'rive' | 'agent' | 'settings', + { + label: string; + action: string; + title: string; + description: string; + } + >; }; permissionDenied: string; result: { @@ -251,7 +264,24 @@ const TOOL_ACTIVITY_COPY = { drag: '拖动', }, }, - loadTools: { displayName: '加载工具组', loaded: (namespace) => namespace ? `已加载 ${namespace} 工具组` : '已加载工具组', count: (n) => `新增 ${n} 个可用工具:`, footer: '下一步即可调用' }, + loadTools: { + displayName: '启用能力', + genericAction: '启用工具能力', + genericTitle: '工具能力已启用', + genericDescription: '现在可以使用这组工具。', + count: (n) => `${n} 项能力可用`, + technicalDetails: '技术详情', + groupId: '工具组', + toolIds: '工具', + groups: { + browser: { label: 'Browser', action: '启用浏览器操作', title: '浏览器操作已启用', description: '可以打开页面、读取内容并与网页交互。' }, + computer_use: { label: 'Computer Use', action: '启用桌面操作', title: '桌面操作已启用', description: '可以查看和操作已授权的本地应用。' }, + mcp: { label: 'MCP', action: '连接 MCP', title: 'MCP 工具已连接', description: '可以调用当前客户端连接的 MCP 服务。' }, + rive: { label: 'Rive', action: '启用 Rive 工作流', title: 'Rive 工作流已启用', description: '可以运行可恢复的多智能体工作流。' }, + agent: { label: 'Agent', action: '启用子智能体', title: '子智能体协作已启用', description: '可以并行分派、跟踪和汇总子任务。' }, + settings: { label: '设置', action: '启用设置工具', title: '设置工具已启用', description: '可以读取或更新当前客户端设置。' }, + }, + }, permissionDenied: '用户已拒绝权限请求', result: { hiddenLines: (n) => `… 已隐藏 ${n} 行`, ptyFailed: '后台终端交互失败', queued: '已输入', notQueued: '未输入', queuedPreview: (action, preview, bytes) => bytes === undefined ? `${action}:${preview}` : `${action}:${preview}… · 共 ${bytes} 字节`, byteCount: (action, bytes) => `${action} ${bytes} 字节`, resizeNotApplied: (size) => `未调整为 ${size}`, resized: (size) => `已调整为 ${size}`, sizeUnchanged: (size) => `尺寸已是 ${size}`, ptyCompleted: '后台终端交互已完成', terminalUnavailable: '终端输出不可用', noTerminalFrame: '(无可用终端画面)', noOutputYet: '(尚无输出)', noOutput: '(无输出)', exitCode: (code) => `退出码 ${code}`, managedBySource: '由源任务管理', sourceUnavailable: '源任务不可用', running: '运行中', success: '成功', failed: '失败', timedOut: '已超时', cancelled: '已取消', disconnected: '已断开', terminalTruncated: '终端输出已截断', terminalRedacted: '终端输出已脱敏', streamHidden: (stream, n) => `… ${stream} 已隐藏 ${n} 行`, streamsTruncated: (limit) => `输出已截断 · 每路仅展示前 ${limit} 行`, outputTruncated: '输出已截断', outputRedacted: '输出已脱敏', @@ -331,7 +361,24 @@ const TOOL_ACTIVITY_COPY = { drag: 'Drag', }, }, - loadTools: { displayName: 'Load tools', loaded: (namespace) => namespace ? `Loaded ${namespace} tools` : 'Loaded tools', count: (n) => `Added ${n} available ${n === 1 ? 'tool' : 'tools'}:`, footer: 'Ready to use' }, + loadTools: { + displayName: 'Enable capabilities', + genericAction: 'Enable tool capabilities', + genericTitle: 'Tool capabilities enabled', + genericDescription: 'This tool group is ready to use.', + count: (n) => `${n} ${n === 1 ? 'capability' : 'capabilities'} available`, + technicalDetails: 'Technical details', + groupId: 'Group', + toolIds: 'Tools', + groups: { + browser: { label: 'Browser', action: 'Enable browser actions', title: 'Browser actions enabled', description: 'Open pages, read content, and interact with websites.' }, + computer_use: { label: 'Computer Use', action: 'Enable desktop actions', title: 'Desktop actions enabled', description: 'View and operate authorized local applications.' }, + mcp: { label: 'MCP', action: 'Connect MCP', title: 'MCP tools connected', description: 'Use MCP services connected by the current client.' }, + rive: { label: 'Rive', action: 'Enable Rive workflows', title: 'Rive workflows enabled', description: 'Run durable multi-agent workflows.' }, + agent: { label: 'Agent', action: 'Enable subagents', title: 'Subagent collaboration enabled', description: 'Delegate, track, and summarize tasks in parallel.' }, + settings: { label: 'Settings', action: 'Enable settings tools', title: 'Settings tools enabled', description: 'Read or update settings owned by the current client.' }, + }, + }, permissionDenied: 'User denied the permission request', result: { hiddenLines: (n) => `… ${n} ${n === 1 ? 'line' : 'lines'} hidden`, ptyFailed: 'Background terminal interaction failed', queued: 'Entered', notQueued: 'Not entered', queuedPreview: (action, preview, bytes) => bytes === undefined ? `${action}: ${preview}` : `${action}: ${preview}… · ${bytes} bytes total`, byteCount: (action, bytes) => `${action} ${bytes} bytes`, resizeNotApplied: (size) => `Not resized to ${size}`, resized: (size) => `Resized to ${size}`, sizeUnchanged: (size) => `Size already ${size}`, ptyCompleted: 'Background terminal interaction completed', terminalUnavailable: 'Terminal output unavailable', noTerminalFrame: '(No terminal frame available)', noOutputYet: '(No output yet)', noOutput: '(No output)', exitCode: (code) => `exit code ${code}`, managedBySource: 'Managed by the source task', sourceUnavailable: 'Source task unavailable', running: 'Running', success: 'Succeeded', failed: 'Failed', timedOut: 'Timed out', cancelled: 'Cancelled', disconnected: 'Disconnected', terminalTruncated: 'Terminal output truncated', terminalRedacted: 'Terminal output redacted', streamHidden: (stream, n) => `… ${n} ${stream} ${n === 1 ? 'line' : 'lines'} hidden`, streamsTruncated: (limit) => `Output truncated · showing the first ${limit} lines of each stream`, outputTruncated: 'Output truncated', outputRedacted: 'Output redacted', diff --git a/packages/ui/src/tool-activity/display-name.ts b/packages/ui/src/tool-activity/display-name.ts index 1f5dcda511..c63e36345f 100644 --- a/packages/ui/src/tool-activity/display-name.ts +++ b/packages/ui/src/tool-activity/display-name.ts @@ -26,7 +26,7 @@ import type { UiLocale } from '@maka/core/ui-locale'; import type { ToolActivityItem } from '../materialize.js'; -import { loadToolDisplayName } from '../tool-format.js'; +import { describeLoadToolResult, loadToolDisplayName } from '../tool-format.js'; const CONNECTOR_TOOL_NAMES: ReadonlySet = new Set(['load_tools', 'load_tool']); @@ -36,6 +36,10 @@ export function isConnectorTool(name: string): boolean { export function resolveToolDisplayName(item: ToolActivityItem, locale: UiLocale): string { if (item.displayName) return item.displayName; - if (isConnectorTool(item.toolName)) return loadToolDisplayName(locale); + if (isConnectorTool(item.toolName)) { + const value = item.result?.kind === 'json' ? item.result.value : undefined; + return describeLoadToolResult(item.args, value, locale)?.actionLabel + ?? loadToolDisplayName(locale); + } return item.toolName; } diff --git a/packages/ui/src/tool-format.ts b/packages/ui/src/tool-format.ts index 8374c6a74e..3d4d8cde84 100644 --- a/packages/ui/src/tool-format.ts +++ b/packages/ui/src/tool-format.ts @@ -26,20 +26,32 @@ export function loadToolDisplayName(locale: UiLocale): string { return getToolActivityCopy(locale).loadTools.displayName; } -interface LoadToolResultDescription { +export type LoadToolGroupKind = + | 'browser' + | 'computer_use' + | 'mcp' + | 'rive' + | 'agent' + | 'settings' + | 'generic'; + +export interface LoadToolResultDescription { + kind: LoadToolGroupKind; + actionLabel: string; title: string; + description: string; + label: string; countLabel: string; - toolsText: string; - footer: string; + groupId?: string; + toolIds: string[]; } /** - * Turn a `load_tools` call + its thin `{ loaded: [...] }` result into friendly, - * locale-aware card copy. Reads the group id from `group` (current) or the - * historical `namespace` arg (`load_tool`, PR #30) so replayed old sessions - * still render. Returns `null` when the result is not the expected shape (e.g. a - * load failure, a text/error result) so the caller falls back to the generic - * preview. + * Turn a `load_tools` call + result into friendly, locale-aware card copy. + * Current results carry presentation metadata; historical `{ loaded: [...] }` + * results are inferred from the call args and tool ids so replayed sessions + * still render. Returns `null` for unexpected shapes so the caller falls back + * to the generic preview. */ export function describeLoadToolResult( args: unknown, @@ -50,21 +62,107 @@ export function describeLoadToolResult( if (!Array.isArray(loaded) || !loaded.every((name) => typeof name === 'string')) { return null; } - const tools = loaded as string[]; + const tools = (loaded as string[]).map(safeDisplayText).filter(Boolean); const argRecord = args as { group?: unknown; namespace?: unknown } | null | undefined; const rawGroup = argRecord?.group ?? argRecord?.namespace; - const namespace = - typeof rawGroup === 'string' && rawGroup.length > 0 ? rawGroup : undefined; + const resultGroup = (value as { group?: unknown }).group; + const groupRecord = + resultGroup && typeof resultGroup === 'object' + ? resultGroup as { id?: unknown; label?: unknown; description?: unknown } + : undefined; + const groupId = firstSafeText(groupRecord?.id, rawGroup); + const suppliedLabel = firstSafeText(groupRecord?.label); + const suppliedDescription = firstSafeText(groupRecord?.description); + const kind = loadToolGroupKind(groupId, suppliedLabel, tools); const n = tools.length; const copy = getToolActivityCopy(locale).loadTools; + if (kind !== 'generic') { + const groupCopy = copy.groups[kind]; + return { + kind, + actionLabel: groupCopy.action, + title: groupCopy.title, + description: groupCopy.description, + label: groupCopy.label, + countLabel: copy.count(n), + ...(groupId ? { groupId } : {}), + toolIds: tools, + }; + } + + const label = suppliedLabel ?? (locale === 'en' ? 'Tools' : '工具'); return { - title: copy.loaded(namespace), + kind, + actionLabel: suppliedLabel + ? locale === 'en' ? `Enable ${suppliedLabel}` : `启用 ${suppliedLabel}` + : copy.genericAction, + title: suppliedLabel + ? locale === 'en' ? `${suppliedLabel} enabled` : `${suppliedLabel} 已启用` + : copy.genericTitle, + description: suppliedDescription ?? copy.genericDescription, + label, countLabel: copy.count(n), - toolsText: tools.join(locale === 'en' ? ', ' : '、'), - footer: copy.footer, + ...(groupId ? { groupId } : {}), + toolIds: tools, }; } +function firstSafeText(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== 'string') continue; + const safe = safeDisplayText(value); + if (safe) return safe; + } + return undefined; +} + +function safeDisplayText(value: string): string { + return redactSecrets(value.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/\s+/g, ' ').trim()); +} + +function loadToolGroupKind( + groupId: string | undefined, + label: string | undefined, + tools: readonly string[], +): LoadToolGroupKind { + const id = groupId?.toLowerCase() ?? ''; + const normalizedLabel = label?.toLowerCase() ?? ''; + const names = tools.map((name) => name.toLowerCase()); + const hasTool = (name: string) => + names.some((candidate) => candidate === name || candidate.endsWith(`__${name}`)); + + if ( + id === 'computer_use' + || id.endsWith('_desktop_computer_use') + || normalizedLabel === 'computer use' + || hasTool('maka_computer') + ) return 'computer_use'; + if ( + id === 'browser' + || id.endsWith('_desktop_browser') + || normalizedLabel === 'browser' + || hasTool('browser_navigate') + ) return 'browser'; + if ( + id === 'rive' + || id.endsWith('_desktop_rive') + || normalizedLabel === 'rive' + || hasTool('riveworkflow') + ) return 'rive'; + if ( + id === 'agent' + || normalizedLabel === 'agent' + || hasTool('agent_spawn') + ) return 'agent'; + if ( + id.endsWith('_desktop_settings') + || normalizedLabel === 'client settings' + || hasTool('makasettingsget') + ) return 'settings'; + if (id.endsWith('_desktop_mcp') || normalizedLabel === 'mcp') return 'mcp'; + return 'generic'; +} + export function formatRedactedJson(value: unknown): string { try { return redactSecrets(JSON.stringify(value, null, 2));