mocode-ai 0.4.9 → 0.5.0
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/README.md +18 -0
- package/dist/agent/core.js +23 -2
- package/dist/agent/index.js +1 -1
- package/dist/config/index.js +17 -7
- package/dist/llm/index.js +22 -1
- package/dist/project-skill/index.js +81 -30
- package/dist/project-skill/initializer.js +45 -46
- package/dist/project-snapshot/index.js +39 -131
- package/dist/project-snapshot/llm-snapshot.js +127 -0
- package/dist/repl/index.js +163 -46
- package/dist/session/compact.js +3 -3
- package/dist/tools/builtins/project-skill-update.js +38 -7
- package/dist/tools/builtins/read-file.js +1 -17
- package/dist/tools/builtins/todolist.js +124 -8
- package/dist/ui/batch.js +23 -6
- package/dist/ui/content.js +96 -0
- package/dist/ui/diff.js +1 -1
- package/dist/ui/intervention.js +32 -4
- package/dist/ui/layout.js +166 -41
- package/dist/ui/prompt.js +11 -11
- package/dist/ui/render.js +44 -18
- package/dist/ui/spinner.js +1 -1
- package/dist/ui/theme.js +117 -0
- package/package.json +5 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readProjectSkill,
|
|
1
|
+
import { readProjectSkill, writeProjectSkillWithCompression, appendProjectSkillWithCompression, } from '../../project-skill/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* 项目专属 Skill 更新工具。
|
|
4
4
|
* 支持三种操作:
|
|
@@ -18,7 +18,33 @@ export const projectSkillUpdateTool = {
|
|
|
18
18
|
'Use this to record architectural patterns, naming conventions, common pitfalls, ' +
|
|
19
19
|
'and key decisions specific to this project. The skill persists across sessions and ' +
|
|
20
20
|
'is injected into the system prompt when MOCODE_PROJECT_SKILL=true. ' +
|
|
21
|
-
'Actions: "read" (view current content), "write" (replace all), "append" (add to end).'
|
|
21
|
+
'Actions: "read" (view current content), "write" (replace all), "append" (add to end). ' +
|
|
22
|
+
'\n\n' +
|
|
23
|
+
'## Skill 维护指南\n' +
|
|
24
|
+
'Skill 专注 Snapshot 无法自动提供的洞察知识(why/how/gotchas)。Snapshot 已提供文件结构和静态文件内容——Skill 不要重复这些信息。\n' +
|
|
25
|
+
'\n' +
|
|
26
|
+
'**Skill 该写什么(Snapshot 不能替代的)**:\n' +
|
|
27
|
+
'- 设计决策的 why(为什么这样设计、取舍是什么)\n' +
|
|
28
|
+
'- 模块行为和职责描述(怎么工作、数据流、调用链)\n' +
|
|
29
|
+
'- 常见坑点和解决方案(踩过的坑、非直觉行为、边界条件)\n' +
|
|
30
|
+
'- 项目约定(命名规范、代码风格、测试策略)\n' +
|
|
31
|
+
'- 开发流程(构建/测试/部署命令及注意事项)\n' +
|
|
32
|
+
'- 关键 API 的使用限制和特殊行为\n' +
|
|
33
|
+
'\n' +
|
|
34
|
+
'**Skill 不该写什么(交给 Snapshot)**:\n' +
|
|
35
|
+
'- 文件列表和目录结构(Snapshot 自动扫描 src 树)\n' +
|
|
36
|
+
'- 静态文件内容摘要(依赖、编译器选项等 → package.json / tsconfig.json)\n' +
|
|
37
|
+
'\n' +
|
|
38
|
+
'**何时更新**:发现新架构模式、踩坑后总结、学到新约定、完成重要重构、用户纠正理解时。\n' +
|
|
39
|
+
'\n' +
|
|
40
|
+
'**注意事项**:\n' +
|
|
41
|
+
'- 保持精简,硬上限 4000 字符(约 1000 token)\n' +
|
|
42
|
+
'- 写可操作的内容,避免空泛描述\n' +
|
|
43
|
+
'- 用具体路径和例子(不要写"有多个模块",要写"src/agent 负责 agent 循环")\n' +
|
|
44
|
+
'- 定期整理,删除过时信息\n' +
|
|
45
|
+
'- 更新前建议先 `read` 看一下现有内容,避免重复\n' +
|
|
46
|
+
'\n' +
|
|
47
|
+
'**自动压缩**:内容超限时会自动调用 LLM 压缩(最多 3 次),无需手动精简。',
|
|
22
48
|
risk: 'safe',
|
|
23
49
|
parameters: {
|
|
24
50
|
type: 'object',
|
|
@@ -35,9 +61,10 @@ export const projectSkillUpdateTool = {
|
|
|
35
61
|
},
|
|
36
62
|
required: ['action'],
|
|
37
63
|
},
|
|
38
|
-
execute: async (args) => {
|
|
64
|
+
execute: async (args, ctx) => {
|
|
39
65
|
const action = String(args.action ?? '').trim();
|
|
40
66
|
const content = String(args.content ?? '');
|
|
67
|
+
const signal = ctx?.signal;
|
|
41
68
|
switch (action) {
|
|
42
69
|
case 'read': {
|
|
43
70
|
const current = readProjectSkill();
|
|
@@ -50,21 +77,25 @@ export const projectSkillUpdateTool = {
|
|
|
50
77
|
if (!content.trim()) {
|
|
51
78
|
return 'Error: "write" action requires non-empty content parameter.';
|
|
52
79
|
}
|
|
53
|
-
const result =
|
|
80
|
+
const result = await writeProjectSkillWithCompression(content, 3, signal);
|
|
54
81
|
if (!result.ok) {
|
|
55
82
|
return `Error: ${result.error}`;
|
|
56
83
|
}
|
|
57
|
-
return
|
|
84
|
+
return result.compressed
|
|
85
|
+
? 'Project skill updated successfully (full replacement, auto-compressed to fit limit).'
|
|
86
|
+
: 'Project skill updated successfully (full replacement).';
|
|
58
87
|
}
|
|
59
88
|
case 'append': {
|
|
60
89
|
if (!content.trim()) {
|
|
61
90
|
return 'Error: "append" action requires non-empty content parameter.';
|
|
62
91
|
}
|
|
63
|
-
const result =
|
|
92
|
+
const result = await appendProjectSkillWithCompression(content, 3, signal);
|
|
64
93
|
if (!result.ok) {
|
|
65
94
|
return `Error: ${result.error}`;
|
|
66
95
|
}
|
|
67
|
-
return
|
|
96
|
+
return result.compressed
|
|
97
|
+
? 'Project skill updated successfully (appended, auto-compressed to fit limit).'
|
|
98
|
+
: 'Project skill updated successfully (appended).';
|
|
68
99
|
}
|
|
69
100
|
default:
|
|
70
101
|
return `Error: Unknown action "${action}". Use "read", "write", or "append".`;
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { MAX_FILE_LINES } from '../constants.js';
|
|
4
|
-
import { lookupSnapshotFile } from '../../project-snapshot/index.js';
|
|
5
|
-
import { config } from '../../config/index.js';
|
|
6
4
|
/** 默认单次 read_file 拉取的行数。刻意压低,逼 LLM 分块读大文件,
|
|
7
5
|
* 配合 description 中的 PAGINATION IS MANDATORY 引导。
|
|
8
6
|
* 300 行 ≈ 一个屏幕的源码量,够定位一段逻辑而不至于吃光上下文。 */
|
|
@@ -31,21 +29,7 @@ export const readFileTool = {
|
|
|
31
29
|
const offset = Number(args.offset ?? 1);
|
|
32
30
|
// 无论 LLM 传多大,单次硬钳到 MAX_FILE_LINES,杜绝「绕过分页引导一把全拿」。
|
|
33
31
|
const limit = Math.min(Number(args.limit ?? DEFAULT_READ_LIMIT), MAX_FILE_LINES);
|
|
34
|
-
|
|
35
|
-
let data;
|
|
36
|
-
if (config.projectSnapshotEnabled) {
|
|
37
|
-
const absPath = resolve(path);
|
|
38
|
-
const cached = lookupSnapshotFile(absPath);
|
|
39
|
-
if (cached) {
|
|
40
|
-
data = cached.content;
|
|
41
|
-
}
|
|
42
|
-
else {
|
|
43
|
-
data = await readFile(absPath, 'utf8');
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
else {
|
|
47
|
-
data = await readFile(resolve(path), 'utf8');
|
|
48
|
-
}
|
|
32
|
+
const data = await readFile(resolve(path), 'utf8');
|
|
49
33
|
const lines = data.split(/\r?\n/);
|
|
50
34
|
const start = Math.max(0, offset - 1);
|
|
51
35
|
const end = Math.min(lines.length, start + limit);
|
|
@@ -20,18 +20,38 @@ export const todolistTool = {
|
|
|
20
20
|
name: 'todolist',
|
|
21
21
|
description: [
|
|
22
22
|
'Maintain a working "notepad" plan in .mocode/plans/<id>.md (file-based, survives context compression).',
|
|
23
|
-
'
|
|
24
|
-
'
|
|
23
|
+
'',
|
|
24
|
+
'## PREREQUISITES (before create)',
|
|
25
|
+
'Research first: explore codebase (read-only tools) → clarify with user (ask_human) → have concrete steps.',
|
|
26
|
+
'Do NOT create prematurely — if you haven\'t done research and confirmation, stop and do that first.',
|
|
27
|
+
'',
|
|
28
|
+
'## WHEN TO USE / NOT USE',
|
|
29
|
+
'ONLY for genuinely complex tasks: ≥3 file changes, ≥5 tool calls across phases, multi-phase features,',
|
|
30
|
+
'or user explicitly requests ("先计划再执行" / "plan then do").',
|
|
31
|
+
'Skip for: single-file edits, bug fixes, quick lookups, ≤3 focused tool calls. If in doubt, just execute.',
|
|
32
|
+
'',
|
|
33
|
+
'## STEP GRANULARITY (4-5 steps max)',
|
|
34
|
+
'Each step = one meaningful execution unit (a phase, not a single tool call).',
|
|
35
|
+
'GOOD: "调研现有架构 → 设计新接口 → 实现核心逻辑 → 编写测试 → 集成验证"',
|
|
36
|
+
'BAD: "打开文件A → 修改函数X → 保存文件A → 运行测试" (too fine-grained, just do it)',
|
|
37
|
+
'',
|
|
38
|
+
'## UPDATE WORKFLOW',
|
|
39
|
+
'Update in real-time after completing each step (one step = one update, or batch_update for 2-3 at once).',
|
|
40
|
+
'Do NOT batch all updates at the end — update as you go so the chip reflects real progress.',
|
|
41
|
+
'All steps done/skipped → plan auto-finishes, archives, and chip disappears.',
|
|
42
|
+
'',
|
|
43
|
+
'## LIFECYCLE',
|
|
25
44
|
'Single plan per session: create refuses if an in-progress plan already exists — finish or abandon it first.',
|
|
26
|
-
'
|
|
27
|
-
|
|
45
|
+
'finish(plan_status="finished") auto-archives to .mocode/plans/archive/; use plan_status="abandoned" to abandon.',
|
|
46
|
+
'To revisit: list with scope=archived, or unarchive to bring back to active. delete permanently removes.',
|
|
47
|
+
].join('\n'),
|
|
28
48
|
parameters: {
|
|
29
49
|
type: 'object',
|
|
30
50
|
properties: {
|
|
31
51
|
action: {
|
|
32
52
|
type: 'string',
|
|
33
|
-
enum: ['create', 'read', 'update', 'add_step', 'finish', 'list', 'delete', 'unarchive'],
|
|
34
|
-
description: 'create=新计划;read=读当前活跃;update=改步骤状态;add_step=追加步骤;finish=收尾(自动归档);list=列计划;delete=永久删除;unarchive=从归档还原到活跃',
|
|
53
|
+
enum: ['create', 'read', 'update', 'batch_update', 'add_step', 'finish', 'list', 'delete', 'unarchive'],
|
|
54
|
+
description: 'create=新计划;read=读当前活跃;update=改步骤状态;batch_update=批量改多个步骤状态;add_step=追加步骤;finish=收尾(自动归档);list=列计划;delete=永久删除;unarchive=从归档还原到活跃',
|
|
35
55
|
},
|
|
36
56
|
title: { type: 'string', description: 'create 必填:计划标题' },
|
|
37
57
|
goal: { type: 'string', description: 'create 可选:目标描述(写进「目标」段)' },
|
|
@@ -49,9 +69,21 @@ export const todolistTool = {
|
|
|
49
69
|
enum: ['pending', 'in_progress', 'done', 'skipped', 'failed'],
|
|
50
70
|
description: 'update 必填:目标状态',
|
|
51
71
|
},
|
|
72
|
+
updates: {
|
|
73
|
+
type: 'array',
|
|
74
|
+
items: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
step_id: { type: 'number', description: '步骤编号' },
|
|
78
|
+
status: { type: 'string', enum: ['pending', 'in_progress', 'done', 'skipped', 'failed'], description: '目标状态' },
|
|
79
|
+
},
|
|
80
|
+
required: ['step_id', 'status'],
|
|
81
|
+
},
|
|
82
|
+
description: 'batch_update 必填:批量更新数组,每项含 step_id 和 status',
|
|
83
|
+
},
|
|
52
84
|
note: {
|
|
53
85
|
type: 'string',
|
|
54
|
-
description: 'update / finish 可选:追加到进度日志的一行说明(可空)',
|
|
86
|
+
description: 'update / batch_update / finish 可选:追加到进度日志的一行说明(可空)',
|
|
55
87
|
},
|
|
56
88
|
plan_status: {
|
|
57
89
|
type: 'string',
|
|
@@ -77,13 +109,14 @@ export const todolistTool = {
|
|
|
77
109
|
case 'create': return doCreate(args);
|
|
78
110
|
case 'read': return doRead();
|
|
79
111
|
case 'update': return doUpdate(args);
|
|
112
|
+
case 'batch_update': return doBatchUpdate(args);
|
|
80
113
|
case 'add_step': return doAddStep(args);
|
|
81
114
|
case 'finish': return doFinish(args);
|
|
82
115
|
case 'list': return doList(args);
|
|
83
116
|
case 'delete': return doDelete(args);
|
|
84
117
|
case 'unarchive': return doUnarchive(args);
|
|
85
118
|
default:
|
|
86
|
-
return `错误:未知 action「${action}」,合法值:create / read / update / add_step / finish / list / delete / unarchive。`;
|
|
119
|
+
return `错误:未知 action「${action}」,合法值:create / read / update / batch_update / add_step / finish / list / delete / unarchive。`;
|
|
87
120
|
}
|
|
88
121
|
}
|
|
89
122
|
catch (e) {
|
|
@@ -175,9 +208,92 @@ function doUpdate(args) {
|
|
|
175
208
|
if (!updated.steps.some((s) => s.id === stepId)) {
|
|
176
209
|
return `错误:找不到 step_id=${stepId}(plan 共 ${updated.steps.length} 步)。`;
|
|
177
210
|
}
|
|
211
|
+
// 自动完成:所有步骤都 done/skipped 时自动 finish + 归档 + 清 chip(LLM 常漏调 finish)
|
|
212
|
+
const allDone = updated.steps.length > 0
|
|
213
|
+
&& updated.steps.every((s) => s.status === 'done' || s.status === 'skipped');
|
|
214
|
+
if (allDone && updated.status === 'in_progress') {
|
|
215
|
+
updated.status = 'finished';
|
|
216
|
+
updated.log.push({ at: localIsoTimestamp(), text: '自动完成:所有步骤已完成' });
|
|
217
|
+
if (!writePlan(updated)) {
|
|
218
|
+
setActivePlan(updated);
|
|
219
|
+
return renderSuccess('update', updated) + '\n⚠ 自动 finish 写盘失败';
|
|
220
|
+
}
|
|
221
|
+
const archived = archivePlan(updated.id);
|
|
222
|
+
clearActivePlan();
|
|
223
|
+
const archivedNote = archived
|
|
224
|
+
? '(已自动归档到 plans/archive/)'
|
|
225
|
+
: '(⚠ 归档失败,plan 仍留在 plans/)';
|
|
226
|
+
return renderSuccess('update', updated) + `\n✓ 自动完成:所有步骤已完成 ${archivedNote}`;
|
|
227
|
+
}
|
|
178
228
|
setActivePlan(updated);
|
|
179
229
|
return renderSuccess('update', updated);
|
|
180
230
|
}
|
|
231
|
+
function doBatchUpdate(args) {
|
|
232
|
+
const cur = getActivePlan();
|
|
233
|
+
if (!cur)
|
|
234
|
+
return '错误:无活跃 plan 可 batch_update。先 create。';
|
|
235
|
+
const updates = args.updates;
|
|
236
|
+
if (!Array.isArray(updates) || updates.length === 0) {
|
|
237
|
+
return '错误:batch_update 必填 updates(非空数组,每项含 step_id 和 status)。';
|
|
238
|
+
}
|
|
239
|
+
// 验证所有更新项
|
|
240
|
+
for (const u of updates) {
|
|
241
|
+
const sid = Number(u.step_id);
|
|
242
|
+
const st = String(u.status ?? '');
|
|
243
|
+
if (!Number.isFinite(sid) || sid < 1) {
|
|
244
|
+
return `错误:updates 中某项 step_id 非法「${u.step_id}」,需 >=1 整数。`;
|
|
245
|
+
}
|
|
246
|
+
if (!VALID_STATUS.has(st)) {
|
|
247
|
+
return `错误:updates 中某项 status 非法「${st}」,合法:pending / in_progress / done / skipped / failed。`;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const note = String(args.note ?? '').trim();
|
|
251
|
+
const updated = updatePlan(cur.id, (p) => {
|
|
252
|
+
for (const u of updates) {
|
|
253
|
+
const sid = Number(u.step_id);
|
|
254
|
+
const st = String(u.status);
|
|
255
|
+
const step = p.steps.find((s) => s.id === sid);
|
|
256
|
+
if (step) {
|
|
257
|
+
step.status = st;
|
|
258
|
+
p.log.push({ at: new Date().toISOString(), text: `step ${sid} → ${st}` });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (note)
|
|
262
|
+
p.log.push({ at: new Date().toISOString(), text: note });
|
|
263
|
+
return p;
|
|
264
|
+
});
|
|
265
|
+
if (!updated)
|
|
266
|
+
return '错误:batch_update 写盘失败。';
|
|
267
|
+
// 检查所有 step_id 是否存在
|
|
268
|
+
const missingIds = [];
|
|
269
|
+
for (const u of updates) {
|
|
270
|
+
const sid = Number(u.step_id);
|
|
271
|
+
if (!updated.steps.some((s) => s.id === sid))
|
|
272
|
+
missingIds.push(sid);
|
|
273
|
+
}
|
|
274
|
+
if (missingIds.length > 0) {
|
|
275
|
+
return `错误:batch_update 找不到 step_id=${missingIds.join(', ')}(plan 共 ${updated.steps.length} 步)。`;
|
|
276
|
+
}
|
|
277
|
+
// 自动完成:所有步骤都 done/skipped 时自动 finish + 归档 + 清 chip(LLM 常漏调 finish)
|
|
278
|
+
const allDone = updated.steps.length > 0
|
|
279
|
+
&& updated.steps.every((s) => s.status === 'done' || s.status === 'skipped');
|
|
280
|
+
if (allDone && updated.status === 'in_progress') {
|
|
281
|
+
updated.status = 'finished';
|
|
282
|
+
updated.log.push({ at: localIsoTimestamp(), text: '自动完成:所有步骤已完成' });
|
|
283
|
+
if (!writePlan(updated)) {
|
|
284
|
+
setActivePlan(updated);
|
|
285
|
+
return renderSuccess('batch_update', updated) + '\n⚠ 自动 finish 写盘失败';
|
|
286
|
+
}
|
|
287
|
+
const archived = archivePlan(updated.id);
|
|
288
|
+
clearActivePlan();
|
|
289
|
+
const archivedNote = archived
|
|
290
|
+
? '(已自动归档到 plans/archive/)'
|
|
291
|
+
: '(⚠ 归档失败,plan 仍留在 plans/)';
|
|
292
|
+
return renderSuccess('batch_update', updated) + `\n✓ 自动完成:所有步骤已完成 ${archivedNote}`;
|
|
293
|
+
}
|
|
294
|
+
setActivePlan(updated);
|
|
295
|
+
return renderSuccess('batch_update', updated);
|
|
296
|
+
}
|
|
181
297
|
function doAddStep(args) {
|
|
182
298
|
const cur = getActivePlan();
|
|
183
299
|
if (!cur)
|
package/dist/ui/batch.js
CHANGED
|
@@ -26,6 +26,8 @@ const MUTATION_TOOLS = new Set(['write_file', 'edit_file']);
|
|
|
26
26
|
function isMutationTool(name) {
|
|
27
27
|
return MUTATION_TOOLS.has(name);
|
|
28
28
|
}
|
|
29
|
+
/** 展开时完整输出的最大行数;超出截断,避免巨型输出撑爆 viewport。 */
|
|
30
|
+
const MAX_EXPAND_LINES = 200;
|
|
29
31
|
/** 通知 buffer 整体清空(clearContent / exitAltScreen / 新一轮 turn)——本模块状态同步归零。 */
|
|
30
32
|
export function reset() {
|
|
31
33
|
batches.clear();
|
|
@@ -45,8 +47,9 @@ export function recordCall(id, name, callSummary) {
|
|
|
45
47
|
return;
|
|
46
48
|
b.entries.push({ name, callSummary, resultSummary: '', diffBlock: null });
|
|
47
49
|
}
|
|
48
|
-
/** 记一条工具结果(diff 块或单行 preview);agent 在 onToolResult 时调,匹配最后一条未填的 entry。
|
|
49
|
-
|
|
50
|
+
/** 记一条工具结果(diff 块或单行 preview);agent 在 onToolResult 时调,匹配最后一条未填的 entry。
|
|
51
|
+
* fullOutput:工具原始完整输出(纯文本),展开时显示;mutation 工具的 diff 块已自含无需传。 */
|
|
52
|
+
export function recordResult(id, name, resultSummary, diffBlock, fullOutput) {
|
|
50
53
|
const b = batches.get(id);
|
|
51
54
|
if (!b || b.entries.length === 0)
|
|
52
55
|
return;
|
|
@@ -55,6 +58,7 @@ export function recordResult(id, name, resultSummary, diffBlock) {
|
|
|
55
58
|
if (b.entries[i].name === name && !b.entries[i].resultSummary) {
|
|
56
59
|
b.entries[i].resultSummary = resultSummary;
|
|
57
60
|
b.entries[i].diffBlock = diffBlock;
|
|
61
|
+
b.entries[i].fullOutput = fullOutput;
|
|
58
62
|
return;
|
|
59
63
|
}
|
|
60
64
|
}
|
|
@@ -63,17 +67,18 @@ export function recordResult(id, name, resultSummary, diffBlock) {
|
|
|
63
67
|
if (!last.resultSummary) {
|
|
64
68
|
last.resultSummary = resultSummary;
|
|
65
69
|
last.diffBlock = diffBlock;
|
|
70
|
+
last.fullOutput = fullOutput;
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
// ── 摘要行文本生成 ──
|
|
69
74
|
/** 把 entry 列表压缩成一行摘要(Claude Code 风格)。 */
|
|
70
75
|
function buildSummaryLine(entries) {
|
|
71
76
|
if (entries.length === 0) {
|
|
72
|
-
return ` ${ui.
|
|
77
|
+
return ` ${ui.bold}${ui.accent}●${ui.reset} ${ui.dim}No tools${ui.reset}`;
|
|
73
78
|
}
|
|
74
79
|
if (entries.length === 1) {
|
|
75
80
|
const e = entries[0];
|
|
76
|
-
return ` ${ui.
|
|
81
|
+
return ` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}`;
|
|
77
82
|
}
|
|
78
83
|
// N>1:同类合并 "read_file 3, glob 1, grep 1"
|
|
79
84
|
const counts = new Map();
|
|
@@ -82,7 +87,7 @@ function buildSummaryLine(entries) {
|
|
|
82
87
|
const parts = [];
|
|
83
88
|
for (const [n, c] of counts)
|
|
84
89
|
parts.push(`${n} ${c}`);
|
|
85
|
-
return ` ${ui.
|
|
90
|
+
return ` ${ui.bold}${ui.accent}●${ui.reset} ${ui.dim}Ran ${entries.length} tools · ${parts.join(', ')}${ui.reset}`;
|
|
86
91
|
}
|
|
87
92
|
// ── 展开/折叠 ──
|
|
88
93
|
/** 把 batch 的详情行展开成自洽行数组(供 layout.contentInsertAfter 走 mid-buffer 插入)。
|
|
@@ -91,7 +96,7 @@ function buildSummaryLine(entries) {
|
|
|
91
96
|
function buildExpandedLines(entries, indent = ' ') {
|
|
92
97
|
const lines = [];
|
|
93
98
|
for (const e of entries) {
|
|
94
|
-
lines.push(`${indent}${ui.
|
|
99
|
+
lines.push(`${indent}${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}\x1B[0m`);
|
|
95
100
|
if (e.diffBlock) {
|
|
96
101
|
// diff 块多行文本(由 renderFileChange 渲染);按 \n 拆成物理行,
|
|
97
102
|
// 每行单独入 rows[]。行末 reset 由本函数统一追加(若原行已带 reset,终端合并即可)。
|
|
@@ -104,6 +109,18 @@ function buildExpandedLines(entries, indent = ' ') {
|
|
|
104
109
|
lines.push(line.endsWith('\x1B[0m') ? line : line + '\x1B[0m');
|
|
105
110
|
}
|
|
106
111
|
}
|
|
112
|
+
else if (e.fullOutput) {
|
|
113
|
+
// 完整工具输出(纯文本):按行展开,每行缩进 + dim 样式;长输出截断到 MAX_EXPAND_LINES 行
|
|
114
|
+
const rawLines = e.fullOutput.split('\n');
|
|
115
|
+
const truncated = rawLines.length > MAX_EXPAND_LINES;
|
|
116
|
+
const displayLines = truncated ? rawLines.slice(0, MAX_EXPAND_LINES) : rawLines;
|
|
117
|
+
for (const line of displayLines) {
|
|
118
|
+
lines.push(`${indent}${ui.gray}${line}${ui.reset}\x1B[0m`);
|
|
119
|
+
}
|
|
120
|
+
if (truncated) {
|
|
121
|
+
lines.push(`${indent}${ui.dim}… (${rawLines.length - MAX_EXPAND_LINES} more lines)${ui.reset}\x1B[0m`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
107
124
|
else if (e.resultSummary) {
|
|
108
125
|
lines.push(`${indent}${ui.gray}↳ ${e.resultSummary}${ui.reset}\x1B[0m`);
|
|
109
126
|
}
|
package/dist/ui/content.js
CHANGED
|
@@ -136,6 +136,60 @@ export function lineAt(abs) {
|
|
|
136
136
|
const all = snapshot();
|
|
137
137
|
return abs >= 0 && abs < all.length ? all[abs] : null;
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* 找「绝对行索引 < absStart 的最近一条用户消息」的文本(用于滚动回看时的「我刚发的
|
|
141
|
+
* 请求」sticky banner)。算法:从 absStart - 1 往上扫,识别「用户气泡行」(由 repl
|
|
142
|
+
* formatUserMessage 写入,剥 SGR 后首字符 = ❯);再从此行往下吞连续的同类行
|
|
143
|
+
* 收集多行消息,join('\n')。
|
|
144
|
+
*
|
|
145
|
+
* 检测 user-bubble 行靠「剥 SGR 后以 ❯ 开头」而非 userBg SGR 前缀:rowStartSgr 继承自
|
|
146
|
+
* 上一行末(可能残留 dim/cyan),且 bubble 起手有 userBg 包裹,直接 startsWith(userBg)
|
|
147
|
+
* 在残留 rowStartSgr 场景会漏判。剥光所有 SGR 后 → 首字符稳定为 ❯ (repl.PROMPT)。
|
|
148
|
+
*
|
|
149
|
+
* 返回的文本已经剥离 ANSI + 提示符,可直接给 banner 渲染(再做截断 / 折叠)。
|
|
150
|
+
* absStart ≤ 0 返 null(没东西在视口上方)。
|
|
151
|
+
*/
|
|
152
|
+
export function lastUserMessageBefore(absStart) {
|
|
153
|
+
if (absStart <= 0)
|
|
154
|
+
return null;
|
|
155
|
+
const all = snapshot();
|
|
156
|
+
const SGR = /\x1B\[[0-9;]*m/g;
|
|
157
|
+
const isUserBubbleRow = (row) => {
|
|
158
|
+
// 剥光 SGR 后首字符 = ❯(repl.PROMPT 首字)→ 是 user bubble;
|
|
159
|
+
// agent 行首字符可能是 ● / ╭ / │ / 数字 / 字母,绝不会撞 ❯。
|
|
160
|
+
const visible = row.replace(SGR, '');
|
|
161
|
+
return visible.startsWith('❯');
|
|
162
|
+
};
|
|
163
|
+
// 1) 从 absStart - 1 往上扫,找第一条 user bubble 行(最近的 user-bubble 的最后一行)
|
|
164
|
+
let bubbleEnd = -1;
|
|
165
|
+
for (let i = Math.min(absStart, all.length) - 1; i >= 0; i--) {
|
|
166
|
+
if (isUserBubbleRow(all[i])) {
|
|
167
|
+
bubbleEnd = i;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (bubbleEnd < 0)
|
|
172
|
+
return null;
|
|
173
|
+
// 2) 往上找气泡起点(连续 user-bubble 行块 = 同一条 user 消息)
|
|
174
|
+
let bubbleStart = bubbleEnd;
|
|
175
|
+
while (bubbleStart - 1 >= 0 && isUserBubbleRow(all[bubbleStart - 1])) {
|
|
176
|
+
bubbleStart--;
|
|
177
|
+
}
|
|
178
|
+
// 3) 收文本:按显示字符剥 SGR + 续行/末填充空格 + 首行 prompt
|
|
179
|
+
// 首行剥前导 '❯ ' 序列 → banner 自己再加回 [banner_prompt] + <text>;
|
|
180
|
+
// 一次剥光所有重复的 ❯(防止用户手敲 prompt / 多重回显 → 出现 '❯ ❯ ...' 双提示符)。
|
|
181
|
+
const joinedText = all
|
|
182
|
+
.slice(bubbleStart, bubbleEnd + 1)
|
|
183
|
+
.map((raw, idx) => {
|
|
184
|
+
let stripped = raw.replace(SGR, '');
|
|
185
|
+
if (idx === 0)
|
|
186
|
+
stripped = stripped.replace(/^(?:❯\s*)+/, ''); // 剥光所有前导 ❯(含每个后面的可选空格)
|
|
187
|
+
return stripped.trimEnd();
|
|
188
|
+
})
|
|
189
|
+
.join('\n')
|
|
190
|
+
.replace(/\n+$/, '');
|
|
191
|
+
return joinedText || null;
|
|
192
|
+
}
|
|
139
193
|
/**
|
|
140
194
|
* 在绝对行索引 after(0-based,已 commit)之后插入 N 条自洽行。
|
|
141
195
|
* 用于「折叠摘要行下展开明细」——把详情行在已写入摘要行后面塞入缓冲,
|
|
@@ -198,3 +252,45 @@ export function deleteFrom(startIdx, n) {
|
|
|
198
252
|
segMark.rowIdx -= actual;
|
|
199
253
|
}
|
|
200
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* 在绝对行索引 startIdx(0-based,已 commit)起**等长替换**为新行(lines.length 必须等于
|
|
257
|
+
* 原区间行数;调用方负责等长,以维持 banner 等「顶部固定行」语义不变)。
|
|
258
|
+
*
|
|
259
|
+
* 用途:layout.writeBanner / rewriteBanner 把 buffer [0, bannerH) 替换为新的自洽行,
|
|
260
|
+
* repaintViewport 自然从 rows[] 头部读出新版 banner,无需 layout 介入。
|
|
261
|
+
*
|
|
262
|
+
* 不动 hasCurrent(行已 commit);若 segMark 在替换区间内,segMark.rowIdx 减至 startIdx
|
|
263
|
+
* (段起点被覆盖)。segMark 在区间前方不动(不在此类用法出现)。
|
|
264
|
+
*
|
|
265
|
+
* 长度不一致直接报错并 no-op(避免静默错位):允许调用方传不同长度时改用 insertAfter + deleteFrom。
|
|
266
|
+
*/
|
|
267
|
+
export function replaceHead(startIdx, lines) {
|
|
268
|
+
if (!Number.isInteger(startIdx) || startIdx < 0) {
|
|
269
|
+
throw new Error(`replaceHead: startIdx 必须 ≥ 0 整数,实得 ${startIdx}`);
|
|
270
|
+
}
|
|
271
|
+
if (lines.length === 0)
|
|
272
|
+
return; // 空替换 = no-op(0 行删 0 行)
|
|
273
|
+
if (hasCurrent) {
|
|
274
|
+
rows.push(rowStartSgr + curRaw + '\x1B[0m');
|
|
275
|
+
curRaw = '';
|
|
276
|
+
rowStartSgr = curSgr;
|
|
277
|
+
hasCurrent = false;
|
|
278
|
+
}
|
|
279
|
+
const committed = rows.length;
|
|
280
|
+
if (startIdx >= committed) {
|
|
281
|
+
throw new Error(`replaceHead: startIdx=${startIdx} 超出已 commit 行数 ${committed}(调用方必须保证等长替换且 startIdx 在 buffer 内)`);
|
|
282
|
+
}
|
|
283
|
+
const end = Math.min(startIdx + lines.length, committed);
|
|
284
|
+
const actualOld = end - startIdx;
|
|
285
|
+
if (actualOld !== lines.length) {
|
|
286
|
+
throw new Error(`replaceHead: 行数不匹配(startIdx=${startIdx},新区间 ${actualOld} 行 ≠ 新行 ${lines.length} 行)`);
|
|
287
|
+
}
|
|
288
|
+
// splice 等长替换:rows.length 不变,segMark 若在区间前方不受影响,区间内被覆盖时平移到 startIdx
|
|
289
|
+
rows.splice(startIdx, lines.length, ...lines);
|
|
290
|
+
if (segMark) {
|
|
291
|
+
const m = segMark.rowIdx;
|
|
292
|
+
if (m >= startIdx && m < end) {
|
|
293
|
+
segMark.rowIdx = startIdx; // 段起点被覆盖 → 重锚到区间头
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
package/dist/ui/diff.js
CHANGED
|
@@ -215,7 +215,7 @@ export function renderFileChange(opts) {
|
|
|
215
215
|
const pathDisp = path; // 路径原样展示(不做截断,长路径由 contentWrite 折行)
|
|
216
216
|
const verb = oldStr === null ? 'Create' : 'Update';
|
|
217
217
|
// 头行:Update(path) / Create(path)
|
|
218
|
-
const head = `${HEAD_INDENT}${ui.
|
|
218
|
+
const head = `${HEAD_INDENT}${ui.bold}${ui.accent}${verb}${ui.reset}${ui.gray}(${ui.reset}${ui.accent}${pathDisp}${ui.reset}${ui.gray})${ui.reset}`;
|
|
219
219
|
// 新建:整文件作 + 行,行号从 1
|
|
220
220
|
if (oldStr === null) {
|
|
221
221
|
const lines = splitLines(newStr);
|
package/dist/ui/intervention.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import readline from 'node:readline';
|
|
2
2
|
import { stdin, stderr } from 'node:process';
|
|
3
3
|
import { ui } from './theme.js';
|
|
4
|
-
import { displayWidth, truncateDisplay, wrapByDisplayWidth } from './render.js';
|
|
4
|
+
import { displayWidth, truncateDisplay, visColToCharCol, wrapByDisplayWidth } from './render.js';
|
|
5
5
|
import * as layout from './layout.js';
|
|
6
6
|
import * as mouse from './mouse.js';
|
|
7
7
|
import { Spinner } from './spinner.js';
|
|
@@ -92,8 +92,8 @@ export async function promptIntervention(req) {
|
|
|
92
92
|
break;
|
|
93
93
|
// 选中项:▸ 与正文均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
|
|
94
94
|
const isSel = idx === selected;
|
|
95
|
-
const color = isSel ? `${ui.
|
|
96
|
-
const marker = isSel ? `${ui.
|
|
95
|
+
const color = isSel ? `${ui.accent}${ui.bold}` : ui.dim;
|
|
96
|
+
const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
|
|
97
97
|
// 数字前缀:只标真实选项(1-9,与 onKeyChoice 的数字直选对应);"自定义"项不占号。
|
|
98
98
|
const numStr = idx < options.length ? `${idx + 1}. ` : '';
|
|
99
99
|
const prefixWidth = 2 + numStr.length; // marker(1)+空格(1)+numStr
|
|
@@ -132,6 +132,28 @@ export async function promptIntervention(req) {
|
|
|
132
132
|
lines.push(`${ui.dim}Enter 提交 · Esc ${cameFromChoice ? '返回选项' : '取消'} · Ctrl+C 取消${ui.reset}`);
|
|
133
133
|
return lines;
|
|
134
134
|
}
|
|
135
|
+
/** 鼠标左键点击输入框 → 把 cursor 移到点击位置(与 prompt.ts 的 applyExternalCursor 同源逻辑)。
|
|
136
|
+
* intervention 的 input 只有一行 text(无 chip / 多行),算法简化:flatIdx=0,段内 visCol → charCol。 */
|
|
137
|
+
function applyExternalCursor(_flatIdx, inSegVis) {
|
|
138
|
+
if (mode !== 'input')
|
|
139
|
+
return; // choice 模式下点击输入框不移动光标(无文本可定位)
|
|
140
|
+
// 把屏幕 visCol 映射到 text 的字符偏移:wrapByDisplayWidth 复刻 paintInput 的折行,
|
|
141
|
+
// visColToCharCol 把显示列反推到字符索引(处理 CJK 全角字符)。
|
|
142
|
+
const g = layout.getGeo();
|
|
143
|
+
const promptW = displayWidth('❯ ');
|
|
144
|
+
const W = Math.max(1, g.cols - promptW);
|
|
145
|
+
const segs = wrapByDisplayWidth(text, W);
|
|
146
|
+
// _flatIdx 由 layout 端算出(点击落在哪段),这里用它定位段;越界兜底末段。
|
|
147
|
+
const seg = segs[Math.min(_flatIdx, segs.length - 1)] ?? '';
|
|
148
|
+
const inChar = visColToCharCol(seg, inSegVis);
|
|
149
|
+
// 累加前面段的字符数 → text 中的绝对偏移。
|
|
150
|
+
let offset = 0;
|
|
151
|
+
for (let i = 0; i < Math.min(_flatIdx, segs.length); i++)
|
|
152
|
+
offset += segs[i].length;
|
|
153
|
+
offset += inChar;
|
|
154
|
+
cursor = Math.max(0, Math.min(offset, text.length));
|
|
155
|
+
redraw();
|
|
156
|
+
}
|
|
135
157
|
function redraw() {
|
|
136
158
|
if (mode === 'choice') {
|
|
137
159
|
const hint = '↑↓ 选择 · Enter 确认 · 数字键直选 · Esc 取消';
|
|
@@ -163,6 +185,7 @@ export async function promptIntervention(req) {
|
|
|
163
185
|
/** 退出:摘自己的监听 + 恢复快照监听 + 擦菜单恢复内容区。不 setRawMode(false)/pause stdin(运行态由 repl 接管)。 */
|
|
164
186
|
function cleanup() {
|
|
165
187
|
layout.setMouseEnabled(true); // 恢复鼠标框选(面板期间禁,防拖拽覆盖菜单)
|
|
188
|
+
layout.setCursorChangeHandler(null); // 注销光标变更处理器
|
|
166
189
|
emitter.removeListener('keypress', onKey);
|
|
167
190
|
for (const l of savedListeners)
|
|
168
191
|
emitter.on('keypress', l);
|
|
@@ -183,6 +206,8 @@ export async function promptIntervention(req) {
|
|
|
183
206
|
layout.resetScroll();
|
|
184
207
|
else
|
|
185
208
|
layout.repaintViewport();
|
|
209
|
+
// 恢复走时计时器(RUNNING 态):面板期间 stopTurnTimer 停了心跳,退出后恢复状态行 200ms 刷新。
|
|
210
|
+
layout.startTurnTimerIfRunning();
|
|
186
211
|
}
|
|
187
212
|
function onKey(_str, key) {
|
|
188
213
|
if (resolved || !key)
|
|
@@ -310,8 +335,10 @@ export async function promptIntervention(req) {
|
|
|
310
335
|
return new Promise((res, rej) => {
|
|
311
336
|
resolve = res;
|
|
312
337
|
try {
|
|
313
|
-
// 进入面板:停 spinner(避免 onFrame 覆盖)+
|
|
338
|
+
// 进入面板:停 spinner(避免 onFrame 覆盖)+ 停走时计时器(避免 drawStatusBar 200ms 心跳
|
|
339
|
+
// 把真光标拉到 runningCaretPos 覆盖 paintInput 的正确光标位)+ 禁鼠标框选(防拖拽 viewport 重画覆盖菜单)+ 回尾(若用户正滚动回看)
|
|
314
340
|
Spinner.pauseCurrent();
|
|
341
|
+
layout.stopTurnTimer();
|
|
315
342
|
layout.setMouseEnabled(false);
|
|
316
343
|
layout.resetScroll();
|
|
317
344
|
// 快照现有 keypress 监听(运行态的 onRunningKey)并摘掉,挂自己的 onKey
|
|
@@ -328,6 +355,7 @@ export async function promptIntervention(req) {
|
|
|
328
355
|
}
|
|
329
356
|
stdin.resume();
|
|
330
357
|
emitter.on('keypress', onKey);
|
|
358
|
+
layout.setCursorChangeHandler(applyExternalCursor); // 鼠标左键单击输入框 → 移光标到点击位
|
|
331
359
|
redraw();
|
|
332
360
|
}
|
|
333
361
|
catch (e) {
|