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';