Skip to content
Merged
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
104 changes: 104 additions & 0 deletions apps/desktop/src/renderer/__tests__/agentActionRowRendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,110 @@ describe('AgentActionRow — 行主文案', () => {
expect(document.querySelector('[data-agent-action-file-chip="true"]')).toBeTruthy();
});

it('pi bash:小写工具名照样解析出意图动词,不再是「调用 bash」', () => {
render(
createElement(AgentActionRow, {
message: mkTool('t1', 'bash', { command: 'git status' }),
}),
);
expect(screen.getByText('chat.agentActionRow.verb.gitStatus')).toBeTruthy();
expect(screen.queryByText('chat.agentActionRow.verb.used')).toBeNull();
expect(screen.queryByText('bash')).toBeNull();
});

it('pi bash:无法分类的命令回退为运行动词 + 命令原文', () => {
render(
createElement(AgentActionRow, {
message: mkTool('t1', 'bash', { command: 'docker ps' }),
}),
);
expect(screen.getByText('chat.agentActionRow.verb.ran')).toBeTruthy();
expect(screen.getByText('docker ps')).toBeTruthy();
});

it('pi read:path 字段渲染成文件 chip 与读取动词', () => {
render(
createElement(AgentActionRow, {
message: mkTool('t1', 'read', { path: '/repo/src/app.ts' }),
}),
);
expect(screen.getByText('chat.agentActionRow.verb.read')).toBeTruthy();
expect(screen.getByText('app.ts')).toBeTruthy();
expect(document.querySelector('[data-agent-action-file-chip="true"]')).toBeTruthy();
});

it('pi grep / find:搜索动词 + 搜索目标', () => {
const { rerender } = render(
createElement(AgentActionRow, {
message: mkTool('t1', 'grep', { pattern: 'TODO', path: 'src/' }),
}),
);
expect(screen.getByText('chat.agentActionRow.verb.searched')).toBeTruthy();
expect(screen.getByText('TODO')).toBeTruthy();
rerender(
createElement(AgentActionRow, {
message: mkTool('t1', 'find', { pattern: '**/*.spec.ts' }),
}),
);
expect(screen.getByText('chat.agentActionRow.verb.searched')).toBeTruthy();
expect(screen.getByText('**/*.spec.ts')).toBeTruthy();
});

it('pi write:创建动词 + 行内 +N 统计,点击进共享 diff lightbox', () => {
render(
createElement(AgentActionRow, {
message: mkTool('t1', 'write', { path: '/repo/src/new.ts', content: 'a\nb' }),
}),
);
expect(screen.getByText('chat.agentActionRow.verb.created')).toBeTruthy();
expect(screen.getByText('new.ts')).toBeTruthy();
expect(screen.getByText('+2')).toBeTruthy();
fireEvent.click(screen.getByRole('button'));
expect(document.body.textContent).toContain('"kind":"diff"');
expect(document.body.textContent).toContain('"filePath":"/repo/src/new.ts"');
});

it('pi edit:edits[].oldText/newText 汇成编辑动词与 diff lightbox', () => {
render(
createElement(AgentActionRow, {
message: mkTool('t1', 'edit', {
path: '/repo/src/app.ts',
edits: [{ oldText: 'old', newText: 'new' }],
}),
}),
);
expect(screen.getByText('chat.agentActionRow.verb.edited')).toBeTruthy();
expect(screen.getByText('app.ts')).toBeTruthy();
expect(screen.getByText('+1')).toBeTruthy();
expect(screen.getByText('-1')).toBeTruthy();
fireEvent.click(screen.getByRole('button'));
expect(document.body.textContent).toContain('"oldString":"old"');
expect(document.body.textContent).toContain('"newString":"new"');
});

// pi 0.83.0 的 edit 同时接受 legacy 顶层单段(LegacyEditToolInput);只认 edits[]
// 会让这种事件退化成空 diff 与 +0 -0。
it('pi edit:legacy 顶层 oldText/newText 也给出真实统计与非空 diff', () => {
render(
createElement(AgentActionRow, {
message: mkTool('t1', 'edit', {
path: '/repo/src/app.ts',
oldText: 'old A\nold B',
newText: 'new A',
}),
}),
);
expect(screen.getByText('chat.agentActionRow.verb.edited')).toBeTruthy();
expect(screen.getByText('app.ts')).toBeTruthy();
expect(screen.getByText('+1')).toBeTruthy();
expect(screen.getByText('-2')).toBeTruthy();
fireEvent.click(screen.getByRole('button'));
expect(document.body.textContent).toContain('"oldString":"old A\\nold B"');
expect(document.body.textContent).toContain('"newString":"new A"');
// 空 diffs 数组会渲染成 "diffs":[] —— 明确断死它没退化。
expect(document.body.textContent).not.toContain('"diffs":[]');
});

