@hunterzhu/pulse-cli 0.4.4 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/resume.js +7 -2
- package/dist/commands/run.js +7 -2
- package/dist/components/ApprovalPrompt.js +1 -1
- package/dist/hooks/useRun.d.ts +3 -1
- package/dist/hooks/useRun.js +40 -33
- package/dist/release-highlights.generated.js +26 -0
- package/dist/types.d.ts +12 -0
- package/dist/utils/approval.js +11 -47
- package/dist/utils/tool-view.d.ts +17 -0
- package/dist/utils/tool-view.js +471 -0
- package/dist/utils/transcript.js +12 -33
- package/package.json +2 -2
package/dist/commands/resume.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createLocalHost } from '@hunterzhu/pulse-server';
|
|
2
|
+
import { consumeToolEvent } from '../utils/tool-view.js';
|
|
2
3
|
import { runInteractive } from './interactive.js';
|
|
3
4
|
import { bindRunSignals } from './signals.js';
|
|
4
|
-
function writeEvent(event, format) {
|
|
5
|
+
function writeEvent(event, format, printed) {
|
|
5
6
|
if (format === 'jsonl') {
|
|
6
7
|
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
7
8
|
return;
|
|
@@ -19,6 +20,9 @@ function writeEvent(event, format) {
|
|
|
19
20
|
else if (event.type === 'error') {
|
|
20
21
|
process.stderr.write(`\n[错误] ${String(event.data ?? '')}\n`);
|
|
21
22
|
}
|
|
23
|
+
const toolLines = consumeToolEvent(event, printed, process.stderr.columns || 80);
|
|
24
|
+
if (toolLines.length)
|
|
25
|
+
process.stderr.write(`${toolLines.join('\n')}\n`);
|
|
22
26
|
}
|
|
23
27
|
export async function runResume(options, conversationId, task, format = 'text', version = '0.4.1') {
|
|
24
28
|
// 交互式终端进入 Ink 界面;jsonl 保持非交互输出
|
|
@@ -36,10 +40,11 @@ export async function runResume(options, conversationId, task, format = 'text',
|
|
|
36
40
|
activeRun = run;
|
|
37
41
|
let streamedText = false;
|
|
38
42
|
let approvalCancelled = false;
|
|
43
|
+
const printedTools = new Set();
|
|
39
44
|
for await (const event of run.events) {
|
|
40
45
|
if (event.type === 'text')
|
|
41
46
|
streamedText = true;
|
|
42
|
-
writeEvent(event, format);
|
|
47
|
+
writeEvent(event, format, printedTools);
|
|
43
48
|
if (event.type === 'waiting' && !approvalCancelled) {
|
|
44
49
|
approvalCancelled = true;
|
|
45
50
|
process.stderr.write('\n[错误] 此运行处于非交互模式,无法请求工具审批;运行已取消。\n');
|
package/dist/commands/run.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createLocalHost } from '@hunterzhu/pulse-server';
|
|
2
|
+
import { consumeToolEvent } from '../utils/tool-view.js';
|
|
2
3
|
import { bindRunSignals } from './signals.js';
|
|
3
|
-
function writeEvent(event, format) {
|
|
4
|
+
function writeEvent(event, format, printed) {
|
|
4
5
|
if (format === 'jsonl') {
|
|
5
6
|
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
6
7
|
return;
|
|
@@ -18,6 +19,9 @@ function writeEvent(event, format) {
|
|
|
18
19
|
else if (event.type === 'error') {
|
|
19
20
|
process.stderr.write(`\n[错误] ${String(event.data ?? '')}\n`);
|
|
20
21
|
}
|
|
22
|
+
const toolLines = consumeToolEvent(event, printed, process.stderr.columns || 80);
|
|
23
|
+
if (toolLines.length)
|
|
24
|
+
process.stderr.write(`${toolLines.join('\n')}\n`);
|
|
21
25
|
}
|
|
22
26
|
export async function runOneShot(options, task, format = 'text', version = '0.4.1') {
|
|
23
27
|
const host = createLocalHost(options);
|
|
@@ -33,10 +37,11 @@ export async function runOneShot(options, task, format = 'text', version = '0.4.
|
|
|
33
37
|
activeRun = run;
|
|
34
38
|
let streamedText = false;
|
|
35
39
|
let approvalCancelled = false;
|
|
40
|
+
const printedTools = new Set();
|
|
36
41
|
for await (const event of run.events) {
|
|
37
42
|
if (event.type === 'text')
|
|
38
43
|
streamedText = true;
|
|
39
|
-
writeEvent(event, format);
|
|
44
|
+
writeEvent(event, format, printedTools);
|
|
40
45
|
if (event.type === 'waiting' && !approvalCancelled) {
|
|
41
46
|
approvalCancelled = true;
|
|
42
47
|
process.stderr.write('\n[错误] 此运行处于非交互模式,无法请求工具审批;运行已取消。\n');
|
|
@@ -7,7 +7,7 @@ export function ApprovalPrompt({ request, isFocused = false, inputMode = false,
|
|
|
7
7
|
const tools = request.tools ?? [{ name: request.toolName, input: request.toolArgs }];
|
|
8
8
|
const previews = tools.map((tool) => describeApprovalTool(tool));
|
|
9
9
|
const truncated = previews.some((preview) => preview.truncated);
|
|
10
|
-
return (_jsxs(Box, { borderStyle: "double", borderColor: theme.warning, flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 1, children: [_jsx(Text, { color: theme.warning, bold: true, children: "\u26A0 \u5BA1\u6279\u8BF7\u6C42" }), _jsx(Text, { color: theme.tool, children: request.toolName })] }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: previews.map((preview, index) => (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsx(Text, { color: theme.tool, bold: true, children: preview.name }), _jsx(Text, {
|
|
10
|
+
return (_jsxs(Box, { borderStyle: "double", borderColor: theme.warning, flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 1, children: [_jsx(Text, { color: theme.warning, bold: true, children: "\u26A0 \u5BA1\u6279\u8BF7\u6C42" }), _jsx(Text, { color: theme.tool, children: request.toolName })] }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: previews.map((preview, index) => (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsx(Text, { color: theme.tool, bold: true, children: preview.name }), _jsx(Text, { children: preview.body })] }, `${preview.name}-${index}`))) }), truncated && (_jsx(Text, { color: theme.warning, children: "\u9884\u89C8\u5DF2\u622A\u65AD\u3002\u9009\u62E9\u201C\u540C\u610F\u201D\u4F1A\u6267\u884C\u4E0A\u9762\u5217\u51FA\u7684\u5B8C\u6574\u64CD\u4F5C\uFF0C\u800C\u4E0D\u53EA\u662F\u53EF\u89C1\u7247\u6BB5\u3002" })), request.prompt && (_jsx(Box, { children: _jsx(Text, { children: request.prompt }) })), request.digest && _jsxs(Text, { color: theme.dim, children: ["\u6458\u8981\u6821\u9A8C\uFF1A", request.digest] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, children: isFocused
|
|
11
11
|
? '审批选择已聚焦(↑↓ 选择,Enter 确认;Tab 切换到输入;默认拒绝)'
|
|
12
12
|
: inputMode
|
|
13
13
|
? '输入模式:可发送补充信息;按 Tab 返回审批选择'
|
package/dist/hooks/useRun.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { LocalHost } from '@hunterzhu/pulse-server';
|
|
2
|
-
import type { ApprovalRequest, AskRequest, DisplayMessage, LaneDisplay } from '../types.js';
|
|
2
|
+
import type { ApprovalRequest, AskRequest, DisplayMessage, LaneDisplay, ToolCallDisplay } from '../types.js';
|
|
3
|
+
/** Running snapshots must not erase a settled call or replace known arguments with an empty object. */
|
|
4
|
+
export declare function mergeToolCall(current: ToolCallDisplay | undefined, next: ToolCallDisplay, source: 'running' | 'settled'): ToolCallDisplay;
|
|
3
5
|
export declare function describeFactStatus(data: unknown): string;
|
|
4
6
|
export declare function useRun({ host, conversationId, addAssistantMessage, }: {
|
|
5
7
|
host: LocalHost | null;
|
package/dist/hooks/useRun.js
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import { runningToolCalls, toolCallFromObservation } from '../utils/tool-view.js';
|
|
3
|
+
/** Running snapshots must not erase a settled call or replace known arguments with an empty object. */
|
|
4
|
+
export function mergeToolCall(current, next, source) {
|
|
5
|
+
if (!current)
|
|
6
|
+
return next;
|
|
7
|
+
if (source === 'running' && current.status !== 'running')
|
|
8
|
+
return current;
|
|
9
|
+
const hasNextArgs = next.arguments !== undefined && Object.keys(next.arguments).length > 0;
|
|
10
|
+
return {
|
|
11
|
+
...current,
|
|
12
|
+
...next,
|
|
13
|
+
arguments: hasNextArgs ? next.arguments : current.arguments,
|
|
14
|
+
...(next.preview === undefined && current.preview !== undefined ? { preview: current.preview } : {}),
|
|
15
|
+
...(next.result === undefined && current.result !== undefined ? { result: current.result } : {}),
|
|
16
|
+
status: source === 'running' ? 'running' : next.status,
|
|
17
|
+
};
|
|
6
18
|
}
|
|
7
19
|
export function describeFactStatus(data) {
|
|
8
20
|
if (typeof data === 'string') {
|
|
@@ -146,26 +158,17 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
146
158
|
addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls?.map((call) => ({ ...call })) });
|
|
147
159
|
break;
|
|
148
160
|
case 'observation': {
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
151
|
-
const id = String(obs.toolCallId ?? obs.tool);
|
|
152
|
-
const nextCall = {
|
|
153
|
-
id,
|
|
154
|
-
name: obs.tool,
|
|
155
|
-
arguments: obs.args && typeof obs.args === 'object' && !Array.isArray(obs.args)
|
|
156
|
-
? obs.args
|
|
157
|
-
: {},
|
|
158
|
-
status: toolStatus(obs.status),
|
|
159
|
-
...(obs.result === undefined ? {} : { result: obs.result }),
|
|
160
|
-
};
|
|
161
|
+
const nextCall = toolCallFromObservation(event.data);
|
|
162
|
+
if (nextCall) {
|
|
161
163
|
const calls = assistantMessage.toolCalls ?? [];
|
|
162
|
-
const index = calls.findIndex((call) => call.id === id);
|
|
164
|
+
const index = calls.findIndex((call) => call.id === nextCall.id);
|
|
165
|
+
const merged = mergeToolCall(index >= 0 ? calls[index] : undefined, nextCall, 'settled');
|
|
163
166
|
assistantMessage.toolCalls = index >= 0
|
|
164
|
-
? calls.map((call, callIndex) => callIndex === index ?
|
|
165
|
-
: [...calls,
|
|
167
|
+
? calls.map((call, callIndex) => callIndex === index ? merged : call)
|
|
168
|
+
: [...calls, merged];
|
|
166
169
|
addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls.map((call) => ({ ...call })) });
|
|
167
|
-
setCurrentStep(
|
|
168
|
-
addProgress(`tool-result:${id}:${
|
|
170
|
+
setCurrentStep(merged.status === 'succeeded' ? `已完成 ${merged.name},正在整理结果…` : merged.status === 'failed' ? `${merged.name} 未成功,正在处理错误…` : `正在执行 ${merged.name}…`);
|
|
171
|
+
addProgress(`tool-result:${merged.id}:${merged.status}`, `${merged.status === 'succeeded' ? '已完成' : merged.status === 'cancelled' ? '已取消' : '执行失败'} · ${merged.name}`);
|
|
169
172
|
}
|
|
170
173
|
break;
|
|
171
174
|
}
|
|
@@ -247,26 +250,30 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
247
250
|
}));
|
|
248
251
|
const activeLanes = fact.lanes.filter((lane) => typeof lane.status === 'string' && !['succeeded', 'failed', 'cancelled'].includes(lane.status));
|
|
249
252
|
const active = activeLanes.find((lane) => lane.activityKind === 'tool') ?? activeLanes[0];
|
|
253
|
+
const running = runningToolCalls(fact);
|
|
254
|
+
if (running.length) {
|
|
255
|
+
let calls = assistantMessage.toolCalls ?? [];
|
|
256
|
+
for (const nextCall of running) {
|
|
257
|
+
const index = calls.findIndex((call) => call.id === nextCall.id);
|
|
258
|
+
const merged = mergeToolCall(index >= 0 ? calls[index] : undefined, nextCall, 'running');
|
|
259
|
+
calls = index >= 0
|
|
260
|
+
? calls.map((call, callIndex) => callIndex === index ? merged : call)
|
|
261
|
+
: [...calls, merged];
|
|
262
|
+
}
|
|
263
|
+
assistantMessage.toolCalls = calls;
|
|
264
|
+
addAssistantMessage({ ...assistantMessage, toolCalls: calls.map((call) => ({ ...call })) });
|
|
265
|
+
}
|
|
250
266
|
if (active && typeof active.goal === 'string') {
|
|
251
|
-
const
|
|
267
|
+
const latest = running.at(-1);
|
|
268
|
+
const toolName = latest?.name ?? (active.activityKind === 'tool' && typeof active.activity === 'string' ? active.activity : undefined);
|
|
252
269
|
const activity = toolName ? `正在调用工具 · ${toolName}` : typeof active.activity === 'string'
|
|
253
270
|
? active.activity === 'llm' ? '正在分析需求和已有证据…' : '正在处理任务…'
|
|
254
271
|
: '正在处理任务…';
|
|
255
272
|
setCurrentStep(`${activity}:${active.goal}`);
|
|
256
273
|
const activityId = toolName
|
|
257
|
-
? (typeof active.activityEffectId === 'string' ? active.activityEffectId : active.id)
|
|
274
|
+
? (typeof active.activityEffectId === 'string' ? active.activityEffectId : latest?.id ?? active.id)
|
|
258
275
|
: active.activity === 'llm' ? 'analysis' : `${active.id}:${active.status}`;
|
|
259
276
|
addProgress(`activity:${activityId}`, activity);
|
|
260
|
-
if (toolName && typeof active.activityToolCallId === 'string') {
|
|
261
|
-
const id = active.activityToolCallId;
|
|
262
|
-
const calls = assistantMessage.toolCalls ?? [];
|
|
263
|
-
const index = calls.findIndex((call) => call.id === id);
|
|
264
|
-
const nextCall = { id, name: toolName, status: 'running', arguments: {} };
|
|
265
|
-
assistantMessage.toolCalls = index >= 0
|
|
266
|
-
? calls.map((call, callIndex) => callIndex === index ? { ...call, ...nextCall } : call)
|
|
267
|
-
: [...calls, nextCall];
|
|
268
|
-
addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls.map((call) => ({ ...call })) });
|
|
269
|
-
}
|
|
270
277
|
}
|
|
271
278
|
break;
|
|
272
279
|
}
|
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
// Generated from CHANGELOG.md and CHANGELOG.en.md by scripts/release/sync-cli-highlights.mjs. Do not edit manually.
|
|
2
2
|
export const releaseHighlights = {
|
|
3
|
+
"0.4.5": [
|
|
4
|
+
{
|
|
5
|
+
"zh": "复杂任务不再受全局模型调用次数上限截断,继续通过阶段、运行时限和循环检测控制执行;旧会话保持原有预算语义。",
|
|
6
|
+
"en": "Complex tasks are no longer cut off by a run-wide model-call ceiling; stage limits, runtime limits, and loop detection still bound execution, while older sessions retain their original budget semantics."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"zh": "大任务上下文从当前目标和阶段出发,沿依赖、证据和文件路径按需展开,同时保留用户要求及最近对话。",
|
|
10
|
+
"en": "Large-task context expands on demand from the current objective and stage along dependencies, evidence, and file paths, while preserving user requirements and the latest exchange."
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"zh": "CLI 展示每次工具调用的状态、文件修改 diff 和命令或读取预览,便于查看并行执行与审批内容。",
|
|
14
|
+
"en": "The CLI displays each tool call's status, file-edit diff, and command or read preview to make parallel execution and approval contents easier to inspect."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"zh": "上下文展开支持继续访问此前已到达的节点,并在恢复会话后保留展开范围;剩余消息和目标中的文件路径可以正确检索。",
|
|
18
|
+
"en": "Context expansion can continue from previously reached nodes after session restoration; remaining messages and file paths in the objective can be retrieved correctly."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"zh": "最终验收引用无效证据时,将具体错误反馈给重试,避免重复提交相同结果。",
|
|
22
|
+
"en": "Final acceptance feeds invalid evidence-reference errors back into retries to avoid repeatedly submitting the same result."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"zh": "暂存写入和追加内容可在审批中预览;长 diff 保留尾部修改,隐藏内容和空行正确标记为截断。",
|
|
26
|
+
"en": "Staged writes and appends include approval previews; long diffs retain tail edits, and hidden content and blank lines are correctly marked as truncated."
|
|
27
|
+
}
|
|
28
|
+
],
|
|
3
29
|
"0.4.4": [
|
|
4
30
|
{
|
|
5
31
|
"zh": "多阶段任务获得完整工具定义,并保留重试和依赖阶段的验收证据;补验证或补报告不再强制重复修改文件。",
|
package/dist/types.d.ts
CHANGED
|
@@ -26,12 +26,24 @@ export interface DisplayMessage {
|
|
|
26
26
|
/** Token 统计 */
|
|
27
27
|
tokenStats?: TokenStatsData | undefined;
|
|
28
28
|
}
|
|
29
|
+
/** Bounded settlement summary. File bodies stay out of this object. */
|
|
30
|
+
export interface ToolCallPreview {
|
|
31
|
+
bytes?: number | undefined;
|
|
32
|
+
lines?: number | undefined;
|
|
33
|
+
truncated?: boolean | undefined;
|
|
34
|
+
matched?: number | undefined;
|
|
35
|
+
locations?: string[] | undefined;
|
|
36
|
+
exitCode?: number | null | undefined;
|
|
37
|
+
output?: string | undefined;
|
|
38
|
+
replacements?: number | undefined;
|
|
39
|
+
}
|
|
29
40
|
/** 工具调用展示数据 */
|
|
30
41
|
export interface ToolCallDisplay {
|
|
31
42
|
id: string;
|
|
32
43
|
name: string;
|
|
33
44
|
arguments?: Record<string, unknown> | undefined;
|
|
34
45
|
result?: unknown;
|
|
46
|
+
preview?: ToolCallPreview | undefined;
|
|
35
47
|
status: 'running' | 'succeeded' | 'failed' | 'cancelled';
|
|
36
48
|
durationMs?: number | undefined;
|
|
37
49
|
}
|
package/dist/utils/approval.js
CHANGED
|
@@ -1,50 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
const previewLimit = 1_000;
|
|
3
|
-
function clip(value) {
|
|
4
|
-
const clean = stripTerminalControls(value);
|
|
5
|
-
if (clean.length <= previewLimit)
|
|
6
|
-
return { text: clean, truncated: false };
|
|
7
|
-
const hidden = clean.length - previewLimit;
|
|
8
|
-
return {
|
|
9
|
-
truncated: true,
|
|
10
|
-
text: `${clean.slice(0, previewLimit)}\n… 还有 ${hidden} 个字符未显示。批准会执行完整内容(共 ${clean.length} 个字符)。`,
|
|
11
|
-
};
|
|
12
|
-
}
|
|
13
|
-
function textField(input, key) {
|
|
14
|
-
const value = input[key];
|
|
15
|
-
return typeof value === 'string' ? value : value === undefined ? '' : JSON.stringify(value);
|
|
16
|
-
}
|
|
1
|
+
import { renderToolCalls } from './tool-view.js';
|
|
17
2
|
export function describeApprovalTool(tool) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const path = stripTerminalControls(textField(tool.input, 'path'));
|
|
21
|
-
return {
|
|
22
|
-
name: tool.name,
|
|
23
|
-
truncated: content.truncated,
|
|
24
|
-
body: `路径: ${path}\n内容(${textField(tool.input, 'content').length} 个字符):\n${content.text}`,
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
if (tool.name === 'fs.apply_patch') {
|
|
28
|
-
const find = clip(textField(tool.input, 'find'));
|
|
29
|
-
const replace = clip(textField(tool.input, 'replace'));
|
|
30
|
-
const path = stripTerminalControls(textField(tool.input, 'path'));
|
|
31
|
-
const all = tool.input.all === true ? '全部匹配' : '第一处匹配';
|
|
32
|
-
return {
|
|
33
|
-
name: tool.name,
|
|
34
|
-
truncated: find.truncated || replace.truncated,
|
|
35
|
-
body: `路径: ${path}\n范围: ${all}\n- ${find.text}\n+ ${replace.text}`,
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
if (tool.name === 'shell.exec') {
|
|
39
|
-
const command = stripTerminalControls(textField(tool.input, 'command'));
|
|
40
|
-
const args = Array.isArray(tool.input.args) ? tool.input.args.map((arg) => stripTerminalControls(String(arg))) : [];
|
|
41
|
-
const cwd = stripTerminalControls(textField(tool.input, 'cwd') || '.');
|
|
42
|
-
return {
|
|
3
|
+
const view = renderToolCalls([{
|
|
4
|
+
id: 'approval',
|
|
43
5
|
name: tool.name,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
6
|
+
arguments: tool.input,
|
|
7
|
+
status: 'running',
|
|
8
|
+
}], { width: 100 });
|
|
9
|
+
const hidden = Math.max(0, view.totalChars - view.shownChars);
|
|
10
|
+
const notice = view.truncated
|
|
11
|
+
? `\n… 还有 ${hidden} 个字符未显示。批准会执行完整内容(共 ${view.totalChars} 个字符)。`
|
|
12
|
+
: '';
|
|
13
|
+
return { name: tool.name, truncated: view.truncated, body: `${view.lines.join('\n')}${notice}` };
|
|
50
14
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ToolCallDisplay, Verbosity } from '../types.js';
|
|
2
|
+
export interface ToolView {
|
|
3
|
+
lines: string[];
|
|
4
|
+
truncated: boolean;
|
|
5
|
+
totalChars: number;
|
|
6
|
+
shownChars: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function renderToolCalls(calls: ToolCallDisplay[], options?: {
|
|
9
|
+
verbosity?: Verbosity;
|
|
10
|
+
width?: number;
|
|
11
|
+
}): ToolView;
|
|
12
|
+
export declare function toolCallFromObservation(data: unknown): ToolCallDisplay | undefined;
|
|
13
|
+
export declare function runningToolCalls(data: unknown): ToolCallDisplay[];
|
|
14
|
+
export declare function consumeToolEvent(event: {
|
|
15
|
+
type: string;
|
|
16
|
+
data?: unknown;
|
|
17
|
+
}, printed: Set<string>, width: number): string[];
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import wrapAnsi from 'wrap-ansi';
|
|
3
|
+
import { theme } from '../theme.js';
|
|
4
|
+
import { stripAnsi, stripTerminalControls } from './ansi.js';
|
|
5
|
+
const verbs = {
|
|
6
|
+
'fs.read': '读取',
|
|
7
|
+
'fs.search': '搜索',
|
|
8
|
+
'fs.list': '列出',
|
|
9
|
+
'fs.write': '新建',
|
|
10
|
+
'fs.apply_patch': '编辑',
|
|
11
|
+
'fs.apply_patches': '编辑',
|
|
12
|
+
'fs.stage': '写入',
|
|
13
|
+
'fs.move': '移动',
|
|
14
|
+
'shell.exec': '运行',
|
|
15
|
+
'web.fetch': '获取',
|
|
16
|
+
'web.search': '检索',
|
|
17
|
+
};
|
|
18
|
+
const lineCap = 24;
|
|
19
|
+
const lineWidth = 160;
|
|
20
|
+
const contextLines = 2;
|
|
21
|
+
export function renderToolCalls(calls, options = {}) {
|
|
22
|
+
const width = Math.max(8, options.width ?? 80);
|
|
23
|
+
const views = calls.map((call) => renderToolCall(call, { ...options, width }));
|
|
24
|
+
return {
|
|
25
|
+
lines: views.flatMap((view) => view.lines),
|
|
26
|
+
truncated: views.some((view) => view.truncated),
|
|
27
|
+
totalChars: views.reduce((sum, view) => sum + view.totalChars, 0),
|
|
28
|
+
shownChars: views.reduce((sum, view) => sum + view.shownChars, 0),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function toolCallFromObservation(data) {
|
|
32
|
+
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
33
|
+
return undefined;
|
|
34
|
+
const obs = data;
|
|
35
|
+
if (typeof obs.tool !== 'string')
|
|
36
|
+
return undefined;
|
|
37
|
+
const preview = parsePreview(obs.preview);
|
|
38
|
+
return {
|
|
39
|
+
id: String(obs.toolCallId ?? obs.tool),
|
|
40
|
+
name: obs.tool,
|
|
41
|
+
arguments: record(obs.args),
|
|
42
|
+
status: toolStatus(obs.status),
|
|
43
|
+
...(obs.result === undefined ? {} : { result: obs.result }),
|
|
44
|
+
...(preview === undefined ? {} : { preview }),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function runningToolCalls(data) {
|
|
48
|
+
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
49
|
+
return [];
|
|
50
|
+
const fact = data;
|
|
51
|
+
if (fact.type !== 'lane.snapshot' || !Array.isArray(fact.lanes))
|
|
52
|
+
return [];
|
|
53
|
+
const calls = [];
|
|
54
|
+
for (const lane of fact.lanes) {
|
|
55
|
+
if (!lane || typeof lane !== 'object' || Array.isArray(lane))
|
|
56
|
+
continue;
|
|
57
|
+
const item = lane;
|
|
58
|
+
const activities = Array.isArray(item.activities) ? item.activities : [item];
|
|
59
|
+
for (const activity of activities) {
|
|
60
|
+
if (!activity || typeof activity !== 'object' || Array.isArray(activity))
|
|
61
|
+
continue;
|
|
62
|
+
const row = activity;
|
|
63
|
+
if (row.activityKind !== 'tool' || typeof row.activity !== 'string' || typeof row.activityToolCallId !== 'string')
|
|
64
|
+
continue;
|
|
65
|
+
calls.push({
|
|
66
|
+
id: row.activityToolCallId,
|
|
67
|
+
name: row.activity,
|
|
68
|
+
status: 'running',
|
|
69
|
+
arguments: record(row.activityArguments),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return calls;
|
|
74
|
+
}
|
|
75
|
+
export function consumeToolEvent(event, printed, width) {
|
|
76
|
+
const calls = event.type === 'observation'
|
|
77
|
+
? [toolCallFromObservation(event.data)].filter((call) => call !== undefined)
|
|
78
|
+
: event.type === 'fact'
|
|
79
|
+
? runningToolCalls(event.data)
|
|
80
|
+
: [];
|
|
81
|
+
const lines = [];
|
|
82
|
+
for (const call of calls) {
|
|
83
|
+
const key = `${call.id}\0${call.status}\0${JSON.stringify(call.arguments ?? {})}\0${call.preview?.output ?? ''}`;
|
|
84
|
+
if (printed.has(key))
|
|
85
|
+
continue;
|
|
86
|
+
printed.add(key);
|
|
87
|
+
lines.push(...renderToolCalls([call], { width }).lines);
|
|
88
|
+
}
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
function renderToolCall(call, options) {
|
|
92
|
+
const args = call.arguments ?? {};
|
|
93
|
+
const source = editableText(args);
|
|
94
|
+
const width = options.width;
|
|
95
|
+
const header = fit(headerLine(call), width);
|
|
96
|
+
const detail = detailLines(call, width);
|
|
97
|
+
const extra = options.verbosity === 'verbose' && call.result !== undefined ? resultLines(call.result, width) : [];
|
|
98
|
+
const lines = [...header, ...detail.lines, ...extra];
|
|
99
|
+
const truncated = detail.truncated;
|
|
100
|
+
return {
|
|
101
|
+
lines,
|
|
102
|
+
truncated: detail.shownChars < source.length || truncated,
|
|
103
|
+
totalChars: source.length,
|
|
104
|
+
shownChars: detail.shownChars,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function headerLine(call) {
|
|
108
|
+
const glyph = call.status === 'running'
|
|
109
|
+
? chalk.yellow('◐')
|
|
110
|
+
: call.status === 'failed'
|
|
111
|
+
? chalk.red('✗')
|
|
112
|
+
: call.status === 'cancelled'
|
|
113
|
+
? chalk.dim('⊘')
|
|
114
|
+
: chalk.green('●');
|
|
115
|
+
const verb = chalk.hex(theme.tool)(verbs[call.name] ?? call.name);
|
|
116
|
+
const target = targetText(call);
|
|
117
|
+
const stat = diffStat(call);
|
|
118
|
+
return `${glyph} ${verb}${target ? ` ${chalk.dim(target)}` : ''}${stat ? ` ${stat}` : ''}`;
|
|
119
|
+
}
|
|
120
|
+
function targetText(call) {
|
|
121
|
+
const args = call.arguments ?? {};
|
|
122
|
+
if (call.name === 'shell.exec')
|
|
123
|
+
return shellCommand(args);
|
|
124
|
+
if (call.name === 'fs.move')
|
|
125
|
+
return joinPair(text(args, 'source'), text(args, 'destination'), ' → ');
|
|
126
|
+
if (call.name === 'fs.stage')
|
|
127
|
+
return stageTarget(args);
|
|
128
|
+
if (call.name === 'web.fetch')
|
|
129
|
+
return text(args, 'url');
|
|
130
|
+
if (call.name === 'web.search' || call.name === 'fs.search')
|
|
131
|
+
return text(args, 'query');
|
|
132
|
+
return text(args, 'path');
|
|
133
|
+
}
|
|
134
|
+
function detailLines(call, width) {
|
|
135
|
+
const args = call.arguments ?? {};
|
|
136
|
+
const failure = failureText(call);
|
|
137
|
+
const failed = failure ? fit(` └ ${failure}`, width) : [];
|
|
138
|
+
if (call.name === 'fs.apply_patch' || call.name === 'fs.apply_patches' || isContentWrite(call)) {
|
|
139
|
+
const diff = editLines(call, width);
|
|
140
|
+
return { lines: [...diff.lines, ...failed], shownChars: diff.shownChars, truncated: diff.truncated };
|
|
141
|
+
}
|
|
142
|
+
const lines = [];
|
|
143
|
+
if (call.name === 'shell.exec') {
|
|
144
|
+
const cwd = text(args, 'cwd');
|
|
145
|
+
if (cwd && cwd !== '.')
|
|
146
|
+
lines.push(...fit(` └ 目录 ${cwd}`, width));
|
|
147
|
+
const output = call.preview?.output ? stripTerminalControls(call.preview.output) : '';
|
|
148
|
+
if (output)
|
|
149
|
+
lines.push(...prefixBlock(output, width));
|
|
150
|
+
if (failed.length)
|
|
151
|
+
lines.push(...failed);
|
|
152
|
+
else if (call.status === 'succeeded' && !output)
|
|
153
|
+
lines.push(...fit(' └ 已完成', width));
|
|
154
|
+
if (call.preview?.truncated)
|
|
155
|
+
lines.push(...fit(chalk.dim(' └ 输出已截断'), width));
|
|
156
|
+
return { lines, shownChars: editableText(args).length, truncated: false };
|
|
157
|
+
}
|
|
158
|
+
if (failed.length)
|
|
159
|
+
return { lines: failed, shownChars: editableText(args).length, truncated: false };
|
|
160
|
+
const summary = summaryLine(call);
|
|
161
|
+
if (summary)
|
|
162
|
+
lines.push(...fit(` └ ${summary}`, width));
|
|
163
|
+
for (const location of call.preview?.locations ?? [])
|
|
164
|
+
lines.push(...fit(chalk.dim(` ${stripTerminalControls(location)}`), width));
|
|
165
|
+
if (!lines.length && call.name !== 'fs.read' && call.name !== 'fs.search' && call.name !== 'fs.list' && call.name !== 'fs.stage' && call.name !== 'fs.move' && call.name !== 'web.fetch' && call.name !== 'web.search') {
|
|
166
|
+
const dumped = dumpArgs(args);
|
|
167
|
+
if (dumped.clipped)
|
|
168
|
+
return { lines: fit(chalk.dim(dumped.text), width), shownChars: 0, truncated: true };
|
|
169
|
+
if (dumped.text && dumped.text !== '{}')
|
|
170
|
+
return { lines: fit(chalk.dim(dumped.text), width), shownChars: editableText(args).length, truncated: false };
|
|
171
|
+
}
|
|
172
|
+
return { lines, shownChars: editableText(args).length, truncated: false };
|
|
173
|
+
}
|
|
174
|
+
function isContentWrite(call) {
|
|
175
|
+
return call.name === 'fs.write' || call.name === 'fs.stage' && typeof call.arguments?.content === 'string';
|
|
176
|
+
}
|
|
177
|
+
function editLines(call, width) {
|
|
178
|
+
const args = call.arguments ?? {};
|
|
179
|
+
const hunks = isContentWrite(call)
|
|
180
|
+
? [{ find: '', replace: text(args, 'content') }]
|
|
181
|
+
: call.name === 'fs.apply_patches'
|
|
182
|
+
? patches(args)
|
|
183
|
+
: [{ find: text(args, 'find'), replace: text(args, 'replace') }];
|
|
184
|
+
const source = editableText(args);
|
|
185
|
+
const rendered = [];
|
|
186
|
+
let hidden = 0;
|
|
187
|
+
let omittedChars = 0;
|
|
188
|
+
if (args.all === true)
|
|
189
|
+
rendered.push(...fit(chalk.dim(' └ 全部匹配'), width));
|
|
190
|
+
for (const [index, hunk] of hunks.entries()) {
|
|
191
|
+
if (index > 0)
|
|
192
|
+
rendered.push(chalk.dim(' @@'));
|
|
193
|
+
const ops = hunk.find === '' && isContentWrite(call)
|
|
194
|
+
? hunk.replace.split('\n').map((line, lineIndex) => ({ op: '+', text: line, line: lineIndex + 1 }))
|
|
195
|
+
: diffLines(hunk.find, hunk.replace);
|
|
196
|
+
const window = windowOps(ops);
|
|
197
|
+
hidden += window.hidden;
|
|
198
|
+
for (const op of ops)
|
|
199
|
+
if (!window.shown.includes(op))
|
|
200
|
+
omittedChars += op.text.length + 1;
|
|
201
|
+
for (const op of window.shown) {
|
|
202
|
+
const visible = clipLine(stripTerminalControls(op.text));
|
|
203
|
+
if (visible.clipped)
|
|
204
|
+
omittedChars += Math.max(0, stripTerminalControls(op.text).length - lineWidth);
|
|
205
|
+
const sign = op.op === ' ' ? ' ' : op.op;
|
|
206
|
+
const body = `${String(op.line).padStart(4)} │${sign} ${visible.text}`;
|
|
207
|
+
const colored = op.op === '+' ? chalk.green(body) : op.op === '-' ? chalk.red(body) : chalk.dim(body);
|
|
208
|
+
rendered.push(...fit(` ${colored}`, width));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (hidden > 0)
|
|
212
|
+
rendered.push(...fit(chalk.dim(` … 还有 ${hidden} 行`), width));
|
|
213
|
+
return { lines: rendered, shownChars: Math.max(0, source.length - omittedChars), truncated: hidden > 0 || omittedChars > 0 };
|
|
214
|
+
}
|
|
215
|
+
function summaryLine(call) {
|
|
216
|
+
const preview = call.preview;
|
|
217
|
+
if (!preview)
|
|
218
|
+
return call.status === 'succeeded' && call.name === 'fs.stage' ? '已完成' : '';
|
|
219
|
+
if (call.name === 'fs.read' || call.name === 'web.fetch') {
|
|
220
|
+
const size = typeof preview.bytes === 'number' ? formatBytes(preview.bytes) : '';
|
|
221
|
+
const lines = typeof preview.lines === 'number' ? `${preview.lines} 行` : '';
|
|
222
|
+
return [lines, size, preview.truncated ? '未读完' : ''].filter(Boolean).join(' · ');
|
|
223
|
+
}
|
|
224
|
+
if (call.name === 'fs.search' || call.name === 'web.search')
|
|
225
|
+
return `${preview.matched ?? preview.locations?.length ?? 0} 处`;
|
|
226
|
+
if (call.name === 'fs.list')
|
|
227
|
+
return `${preview.matched ?? preview.locations?.length ?? 0} 项`;
|
|
228
|
+
if (typeof preview.replacements === 'number' && preview.replacements > 1)
|
|
229
|
+
return `${preview.replacements} 处`;
|
|
230
|
+
return '';
|
|
231
|
+
}
|
|
232
|
+
function failureText(call) {
|
|
233
|
+
if (call.status !== 'failed' && call.status !== 'cancelled')
|
|
234
|
+
return '';
|
|
235
|
+
const label = call.status === 'cancelled' ? '已取消' : '失败';
|
|
236
|
+
const result = call.result;
|
|
237
|
+
if (typeof result === 'string' && result)
|
|
238
|
+
return `${label} · ${stripTerminalControls(result)}`;
|
|
239
|
+
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
|
240
|
+
const record = result;
|
|
241
|
+
const message = typeof record.message === 'string' ? record.message : undefined;
|
|
242
|
+
const code = typeof record.code === 'string' ? record.code : undefined;
|
|
243
|
+
const detail = message ?? code;
|
|
244
|
+
if (detail)
|
|
245
|
+
return `${label} · ${stripTerminalControls(detail)}`;
|
|
246
|
+
}
|
|
247
|
+
return label;
|
|
248
|
+
}
|
|
249
|
+
function resultLines(result, width) {
|
|
250
|
+
const text = stripTerminalControls(typeof result === 'string' ? result : safeJson(result));
|
|
251
|
+
const clipped = text.length > 500 ? `${text.slice(0, 500)}…` : text;
|
|
252
|
+
return fit(chalk.dim(clipped), width);
|
|
253
|
+
}
|
|
254
|
+
function diffStat(call) {
|
|
255
|
+
if (call.name !== 'fs.apply_patch' && call.name !== 'fs.apply_patches' && !isContentWrite(call))
|
|
256
|
+
return '';
|
|
257
|
+
const counts = countEdits(call);
|
|
258
|
+
if (counts.add === 0 && counts.del === 0)
|
|
259
|
+
return '';
|
|
260
|
+
return `${chalk.green(`+${counts.add}`)} ${chalk.red(`−${counts.del}`)}`;
|
|
261
|
+
}
|
|
262
|
+
function countEdits(call) {
|
|
263
|
+
const args = call.arguments ?? {};
|
|
264
|
+
const hunks = isContentWrite(call)
|
|
265
|
+
? [{ find: '', replace: text(args, 'content') }]
|
|
266
|
+
: call.name === 'fs.apply_patches' ? patches(args) : [{ find: text(args, 'find'), replace: text(args, 'replace') }];
|
|
267
|
+
return hunks.reduce((sum, hunk) => {
|
|
268
|
+
if (isContentWrite(call))
|
|
269
|
+
return { add: sum.add + hunk.replace.split('\n').filter((line) => line.length > 0).length, del: sum.del };
|
|
270
|
+
for (const op of diffLines(hunk.find, hunk.replace)) {
|
|
271
|
+
if (op.op === '+')
|
|
272
|
+
sum.add += 1;
|
|
273
|
+
if (op.op === '-')
|
|
274
|
+
sum.del += 1;
|
|
275
|
+
}
|
|
276
|
+
return sum;
|
|
277
|
+
}, { add: 0, del: 0 });
|
|
278
|
+
}
|
|
279
|
+
function diffLines(before, after) {
|
|
280
|
+
return diffLineArrays(before.split('\n'), after.split('\n'));
|
|
281
|
+
}
|
|
282
|
+
function diffLineArrays(left, right) {
|
|
283
|
+
if (left.length * right.length > 20_000) {
|
|
284
|
+
// Trim shared edges before the quadratic diff so a long fragment with one
|
|
285
|
+
// tail edit still displays that edit, rather than an unrelated prefix.
|
|
286
|
+
let prefix = 0;
|
|
287
|
+
while (prefix < left.length && prefix < right.length && left[prefix] === right[prefix])
|
|
288
|
+
prefix++;
|
|
289
|
+
let suffix = 0;
|
|
290
|
+
while (suffix < left.length - prefix && suffix < right.length - prefix && left[left.length - suffix - 1] === right[right.length - suffix - 1])
|
|
291
|
+
suffix++;
|
|
292
|
+
if (prefix || suffix)
|
|
293
|
+
return [
|
|
294
|
+
...left.slice(0, prefix).map((text, index) => ({ op: ' ', text, line: index + 1 })),
|
|
295
|
+
...diffLineArrays(left.slice(prefix, left.length - suffix), right.slice(prefix, right.length - suffix)).map((op) => ({ ...op, line: op.line + prefix })),
|
|
296
|
+
...left.slice(left.length - suffix).map((text, index) => ({ op: ' ', text, line: left.length - suffix + index + 1 })),
|
|
297
|
+
];
|
|
298
|
+
// A linear fallback retains every operation; only the display window clips
|
|
299
|
+
// them, so hidden lines and the approval truncation warning stay accurate.
|
|
300
|
+
return [
|
|
301
|
+
...left.map((text, index) => ({ op: '-', text, line: index + 1 })),
|
|
302
|
+
...right.map((text, index) => ({ op: '+', text, line: index + 1 })),
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
const scores = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0));
|
|
306
|
+
for (let i = left.length - 1; i >= 0; i -= 1) {
|
|
307
|
+
for (let j = right.length - 1; j >= 0; j -= 1) {
|
|
308
|
+
scores[i][j] = left[i] === right[j] ? scores[i + 1][j + 1] + 1 : Math.max(scores[i + 1][j], scores[i][j + 1]);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const ops = [];
|
|
312
|
+
let i = 0;
|
|
313
|
+
let j = 0;
|
|
314
|
+
while (i < left.length && j < right.length) {
|
|
315
|
+
if (left[i] === right[j]) {
|
|
316
|
+
ops.push({ op: ' ', text: left[i], line: i + 1 });
|
|
317
|
+
i += 1;
|
|
318
|
+
j += 1;
|
|
319
|
+
}
|
|
320
|
+
else if (scores[i + 1][j] >= scores[i][j + 1]) {
|
|
321
|
+
ops.push({ op: '-', text: left[i], line: i + 1 });
|
|
322
|
+
i += 1;
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
ops.push({ op: '+', text: right[j], line: j + 1 });
|
|
326
|
+
j += 1;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
while (i < left.length) {
|
|
330
|
+
ops.push({ op: '-', text: left[i], line: i + 1 });
|
|
331
|
+
i += 1;
|
|
332
|
+
}
|
|
333
|
+
while (j < right.length) {
|
|
334
|
+
ops.push({ op: '+', text: right[j], line: j + 1 });
|
|
335
|
+
j += 1;
|
|
336
|
+
}
|
|
337
|
+
return ops;
|
|
338
|
+
}
|
|
339
|
+
function windowOps(ops) {
|
|
340
|
+
const keep = new Set();
|
|
341
|
+
ops.forEach((op, index) => {
|
|
342
|
+
if (op.op === ' ')
|
|
343
|
+
return;
|
|
344
|
+
for (let cursor = index - contextLines; cursor <= index + contextLines; cursor += 1)
|
|
345
|
+
if (cursor >= 0 && cursor < ops.length)
|
|
346
|
+
keep.add(cursor);
|
|
347
|
+
});
|
|
348
|
+
const interesting = keep.size ? ops.filter((_, index) => keep.has(index)) : ops.slice(0, contextLines);
|
|
349
|
+
if (interesting.length <= lineCap)
|
|
350
|
+
return { shown: interesting, hidden: ops.length - interesting.length };
|
|
351
|
+
return { shown: interesting.slice(0, lineCap), hidden: ops.length - lineCap };
|
|
352
|
+
}
|
|
353
|
+
function patches(args) {
|
|
354
|
+
if (!Array.isArray(args.patches))
|
|
355
|
+
return [];
|
|
356
|
+
return args.patches.flatMap((patch) => {
|
|
357
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch))
|
|
358
|
+
return [];
|
|
359
|
+
const row = patch;
|
|
360
|
+
return [{ find: text(row, 'find'), replace: text(row, 'replace') }];
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
function shellCommand(args) {
|
|
364
|
+
const command = text(args, 'command');
|
|
365
|
+
const values = Array.isArray(args.args) ? args.args.map((arg) => shellToken(stripTerminalControls(String(arg)))) : [];
|
|
366
|
+
return [command, ...values].filter(Boolean).join(' ');
|
|
367
|
+
}
|
|
368
|
+
function stageTarget(args) {
|
|
369
|
+
const operation = text(args, 'operation') || text(args, 'op');
|
|
370
|
+
const path = text(args, 'path');
|
|
371
|
+
const draft = text(args, 'draftId');
|
|
372
|
+
if (operation && path)
|
|
373
|
+
return `${operation} ${path}`;
|
|
374
|
+
if (operation)
|
|
375
|
+
return draft ? `${operation} ${draft.slice(0, 8)}` : operation;
|
|
376
|
+
return path;
|
|
377
|
+
}
|
|
378
|
+
function prefixBlock(output, width) {
|
|
379
|
+
const rows = output.split('\n');
|
|
380
|
+
return rows.flatMap((row, index) => fit(`${index === 0 ? ' └ ' : ' '}${row}`, width));
|
|
381
|
+
}
|
|
382
|
+
function formatBytes(bytes) {
|
|
383
|
+
if (bytes < 1024)
|
|
384
|
+
return `${bytes} B`;
|
|
385
|
+
const kilobytes = bytes / 1024;
|
|
386
|
+
return kilobytes < 10 ? `${kilobytes.toFixed(1)} KB` : `${Math.round(kilobytes)} KB`;
|
|
387
|
+
}
|
|
388
|
+
function editableText(args) {
|
|
389
|
+
const parts = [];
|
|
390
|
+
if (typeof args.content === 'string')
|
|
391
|
+
parts.push(args.content);
|
|
392
|
+
if (typeof args.find === 'string')
|
|
393
|
+
parts.push(args.find);
|
|
394
|
+
if (typeof args.replace === 'string')
|
|
395
|
+
parts.push(args.replace);
|
|
396
|
+
for (const hunk of patches(args))
|
|
397
|
+
parts.push(hunk.find, hunk.replace);
|
|
398
|
+
return parts.join('');
|
|
399
|
+
}
|
|
400
|
+
function dumpArgs(args) {
|
|
401
|
+
const visible = Object.fromEntries(Object.entries(args).filter(([key]) => key !== 'expectedHash' && key !== 'hash'));
|
|
402
|
+
const text = safeJson(visible);
|
|
403
|
+
if (text.length <= 1_000)
|
|
404
|
+
return { text, clipped: false };
|
|
405
|
+
return { text: `${text.slice(0, 1_000)}\n…`, clipped: true };
|
|
406
|
+
}
|
|
407
|
+
function clipLine(value) {
|
|
408
|
+
if (value.length <= lineWidth)
|
|
409
|
+
return { text: value, clipped: false };
|
|
410
|
+
return { text: `${value.slice(0, lineWidth)}…`, clipped: true };
|
|
411
|
+
}
|
|
412
|
+
function text(args, key) {
|
|
413
|
+
const value = args[key];
|
|
414
|
+
return typeof value === 'string' ? stripTerminalControls(value) : '';
|
|
415
|
+
}
|
|
416
|
+
function joinPair(left, right, separator) {
|
|
417
|
+
return [left, right].filter(Boolean).join(separator);
|
|
418
|
+
}
|
|
419
|
+
function shellToken(value) {
|
|
420
|
+
if (value.length === 0)
|
|
421
|
+
return "''";
|
|
422
|
+
if (/^[\w./:@%+=,~-]+$/.test(value))
|
|
423
|
+
return value;
|
|
424
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
425
|
+
}
|
|
426
|
+
function fit(line, width) {
|
|
427
|
+
if (stripAnsi(line).length <= width)
|
|
428
|
+
return [line];
|
|
429
|
+
return wrapAnsi(line, width, { hard: true, trim: false }).split('\n');
|
|
430
|
+
}
|
|
431
|
+
function record(value) {
|
|
432
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
433
|
+
return {};
|
|
434
|
+
return value;
|
|
435
|
+
}
|
|
436
|
+
function toolStatus(value) {
|
|
437
|
+
if (value === 'succeeded' || value === 'failed' || value === 'running' || value === 'cancelled')
|
|
438
|
+
return value;
|
|
439
|
+
return 'running';
|
|
440
|
+
}
|
|
441
|
+
function parsePreview(value) {
|
|
442
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
443
|
+
return undefined;
|
|
444
|
+
const row = value;
|
|
445
|
+
const preview = {};
|
|
446
|
+
if (typeof row.bytes === 'number')
|
|
447
|
+
preview.bytes = row.bytes;
|
|
448
|
+
if (typeof row.lines === 'number')
|
|
449
|
+
preview.lines = row.lines;
|
|
450
|
+
if (row.truncated === true)
|
|
451
|
+
preview.truncated = true;
|
|
452
|
+
if (typeof row.matched === 'number')
|
|
453
|
+
preview.matched = row.matched;
|
|
454
|
+
if (Array.isArray(row.locations))
|
|
455
|
+
preview.locations = row.locations.filter((item) => typeof item === 'string').slice(0, 5);
|
|
456
|
+
if (typeof row.exitCode === 'number' || row.exitCode === null)
|
|
457
|
+
preview.exitCode = row.exitCode;
|
|
458
|
+
if (typeof row.output === 'string')
|
|
459
|
+
preview.output = row.output;
|
|
460
|
+
if (typeof row.replacements === 'number')
|
|
461
|
+
preview.replacements = row.replacements;
|
|
462
|
+
return Object.keys(preview).length ? preview : undefined;
|
|
463
|
+
}
|
|
464
|
+
function safeJson(value) {
|
|
465
|
+
try {
|
|
466
|
+
return JSON.stringify(value, null, 2) ?? '';
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
return String(value);
|
|
470
|
+
}
|
|
471
|
+
}
|
package/dist/utils/transcript.js
CHANGED
|
@@ -2,6 +2,7 @@ import wrapAnsi from 'wrap-ansi';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { stripTerminalControls } from './ansi.js';
|
|
4
4
|
import { renderMarkdownToAnsi } from './markdown.js';
|
|
5
|
+
import { renderToolCalls } from './tool-view.js';
|
|
5
6
|
/** One shared rendering and wrapping path; scroll offsets are actual terminal rows. */
|
|
6
7
|
export function transcriptLines(messages, width, verbosity = 'normal') {
|
|
7
8
|
const blocks = messages.map((message) => {
|
|
@@ -13,40 +14,10 @@ export function transcriptLines(messages, width, verbosity = 'normal') {
|
|
|
13
14
|
const body = wrapAnsi(text, contentWidth, { hard: true, trim: false }).split('\n');
|
|
14
15
|
return { id: message.id, role: message.role, lines: [chalk.blue.bold('❯ 你发来'), ...body.map((line) => ` ${line}`)] };
|
|
15
16
|
}
|
|
16
|
-
const parts = [];
|
|
17
17
|
const tools = message.toolCalls ?? [];
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const statusLabel = { running: '正在调用', succeeded: '已完成', failed: '失败', cancelled: '已取消' };
|
|
22
|
-
if (verbosity === 'verbose') {
|
|
23
|
-
for (const tool of tools) {
|
|
24
|
-
const detail = `${tool.name} · ${statusLabel[tool.status]}\n${JSON.stringify(tool.arguments ?? {}, null, 2)}${tool.result === undefined ? '' : `\n${typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2)}`}`;
|
|
25
|
-
parts.push(chalk.dim(stripTerminalControls(detail)));
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
else {
|
|
29
|
-
const groups = new Map();
|
|
30
|
-
for (const tool of tools) {
|
|
31
|
-
const result = tool.result && typeof tool.result === 'object' && !Array.isArray(tool.result) ? tool.result : undefined;
|
|
32
|
-
const error = tool.status === 'failed' || tool.status === 'cancelled'
|
|
33
|
-
? [result?.code, result?.message].filter((value) => typeof value === 'string').at(-1)
|
|
34
|
-
: undefined;
|
|
35
|
-
const key = `${tool.name}\0${tool.status}\0${error ?? ''}`;
|
|
36
|
-
const group = groups.get(key);
|
|
37
|
-
if (group)
|
|
38
|
-
group.count++;
|
|
39
|
-
else
|
|
40
|
-
groups.set(key, { name: tool.name, status: tool.status, count: 1, ...(error ? { error } : {}) });
|
|
41
|
-
}
|
|
42
|
-
for (const group of groups.values()) {
|
|
43
|
-
parts.push(chalk.dim(`${group.name}${group.count > 1 ? ` × ${group.count}` : ''} · ${statusLabel[group.status]}${group.error ? ` · ${group.error}` : ''}`));
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
if (text)
|
|
48
|
-
parts.push(renderMarkdownToAnsi(text));
|
|
49
|
-
const body = wrapAnsi(parts.join('\n'), contentWidth, { hard: true, trim: false }).split('\n');
|
|
18
|
+
const toolLines = tools.length ? toolSummary(tools, verbosity, contentWidth) : [];
|
|
19
|
+
const textLines = text ? wrapAnsi(renderMarkdownToAnsi(text), contentWidth, { hard: true, trim: false }).split('\n') : [];
|
|
20
|
+
const body = [...toolLines, ...(toolLines.length && textLines.length ? [''] : []), ...textLines];
|
|
50
21
|
const streamLabel = message.streamStatus === 'streaming' ? chalk.yellow(' · 正在生成') : message.streamStatus === 'incomplete' ? chalk.red(' · 未完成') : '';
|
|
51
22
|
return { id: message.id, role: message.role, lines: [chalk.cyan.bold('Pulse') + streamLabel, ...body.map((line) => ` ${line}`)] };
|
|
52
23
|
}).filter((block) => block.lines.length > 0);
|
|
@@ -59,6 +30,14 @@ export function transcriptLines(messages, width, verbosity = 'normal') {
|
|
|
59
30
|
return [...block.lines, ...separator];
|
|
60
31
|
});
|
|
61
32
|
}
|
|
33
|
+
function toolSummary(tools, verbosity, width) {
|
|
34
|
+
if (verbosity === 'quiet') {
|
|
35
|
+
const failed = tools.filter((tool) => tool.status === 'failed' || tool.status === 'cancelled').length;
|
|
36
|
+
const done = tools.filter((tool) => tool.status === 'succeeded').length;
|
|
37
|
+
return [chalk.dim(`工具 · ${done}/${tools.length} 已完成${failed ? ` · ${failed} 未成功` : ''}`)];
|
|
38
|
+
}
|
|
39
|
+
return renderToolCalls(tools, { verbosity, width }).lines;
|
|
40
|
+
}
|
|
62
41
|
export function scrollAction(input, key) {
|
|
63
42
|
if (key.pageUp || (key.ctrl && input === 'p'))
|
|
64
43
|
return 'up';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hunterzhu/pulse-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "A local-first AI task assistant for coding, research, and file workflows.",
|
|
5
5
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
6
6
|
"keywords": [
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"registry": "https://registry.npmjs.org"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@hunterzhu/pulse-server": "0.4.
|
|
39
|
+
"@hunterzhu/pulse-server": "0.4.5",
|
|
40
40
|
"chalk": "^5.4.1",
|
|
41
41
|
"highlight.js": "^11.10.0",
|
|
42
42
|
"ink": "^7.1.1",
|