Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions web/src/components/PermissionModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -125,17 +135,24 @@ export function PermissionModal({ request, onResolve, originLabel = '' }) {
) : !isPlanEnter && (
<div className="rounded-md bg-surface-alt border border-border font-mono overflow-hidden">
<div className="px-3.5 py-2 border-b border-border text-[12px] font-semibold text-fg flex items-center justify-between gap-2">
<span className="truncate">{request.tool || 'tool'}</span>
<span className="truncate">{toolPreview.toolLabel}</span>
{preview.truncated > 0 && (
<span className="shrink-0 text-[10px] font-normal text-warn">仅显示预览</span>
)}
</div>
<pre
className="px-3.5 py-2.5 text-[11px] leading-relaxed text-fg-2 m-0 whitespace-pre-wrap break-words overflow-auto overscroll-contain"
style={{ maxHeight: 'min(48vh, 360px)' }}
>
{preview.text}
</pre>
{toolPreview.kind === 'file' ? (
<div className="px-3.5 py-2.5">
<div className="text-[12px] text-fg-2 break-all">{toolPreview.filePath}</div>
<div className="text-[11px] text-fg-mute mt-0.5">{toolPreview.detail}</div>
</div>
) : (
<pre
className="px-3.5 py-2.5 text-[11px] leading-relaxed text-fg-2 m-0 whitespace-pre-wrap break-words overflow-auto overscroll-contain"
style={{ maxHeight: 'min(48vh, 360px)' }}
>
{preview.text}
</pre>
)}
</div>
)}
</div>
Expand Down
68 changes: 68 additions & 0 deletions web/src/lib/permissionToolPreview.js
Original file line number Diff line number Diff line change
@@ -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' };
}
114 changes: 114 additions & 0 deletions web/src/lib/permissionToolPreview.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
1 change: 1 addition & 0 deletions web/src/lib/runTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading