From 3df862b82b46b743c24845c6af6386fac40e9c54 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Sat, 11 Jul 2026 01:52:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=9D=83=E9=99=90=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E5=BC=B9=E7=AA=97=E7=B2=BE=E7=AE=80=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20PascalCase=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=90=8D=20+=20=E6=96=87=E4=BB=B6=E6=94=B9=E5=8A=A8=E6=91=98?= =?UTF-8?q?=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐 TUI 工具行改造的口径:弹窗头部工具名走 pascal_case_tool_name 同款转换(file_write → FileWrite,MCP 双下划线名逐词转换); file_write/file_edit 不再把参数 JSON(含完整文件内容)糊进弹窗, 只显示文件路径 + 行数摘要(写入 N 行 / 替换 N→M 行 / 删除 N 行, replace_all 标注所有匹配);bash 只显示命令本体;其余工具保留 紧凑 JSON 兜底。展示逻辑收口在纯函数 lib/permissionToolPreview.js, Node 单测覆盖行数边界与异形参数降级。后端 payload 零改动。 Co-Authored-By: Claude Fable 5 --- web/src/components/PermissionModal.jsx | 33 +++++-- web/src/lib/permissionToolPreview.js | 68 +++++++++++++ web/src/lib/permissionToolPreview.test.js | 114 ++++++++++++++++++++++ web/src/lib/runTests.js | 1 + 4 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 web/src/lib/permissionToolPreview.js create mode 100644 web/src/lib/permissionToolPreview.test.js diff --git a/web/src/components/PermissionModal.jsx b/web/src/components/PermissionModal.jsx index 30aba99c..1bea3a58 100644 --- a/web/src/components/PermissionModal.jsx +++ b/web/src/components/PermissionModal.jsx @@ -4,6 +4,7 @@ import { useMemo, useRef } from 'react'; import { connection } from '../lib/connection.js'; import { planPermissionPresentation } from '../lib/permissionRequestPresentation.js'; +import { buildPermissionToolPreview } from '../lib/permissionToolPreview.js'; import { Modal } from './Modal.jsx'; import { VsIcon } from './Icon.jsx'; @@ -57,7 +58,16 @@ function formatArgsPreview(args) { } export function PermissionModal({ request, onResolve, originLabel = '' }) { - const preview = useMemo(() => formatArgsPreview(request?.args), [request?.args]); + const toolPreview = useMemo( + () => buildPermissionToolPreview(request?.tool, request?.args), + [request?.tool, request?.args], + ); + // command/json 两种 kind 才需要长文本预览;file kind 只展示路径 + 行数。 + const preview = useMemo(() => { + if (toolPreview.kind === 'command') return truncateText(toolPreview.command, MAX_PREVIEW_CHARS); + if (toolPreview.kind === 'json') return formatArgsPreview(request?.args); + return { text: '', truncated: 0 }; + }, [toolPreview, request?.args]); const resolvedRef = useRef(false); const { isPlanEnter, @@ -125,17 +135,24 @@ export function PermissionModal({ request, onResolve, originLabel = '' }) { ) : !isPlanEnter && (
- {request.tool || 'tool'} + {toolPreview.toolLabel} {preview.truncated > 0 && ( 仅显示预览 )}
-
-                  {preview.text}
-                
+ {toolPreview.kind === 'file' ? ( +
+
{toolPreview.filePath}
+
{toolPreview.detail}
+
+ ) : ( +
+                    {preview.text}
+                  
+ )}
)} diff --git a/web/src/lib/permissionToolPreview.js b/web/src/lib/permissionToolPreview.js new file mode 100644 index 00000000..8827c398 --- /dev/null +++ b/web/src/lib/permissionToolPreview.js @@ -0,0 +1,68 @@ +// 权限确认弹窗的工具展示口径(纯函数,Node 单测)。 +// 与 TUI 侧保持一致:工具名走 pascal_case_tool_name 同款转换 +// (src/tui/tool_row_format.cpp),file_write/file_edit 只提示 +// 「改哪个文件、多少行」,不再把参数 JSON(含完整文件内容)糊进弹窗。 + +// 对齐 C++ pascal_case_tool_name:下划线作分词符丢弃,词首小写字母转大写, +// 已是驼峰的名字(AskUserQuestion / EnterWorktree)原样保留。 +export function pascalCaseToolName(name) { + const s = String(name ?? ''); + let out = ''; + let upperNext = true; + for (const ch of s) { + if (ch === '_') { + upperNext = true; + continue; + } + out += upperNext && ch >= 'a' && ch <= 'z' ? ch.toUpperCase() : ch; + upperNext = false; + } + return out; +} + +// 行数统计:空串 0 行;结尾单个换行不额外计一行("a\n" 是 1 行)。 +export function countLines(text) { + const s = String(text ?? ''); + if (!s) return 0; + const body = s.endsWith('\n') ? s.slice(0, -1) : s; + return body === '' ? 1 : body.split('\n').length; +} + +// 返回 { toolLabel, kind, ... }: +// kind='file' → filePath + detail(文件写入/编辑的精简摘要) +// kind='command' → command(bash,命令本身就是要审的内容) +// kind='json' → 无更好渲染的工具,调用方回退到紧凑 JSON 预览 +export function buildPermissionToolPreview(tool, args) { + const toolLabel = pascalCaseToolName(tool || 'tool') || 'Tool'; + const a = args && typeof args === 'object' && !Array.isArray(args) ? args : null; + + if (tool === 'file_write' && a && typeof a.file_path === 'string' && a.file_path) { + return { + toolLabel, + kind: 'file', + filePath: a.file_path, + detail: `写入 ${countLines(a.content)} 行`, + }; + } + + if (tool === 'file_edit' && a && typeof a.file_path === 'string' && a.file_path) { + const oldLines = countLines(a.old_string); + const newLines = countLines(a.new_string); + let detail; + if (oldLines === 0) { + detail = `写入 ${newLines} 行`; + } else if (newLines === 0) { + detail = `删除 ${oldLines} 行`; + } else { + detail = `替换 ${oldLines} 行 → ${newLines} 行`; + } + if (a.replace_all === true) detail += '(所有匹配)'; + return { toolLabel, kind: 'file', filePath: a.file_path, detail }; + } + + if (tool === 'bash' && a && typeof a.command === 'string') { + return { toolLabel, kind: 'command', command: a.command }; + } + + return { toolLabel, kind: 'json' }; +} diff --git a/web/src/lib/permissionToolPreview.test.js b/web/src/lib/permissionToolPreview.test.js new file mode 100644 index 00000000..e51f19a7 --- /dev/null +++ b/web/src/lib/permissionToolPreview.test.js @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { + pascalCaseToolName, + countLines, + buildPermissionToolPreview, +} from './permissionToolPreview.js'; + +function run(name, fn) { + try { + fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} + +// 场景:权限弹窗头部显示工具名。 +// 期望:与 TUI 的 pascal_case_tool_name 同口径 —— 下划线分词转 PascalCase, +// 已是驼峰的内置名原样保留,MCP 双下划线名同样逐词转换。 +run('pascalCaseToolName 与 TUI 口径一致', () => { + assert.equal(pascalCaseToolName('file_write'), 'FileWrite'); + assert.equal(pascalCaseToolName('bash'), 'Bash'); + assert.equal(pascalCaseToolName('AskUserQuestion'), 'AskUserQuestion'); + assert.equal(pascalCaseToolName('EnterWorktree'), 'EnterWorktree'); + assert.equal(pascalCaseToolName('mcp__server__snapshot'), 'McpServerSnapshot'); + assert.equal(pascalCaseToolName(''), ''); +}); + +// 场景:统计 file_write content / file_edit old|new_string 的行数。 +// 期望:空串 0 行;结尾单个换行不额外计行("a\n" 是 1 行不是 2 行)—— +// 否则写入整文件(必带尾换行)时行数恒虚高 1。 +run('countLines 行数统计边界', () => { + assert.equal(countLines(''), 0); + assert.equal(countLines(null), 0); + assert.equal(countLines('a'), 1); + assert.equal(countLines('a\n'), 1); + assert.equal(countLines('a\nb'), 2); + assert.equal(countLines('a\nb\n'), 2); + assert.equal(countLines('\n'), 1); +}); + +// 场景:file_write 请求确认(弹窗此前直接糊整个参数 JSON,含完整文件内容)。 +// 期望:kind='file',只给文件路径 + 「写入 N 行」,不透出 content 本体。 +run('file_write 摘要为 路径 + 写入行数', () => { + const view = buildPermissionToolPreview('file_write', { + file_path: 'C:/proj/main.go', + content: 'package main\nfunc main() {}\n', + }); + assert.equal(view.toolLabel, 'FileWrite'); + assert.equal(view.kind, 'file'); + assert.equal(view.filePath, 'C:/proj/main.go'); + assert.equal(view.detail, '写入 2 行'); +}); + +// 场景:file_edit 三种形态 —— 替换 / 新建(old 空)/ 删除(new 空), +// 以及 replace_all 全量替换。 +// 期望:detail 分别为「替换 N 行 → M 行」「写入 M 行」「删除 N 行」, +// replace_all 追加「(所有匹配)」提示。 +run('file_edit 摘要覆盖 替换/新建/删除/全量', () => { + const replace = buildPermissionToolPreview('file_edit', { + file_path: 'a.ts', + old_string: 'x\ny\nz', + new_string: 'x\nz', + }); + assert.equal(replace.toolLabel, 'FileEdit'); + assert.equal(replace.kind, 'file'); + assert.equal(replace.detail, '替换 3 行 → 2 行'); + + const create = buildPermissionToolPreview('file_edit', { + file_path: 'a.ts', + old_string: '', + new_string: 'x\ny', + }); + assert.equal(create.detail, '写入 2 行'); + + const remove = buildPermissionToolPreview('file_edit', { + file_path: 'a.ts', + old_string: 'x\ny', + new_string: '', + }); + assert.equal(remove.detail, '删除 2 行'); + + const all = buildPermissionToolPreview('file_edit', { + file_path: 'a.ts', + old_string: 'x', + new_string: 'y', + replace_all: true, + }); + assert.equal(all.detail, '替换 1 行 → 1 行(所有匹配)'); +}); + +// 场景:bash 请求确认 —— 命令本身就是用户要审的内容。 +// 期望:kind='command',原样透出 command(截断由渲染层负责)。 +run('bash 透出命令本体', () => { + const view = buildPermissionToolPreview('bash', { command: 'rm -rf build' }); + assert.equal(view.toolLabel, 'Bash'); + assert.equal(view.kind, 'command'); + assert.equal(view.command, 'rm -rf build'); +}); + +// 场景:其他工具(MCP / memory_write 等)以及参数缺失/异形的降级路径 —— +// file_path 缺失、args 是字符串(后端 JSON parse 失败原样透传)、args 是数组。 +// 期望:一律 kind='json' 回退到紧凑 JSON 预览,不抛异常。 +run('未识别工具与异形参数回退 json', () => { + assert.equal(buildPermissionToolPreview('memory_write', { path: 'x' }).kind, 'json'); + assert.equal(buildPermissionToolPreview('file_write', {}).kind, 'json'); + assert.equal(buildPermissionToolPreview('file_edit', { old_string: 'x' }).kind, 'json'); + assert.equal(buildPermissionToolPreview('bash', 'not-json').kind, 'json'); + assert.equal(buildPermissionToolPreview('file_write', ['a']).kind, 'json'); + const fallback = buildPermissionToolPreview(undefined, undefined); + assert.equal(fallback.kind, 'json'); + assert.equal(fallback.toolLabel, 'Tool'); +}); diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index 75030545..e8f07003 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -35,6 +35,7 @@ import './sessionModel.test.js'; import './compactMessagePreview.test.js'; import './permissionMode.test.js'; import './permissionRequestPresentation.test.js'; +import './permissionToolPreview.test.js'; import './tokenBudget.test.js'; import './usageStats.test.js'; import './desktopFeedback.test.js';