it('状态图标:running / done 经 aria-label 可达,缺省为 done', () => {
const { rerender } = render(
createElement(AgentActionRow, {
Expand Down
45 changes: 45 additions & 0 deletions apps/desktop/src/renderer/__tests__/diffStats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,51 @@ describe('statsForToolCall', () => {
expect(statsForToolCall('MultiEdit', { edits: [] })).toEqual({ add: 0, del: 0 });
});

it('pi edit: sums edits[].oldText/newText like MultiEdit', () => {
const stats = statsForToolCall('edit', {
path: '/foo',
edits: [
{ oldText: 'a', newText: 'b' }, // +1 -1
{ oldText: '', newText: 'x\ny' }, // +2 -0
],
});
expect(stats).toEqual({ add: 3, del: 1 });
});

it('pi edit: legacy top-level oldText/newText yields real counts, not +0 -0', () => {
expect(statsForToolCall('edit', {
path: '/foo',
oldText: 'old A\nold B',
newText: 'new A',
})).toEqual({ add: 1, del: 2 });
});

it('pi edit: top-level pair is counted after edits[] when both are present', () => {
expect(statsForToolCall('edit', {
path: '/foo',
edits: [{ oldText: 'a', newText: 'b' }], // +1 -1
oldText: 'c',
newText: 'd', // +1 -1
})).toEqual({ add: 2, del: 2 });
});

it('pi edit: no usable replacement returns +0 -0 (not null)', () => {
expect(statsForToolCall('edit', { path: '/foo' })).toEqual({ add: 0, del: 0 });
});

it('pi write: full content as +N -0', () => {
expect(statsForToolCall('write', { path: '/foo', content: 'a\nb' })).toEqual({
add: 2,
del: 0,
});
});

it('pi read-only tools stay null', () => {
expect(statsForToolCall('read', { path: '/foo' })).toBeNull();
expect(statsForToolCall('bash', { command: 'ls' })).toBeNull();
expect(statsForToolCall('grep', { pattern: 'foo' })).toBeNull();
});

it('file_change: sums unified diffs across all changed files', () => {
expect(statsForToolCall('file_change', {
changes: [
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/renderer/__tests__/verbAggregator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ describe('verbForTool', () => {
expect(verbForTool('web_search')).toBe('fetched');
});

it('maps pi builtin lowercase tools', () => {
expect(verbForTool('bash')).toBe('ran');
expect(verbForTool('read')).toBe('read');
expect(verbForTool('ls')).toBe('read');
expect(verbForTool('edit')).toBe('edited');
expect(verbForTool('write')).toBe('created');
expect(verbForTool('grep')).toBe('searched');
expect(verbForTool('find')).toBe('searched');
});

it('falls back to "used" for unknown tools', () => {
expect(verbForTool('FooBar')).toBe('used');
expect(verbForTool('')).toBe('used');
Expand Down
47 changes: 42 additions & 5 deletions apps/desktop/src/renderer/components/chat/AgentActionRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { useTranslation } from 'react-i18next';
import {
describeToolUse,
normalizeDisplayCommand,
piEditReplacements,
type CommandIntent,
type ToolUseDescriptor,
} from '@cindy/maker-shared';
Expand All @@ -69,7 +70,17 @@ import { TextLightbox } from './TextLightbox';
import { ToolPayloadLightbox, type ToolPayloadMode } from './ToolPayloadLightbox';
import { useFileChipContextMenu } from './useFileChipContextMenu';

const FILE_PATH_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'Read']);
/**
* 点击走「文件类」交互(diff / 文稿 / 图片 lightbox)的工具:CC 大写 + pi 小写
* (pi 内置工具名全小写、文件字段为 path,见 toolUseDescriptor.ts)。
*
* 注意这**不是**「所有 kind='file' 描述符」的集合:pi 的 `ls` 也被归一化成
* kind='file'(读取语义)并渲染文件 chip,但**刻意不列入本集合** —— 它的目标是
* 目录,开文稿/图片 lightbox 没有意义,因此点击仍走命令类的就地展开路径
* (isInlineExpand)。新增工具时按「点击后该看到什么」判断是否入列,别按
* 描述符 kind 判断。
*/
const FILE_PATH_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'Read', 'edit', 'write', 'read']);

/**
* v10 (2026-04-20): 命令类工具(Bash/Grep/Glob/WebFetch/WebSearch/...)的
Expand Down Expand Up @@ -451,14 +462,16 @@ function formatInlineInput(
if (!inp) return '';
switch (toolName) {
case 'Bash':
case 'bash':
case 'exec': {
// description 已上移为行主文案(issue #450),这里只展示命令原文 + cwd,
// 避免同一句话在折叠行和展开区重复出现。
const cmd = commandDisplayText(inp);
const cwd = typeof inp.cwd === 'string' && inp.cwd ? `cwd: ${inp.cwd}` : '';
return cwd ? `${cmd}\n${cwd}` : cmd;
}
case 'Grep': {
case 'Grep':
case 'grep': {
const pattern = typeof inp.pattern === 'string' ? inp.pattern : '';
const path = typeof inp.path === 'string' ? inp.path : '';
const glob = typeof inp.glob === 'string' ? inp.glob : '';
Expand All @@ -474,11 +487,17 @@ function formatInlineInput(
.filter(Boolean)
.join('\n');
}
case 'Glob': {
case 'Glob':
case 'find': {
const pattern = typeof inp.pattern === 'string' ? inp.pattern : '';
const path = typeof inp.path === 'string' ? inp.path : '';
return path ? `${pattern}\nin: ${path}` : pattern;
}
case 'ls': {
// pi ls:path 可缺省(默认当前目录)。
const path = typeof inp.path === 'string' ? inp.path : '';
return path;
}
case 'WebFetch': {
const url = typeof inp.url === 'string' ? inp.url : '';
const prompt = typeof inp.prompt === 'string' ? inp.prompt : '';
Expand Down Expand Up @@ -536,7 +555,7 @@ function buildDiffPayload(
],
};
}
if (toolName === 'Write') {
if (toolName === 'Write' || toolName === 'write') {
const c = typeof inp.content === 'string' ? inp.content : '';
return {
kind: 'diff',
Expand All @@ -549,6 +568,24 @@ function buildDiffPayload(
],
};
}
// pi edit:声明 schema 的 edits[] 与 legacy 顶层 {oldText,newText} 两种形态,
// 由共享的 piEditReplacements 归一化(只认一种会让另一种退化成空 diff)。
if (toolName === 'edit') {
return {
kind: 'diff',
files: [
{
key: filePath,
filePath,
diffs: piEditReplacements(inp).map((edit, index) => ({
key: `edit:${index}`,
oldString: edit.oldText,
newString: edit.newText,
})),
},
],
};
}
if (toolName === 'MultiEdit') {
const edits = Array.isArray(inp.edits) ? inp.edits : [];
return {
Expand Down Expand Up @@ -719,7 +756,7 @@ export function AgentActionRow({
return;
}
triggerRef.current = anchor;
if (toolName === 'Read' && filePath) {
if ((toolName === 'Read' || toolName === 'read') && filePath) {
// 模型可能给相对路径(runtime 按会话工作目录解析后 Read 照样成功),而
// 预览 / 定位 IPC 一律要求绝对路径 —— 先按 workingDir 补齐,镜像 runtime
// 语义,保证 chip 打开的就是 agent 实际读到的那个文件。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

import type { ChatMessage } from '@/lib/makerChatStore';

const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
// CC 大写(file_path 字段)+ pi 小写(path 字段,见 toolUseDescriptor.ts 数据来源约定)。
const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'edit', 'write']);

function slashPath(p: string): string {
return p.replace(/\\/g, '/').replace(/\/+/g, '/');
Expand Down Expand Up @@ -75,7 +76,7 @@ function extractToolFilePaths(msg: ChatMessage): string[] {
const input = (msg.toolInput as Record<string, unknown> | null) ?? null;
if (msg.toolName === 'file_change') return collectCodexFileChangePaths(input);
if (!EDIT_TOOL_NAMES.has(msg.toolName)) return [];
const filePath = input?.file_path;
const filePath = input?.file_path ?? input?.path;
return typeof filePath === 'string' && filePath ? [filePath] : [];
}

Expand Down
15 changes: 14 additions & 1 deletion apps/desktop/src/renderer/lib/agent-actions/diffStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/

import { diffLines } from 'diff';
import { piEditReplacements } from '@cindy/maker-shared';

export interface DiffStat {
add: number;
Expand Down Expand Up @@ -71,12 +72,24 @@ export function statsForToolCall(
return computeDiffStats(o, n);
}

if (toolName === 'Write') {
if (toolName === 'Write' || toolName === 'write') {
const c = typeof inp.content === 'string' ? inp.content : '';
// All-add: oldStr = ''. Surface as `+N -0` per ADR-5.
return computeDiffStats('', c);
}

// pi edit:两种入参形态(edits[] + legacy 顶层单段)由共享归一化器抹平后逐段求和。
if (toolName === 'edit') {
let add = 0;
let del = 0;
for (const edit of piEditReplacements(inp)) {
const s = computeDiffStats(edit.oldText, edit.newText);
add += s.add;
del += s.del;
}
return { add, del };
}

if (toolName === 'MultiEdit') {
const edits = Array.isArray(inp.edits) ? inp.edits : [];
let add = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ const TOOL_TO_VERB: Record<string, Verb> = {
WebFetch: 'fetched',
WebSearch: 'fetched',
web_search: 'fetched',
// pi 内置工具(全小写,见 toolUseDescriptor.ts 数据来源约定)。
bash: 'ran',
read: 'read',
ls: 'read',
edit: 'edited',
write: 'created',
grep: 'searched',
find: 'searched',
};

const ORDER: Verb[] = [
Expand Down
Loading