mocode-ai 0.4.4 → 0.4.6
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 +219 -209
- package/README.zh-CN.md +10 -0
- package/dist/agent/index.js +30 -13
- package/dist/config/index.js +13 -11
- package/dist/config/presets.js +177 -0
- package/dist/llm/capabilities.js +4 -0
- package/dist/llm/index.js +5 -1
- package/dist/memory/reflect.js +5 -2
- package/dist/repl/index.js +268 -10
- package/dist/tools/builtins/ask-human.js +71 -43
- package/dist/tools/builtins/codegraph.js +1 -1
- package/dist/ui/batch.js +235 -0
- package/dist/ui/content.js +51 -0
- package/dist/ui/diff.js +41 -4
- package/dist/ui/intervention.js +59 -20
- package/dist/ui/layout.js +449 -66
- package/dist/ui/prompt.js +99 -5
- package/dist/ui/render.js +22 -0
- package/dist/ui/theme.js +22 -0
- package/package.json +1 -1
package/dist/ui/batch.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具调用批量折叠渲染(仿 Claude Code):
|
|
3
|
+
* agent 一轮返回 N 个 tool_calls 时,不逐个打印 `● name ↳ result`,
|
|
4
|
+
* 改输出一行摘要 `● Ran N tools · read 3, grep 1, glob 1`;
|
|
5
|
+
* 鼠标点击该摘要行 → 展开完整明细(● 头 + ↳ preview / diff 块),再点折回。
|
|
6
|
+
*
|
|
7
|
+
* 设计要点:
|
|
8
|
+
* - 实时执行 + renderHistory 回放共用本渲染器;
|
|
9
|
+
* - 仅 buffer 写一行,详情行展开时才插入(mid-buffer insert via layout.contentInsertAfter);
|
|
10
|
+
* - 默认折叠;展开/折叠不影响历史回放的 history 结构(history 仍存完整 tool_calls+tool 消息)。
|
|
11
|
+
*
|
|
12
|
+
* 状态全模块级(单实例)——一次 REPL 内所有 batch 共享,清内容区 / 退 alt 屏时统一重置。
|
|
13
|
+
*/
|
|
14
|
+
import { ui } from './theme.js';
|
|
15
|
+
const batches = new Map();
|
|
16
|
+
/** 绝对行索引 → 所属 batch id(仅记录 summary 行;用于鼠标点击反查)。
|
|
17
|
+
* buffer 行数变化时本表可能漂移——但只在 insertAfter/deleteFrom 后由本模块同步更新,
|
|
18
|
+
* 并保持 buffer 当前状态对应。 */
|
|
19
|
+
const absLineToBatchId = new Map();
|
|
20
|
+
/** 已展开的 batch id;默认空(全折叠);layout.mouse release 点击摘要行时切。
|
|
21
|
+
* 含 mutation 的 batch 不在此 set——它们走 forceExpanded 永远展开,与 toggle 隔离。 */
|
|
22
|
+
const expandedBatches = new Set();
|
|
23
|
+
/** mutation 工具名集合(写盘操作);与 src/agent/core.ts 的 isMutationTool 同步,本模块独立持有
|
|
24
|
+
* 避免 ui → agent 反向依赖。 */
|
|
25
|
+
const MUTATION_TOOLS = new Set(['write_file', 'edit_file']);
|
|
26
|
+
function isMutationTool(name) {
|
|
27
|
+
return MUTATION_TOOLS.has(name);
|
|
28
|
+
}
|
|
29
|
+
/** 通知 buffer 整体清空(clearContent / exitAltScreen / 新一轮 turn)——本模块状态同步归零。 */
|
|
30
|
+
export function reset() {
|
|
31
|
+
batches.clear();
|
|
32
|
+
absLineToBatchId.clear();
|
|
33
|
+
expandedBatches.clear();
|
|
34
|
+
}
|
|
35
|
+
/** 新建一个 batch(在 agent 拿到第一条 onToolHeader 时调)。返回 id。 */
|
|
36
|
+
export function beginBatch() {
|
|
37
|
+
const id = `b${++_idCounter}`;
|
|
38
|
+
batches.set(id, { id, summaryAbsIdx: -1, entries: [], forceExpanded: false });
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
/** 记一条工具调用(在 onToolHeader 时调,与 setEntryResult 配对;entries 顺序 = agent 调用顺序)。 */
|
|
42
|
+
export function recordCall(id, name, callSummary) {
|
|
43
|
+
const b = batches.get(id);
|
|
44
|
+
if (!b)
|
|
45
|
+
return;
|
|
46
|
+
b.entries.push({ name, callSummary, resultSummary: '', diffBlock: null });
|
|
47
|
+
}
|
|
48
|
+
/** 记一条工具结果(diff 块或单行 preview);agent 在 onToolResult 时调,匹配最后一条未填的 entry。 */
|
|
49
|
+
export function recordResult(id, name, resultSummary, diffBlock) {
|
|
50
|
+
const b = batches.get(id);
|
|
51
|
+
if (!b || b.entries.length === 0)
|
|
52
|
+
return;
|
|
53
|
+
// 反向找最后一条同名的 entry 填结果;同名工具一批多次调用时正向遍历更安全——用 lastIndexOf 同名回退
|
|
54
|
+
for (let i = b.entries.length - 1; i >= 0; i--) {
|
|
55
|
+
if (b.entries[i].name === name && !b.entries[i].resultSummary) {
|
|
56
|
+
b.entries[i].resultSummary = resultSummary;
|
|
57
|
+
b.entries[i].diffBlock = diffBlock;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// 兜底:无匹配则填最后一条
|
|
62
|
+
const last = b.entries[b.entries.length - 1];
|
|
63
|
+
if (!last.resultSummary) {
|
|
64
|
+
last.resultSummary = resultSummary;
|
|
65
|
+
last.diffBlock = diffBlock;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// ── 摘要行文本生成 ──
|
|
69
|
+
/** 把 entry 列表压缩成一行摘要(Claude Code 风格)。 */
|
|
70
|
+
function buildSummaryLine(entries) {
|
|
71
|
+
if (entries.length === 0) {
|
|
72
|
+
return ` ${ui.brightMagenta}●${ui.reset} ${ui.dim}No tools${ui.reset}`;
|
|
73
|
+
}
|
|
74
|
+
if (entries.length === 1) {
|
|
75
|
+
const e = entries[0];
|
|
76
|
+
return ` ${ui.brightMagenta}●${ui.reset} ${ui.cyan}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}`;
|
|
77
|
+
}
|
|
78
|
+
// N>1:同类合并 "read_file 3, glob 1, grep 1"
|
|
79
|
+
const counts = new Map();
|
|
80
|
+
for (const e of entries)
|
|
81
|
+
counts.set(e.name, (counts.get(e.name) ?? 0) + 1);
|
|
82
|
+
const parts = [];
|
|
83
|
+
for (const [n, c] of counts)
|
|
84
|
+
parts.push(`${n} ${c}`);
|
|
85
|
+
return ` ${ui.brightMagenta}●${ui.reset} ${ui.dim}Ran ${entries.length} tools · ${parts.join(', ')}${ui.reset}`;
|
|
86
|
+
}
|
|
87
|
+
// ── 展开/折叠 ──
|
|
88
|
+
/** 把 batch 的详情行展开成自洽行数组(供 layout.contentInsertAfter 走 mid-buffer 插入)。
|
|
89
|
+
* 每行末尾必须以 \x1B[0m 收尾(SGR 自洽模型),行内允许含 SGR(行末 reset 不影响行内样式),
|
|
90
|
+
* 但**绝不**带 \n——rows[] 是行数组,不是流输出。 */
|
|
91
|
+
function buildExpandedLines(entries, indent = ' ') {
|
|
92
|
+
const lines = [];
|
|
93
|
+
for (const e of entries) {
|
|
94
|
+
lines.push(`${indent}${ui.brightMagenta}●${ui.reset} ${ui.cyan}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}\x1B[0m`);
|
|
95
|
+
if (e.diffBlock) {
|
|
96
|
+
// diff 块多行文本(由 renderFileChange 渲染);按 \n 拆成物理行,
|
|
97
|
+
// 每行单独入 rows[]。行末 reset 由本函数统一追加(若原行已带 reset,终端合并即可)。
|
|
98
|
+
const block = e.diffBlock.endsWith('\n') ? e.diffBlock : e.diffBlock + '\n';
|
|
99
|
+
for (const line of block.split('\n')) {
|
|
100
|
+
if (line === '' && lines.length > 0 && lines[lines.length - 1] === '')
|
|
101
|
+
continue; // 折叠连续空行
|
|
102
|
+
if (line === '' && lines.length > 0)
|
|
103
|
+
continue; // 跳过首尾空行(diff 头/尾换行)
|
|
104
|
+
lines.push(line.endsWith('\x1B[0m') ? line : line + '\x1B[0m');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else if (e.resultSummary) {
|
|
108
|
+
lines.push(`${indent}${ui.gray}↳ ${e.resultSummary}${ui.reset}\x1B[0m`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
|
113
|
+
/** 在 batch 收尾时(onToolBatchEnd):写摘要行 + 登记 summaryAbsIdx;若已展开(回放场景)立即插详情。 */
|
|
114
|
+
export function endBatch(id, layout) {
|
|
115
|
+
const b = batches.get(id);
|
|
116
|
+
if (!b || b.summaryAbsIdx >= 0)
|
|
117
|
+
return; // 幂等
|
|
118
|
+
// 含 mutation(write_file/edit_file)时强制展开——写盘操作必须让用户看到 diff
|
|
119
|
+
b.forceExpanded = b.entries.some((e) => isMutationTool(e.name));
|
|
120
|
+
// 单条 mutation 调用:摘要行("● edit_file path")与展开详情头逐字重复,跳过摘要行、只写详情
|
|
121
|
+
// (N>1 时摘要行是聚合信息 "Ran N tools · ...",与详情不重复,两者照常都写)。
|
|
122
|
+
if (b.forceExpanded && b.entries.length === 1) {
|
|
123
|
+
// 无摘要行可当父行,详情头改用顶层 2 空格缩进(与 buildSummaryLine/diff head 对齐,而非嵌套的 4 空格)
|
|
124
|
+
const lines = buildExpandedLines(b.entries, ' ');
|
|
125
|
+
layout.contentWrite(lines.join('\n') + '\n');
|
|
126
|
+
b.summaryAbsIdx = Math.max(0, layout.totalRows() - 1 - lines.length);
|
|
127
|
+
absLineToBatchId.set(b.summaryAbsIdx, b.id);
|
|
128
|
+
expandedBatches.add(b.id); // 已展开;防止 toggleBatch 再次 expand() 造成重复插入
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const summary = buildSummaryLine(b.entries);
|
|
132
|
+
// 写摘要行(以 \n 收尾;contentWrite 会 breakRow 让其成为完整物理行)
|
|
133
|
+
layout.contentWrite(summary + '\n');
|
|
134
|
+
// 摘要行绝对索引 = totalRows - 2(hasCurrent 那行是新空行)
|
|
135
|
+
b.summaryAbsIdx = Math.max(0, layout.totalRows() - 2);
|
|
136
|
+
absLineToBatchId.set(b.summaryAbsIdx, b.id);
|
|
137
|
+
// forceExpanded 时立刻展开(让 diff 在收尾后立即可见,无需点击)
|
|
138
|
+
if (b.forceExpanded) {
|
|
139
|
+
expand(b, layout);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** 绝对行索引 → 命中 batch id(用于鼠标 release 反查);非摘要行返 null。 */
|
|
143
|
+
export function findBatchByAbsLine(absLine) {
|
|
144
|
+
return absLineToBatchId.get(absLine) ?? null;
|
|
145
|
+
}
|
|
146
|
+
/** 当前 batch 是否已展开。 */
|
|
147
|
+
export function isExpanded(id) {
|
|
148
|
+
return expandedBatches.has(id);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* 切换 batch 展开/折叠;无变化时 no-op。
|
|
152
|
+
* 折叠:从 buffer 删详情行(mid-buffer delete);
|
|
153
|
+
* 展开:把详情行插入摘要行下方(mid-buffer insert)。
|
|
154
|
+
* 同步更新 absLineToBatchId 中所有受影响的索引:
|
|
155
|
+
* - 删除/插入点之后的 batch 摘要行索引相应平移。
|
|
156
|
+
* 含 mutation(write_file/edit_file)的 batch 强制展开——不允许折叠(写盘操作必须始终可见)。
|
|
157
|
+
*/
|
|
158
|
+
export function toggleBatch(id, layout) {
|
|
159
|
+
const b = batches.get(id);
|
|
160
|
+
if (!b)
|
|
161
|
+
return;
|
|
162
|
+
if (b.forceExpanded) {
|
|
163
|
+
// 写盘操作的 batch 强制展开,toggle 拒绝折叠(用户能看到完整 diff 即用)
|
|
164
|
+
if (!expandedBatches.has(id))
|
|
165
|
+
expand(b, layout);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (expandedBatches.has(id)) {
|
|
169
|
+
collapse(b, layout);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
expand(b, layout);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function expand(b, layout) {
|
|
176
|
+
const lines = buildExpandedLines(b.entries);
|
|
177
|
+
layout.contentInsertAfter(b.summaryAbsIdx, lines);
|
|
178
|
+
expandedBatches.add(b.id);
|
|
179
|
+
}
|
|
180
|
+
function collapse(b, layout) {
|
|
181
|
+
const lines = buildExpandedLines(b.entries);
|
|
182
|
+
layout.contentDeleteFrom(b.summaryAbsIdx + 1, lines.length);
|
|
183
|
+
expandedBatches.delete(b.id);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* 当 buffer 中段插/删 N 行后,所有受影响 batch 的 summaryAbsIdx 需平移。
|
|
187
|
+
* 由 layout.contentInsertAfter / contentDeleteFrom 在每次变动后调一次,
|
|
188
|
+
* 参数 afterIdx 是被插入/删除点的绝对索引(插入点之前索引不变;之后索引 += delta)。
|
|
189
|
+
*/
|
|
190
|
+
export function shiftBatchesAfter(absIdx, delta) {
|
|
191
|
+
if (delta === 0)
|
|
192
|
+
return;
|
|
193
|
+
// 重建 absLineToBatchId:删除所有 <= absIdx 的项,把 > absIdx 的项按 delta 平移
|
|
194
|
+
const next = new Map();
|
|
195
|
+
for (const [idx, id] of absLineToBatchId) {
|
|
196
|
+
if (idx <= absIdx) {
|
|
197
|
+
next.set(idx, id);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
const newIdx = idx + delta;
|
|
201
|
+
if (newIdx >= 0)
|
|
202
|
+
next.set(newIdx, id);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
absLineToBatchId.clear();
|
|
206
|
+
for (const [k, v] of next)
|
|
207
|
+
absLineToBatchId.set(k, v);
|
|
208
|
+
// 同步每个 batch 的 summaryAbsIdx
|
|
209
|
+
for (const b of batches.values()) {
|
|
210
|
+
if (b.summaryAbsIdx > absIdx)
|
|
211
|
+
b.summaryAbsIdx = Math.max(0, b.summaryAbsIdx + delta);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// ── history 回放支持 ──
|
|
215
|
+
/** 把已构造好的 BatchEntry[] 直接落成摘要行(用于 renderHistory 回放;不记录 id 也不需可切换)。
|
|
216
|
+
* 含 mutation(write_file/edit_file)时整批展开——与实时 endBatch 行为一致。 */
|
|
217
|
+
export function writeSummaryOnly(entries, layout) {
|
|
218
|
+
const hasMutation = entries.some((e) => isMutationTool(e.name));
|
|
219
|
+
// 单条 mutation:同 endBatch,摘要行与展开详情头重复,跳过摘要行只写详情
|
|
220
|
+
if (hasMutation && entries.length === 1) {
|
|
221
|
+
const lines = buildExpandedLines(entries, ' ');
|
|
222
|
+
layout.contentWrite(lines.join('\n') + '\n');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
layout.contentWrite(buildSummaryLine(entries) + '\n');
|
|
226
|
+
if (hasMutation) {
|
|
227
|
+
// 回放时同步展开:重新调 expand 路径需要 BatchRecord,此处直接拼 line 插入
|
|
228
|
+
const summaryIdx = Math.max(0, layout.totalRows() - 2);
|
|
229
|
+
const lines = buildExpandedLines(entries);
|
|
230
|
+
layout.contentInsertAfter(summaryIdx, lines);
|
|
231
|
+
// 不入 batches/absLineToBatchId/expandedBatches——回放行不支持点击 toggle(设计取舍:
|
|
232
|
+
// 简化模型;若需要支持回放也可展开/折叠,可在 alt-screen 启动时建一张临时映射)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
let _idCounter = 0;
|
package/dist/ui/content.js
CHANGED
|
@@ -136,3 +136,54 @@ export function lineAt(abs) {
|
|
|
136
136
|
const all = snapshot();
|
|
137
137
|
return abs >= 0 && abs < all.length ? all[abs] : null;
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* 在绝对行索引 after(0-based,已 commit)之后插入 N 条自洽行。
|
|
141
|
+
* 用于「折叠摘要行下展开明细」——把详情行在已写入摘要行后面塞入缓冲,
|
|
142
|
+
* 不重写尾部已有内容;且不影响更早的 buffer 行(hasCurrent 先 commit 再 splice)。
|
|
143
|
+
*
|
|
144
|
+
* 行须自洽(每行带行末 \x1B[0m),不经 feedChar/feedSgr,直接入 rows。
|
|
145
|
+
* after < 0 视为在所有已 commit 行之前插入;after ≥ 已 commit 行数则追加到末尾。
|
|
146
|
+
* 不动 segMark——此函数用于非 md 路径(BatchRenderer 展开/折叠);
|
|
147
|
+
* md 段切回 layout.contentWriteMd 时 setLines 仍按 segMark 截断。
|
|
148
|
+
*
|
|
149
|
+
* 后置条件:插入后 hasCurrent=false(新空行不由本函数建立);调用方须自行决定续写位
|
|
150
|
+
* (BatchRenderer 在插入后调 layout.contentWrite 续写,新 \n 自然在详情块后建新行)。
|
|
151
|
+
*/
|
|
152
|
+
export function insertAfter(after, lines) {
|
|
153
|
+
if (lines.length === 0)
|
|
154
|
+
return;
|
|
155
|
+
if (hasCurrent) {
|
|
156
|
+
rows.push(rowStartSgr + curRaw + '\x1B[0m');
|
|
157
|
+
curRaw = '';
|
|
158
|
+
rowStartSgr = curSgr;
|
|
159
|
+
hasCurrent = false;
|
|
160
|
+
}
|
|
161
|
+
const committed = rows.length;
|
|
162
|
+
// after 是绝对行索引;若超过 committed(例如快照时 hasCurrent=true),钳到末尾
|
|
163
|
+
const target = after < 0 ? 0 : Math.min(after + 1, committed);
|
|
164
|
+
rows.splice(target, 0, ...lines);
|
|
165
|
+
if (rows.length > MAX_ROWS + 512)
|
|
166
|
+
rows.splice(0, rows.length - MAX_ROWS);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 从绝对行索引 startIdx(0-based,已 commit)起删 n 行。
|
|
170
|
+
* 用于「已展开明细折回摘要」——把详情行从中段裁掉,保留摘要行和后续内容。
|
|
171
|
+
* startIdx 越界或 n <= 0 直接 no-op。hasCurrent 时先 commit(同 insertAfter)。
|
|
172
|
+
*
|
|
173
|
+
* 后置条件:删除后 hasCurrent=false。后续 layout.contentWrite 自然续写。
|
|
174
|
+
*/
|
|
175
|
+
export function deleteFrom(startIdx, n) {
|
|
176
|
+
if (n <= 0)
|
|
177
|
+
return;
|
|
178
|
+
if (hasCurrent) {
|
|
179
|
+
rows.push(rowStartSgr + curRaw + '\x1B[0m');
|
|
180
|
+
curRaw = '';
|
|
181
|
+
rowStartSgr = curSgr;
|
|
182
|
+
hasCurrent = false;
|
|
183
|
+
}
|
|
184
|
+
const committed = rows.length;
|
|
185
|
+
if (startIdx >= committed)
|
|
186
|
+
return;
|
|
187
|
+
const end = Math.min(startIdx + n, committed);
|
|
188
|
+
rows.splice(startIdx, end - startIdx);
|
|
189
|
+
}
|
package/dist/ui/diff.js
CHANGED
|
@@ -191,11 +191,14 @@ function compactCtx(ops) {
|
|
|
191
191
|
}
|
|
192
192
|
return items;
|
|
193
193
|
}
|
|
194
|
-
function gutterOf(op) {
|
|
194
|
+
function gutterOf(op, restoreBg = '') {
|
|
195
|
+
// restoreBg:add/del 行在 pushBody 处已铺底色,gutter 的 `${ui.reset}` 会清掉底色,所以
|
|
196
|
+
// reset 后必须重发 bg SGR,让该行后续字符继续带底色(否则 gutter 后一小段会变无色)。
|
|
197
|
+
const rb = restoreBg;
|
|
195
198
|
if (op === 'del')
|
|
196
|
-
return `${ui.red}-${ui.reset}`;
|
|
199
|
+
return `${ui.red}-${ui.reset}${rb}`;
|
|
197
200
|
if (op === 'add')
|
|
198
|
-
return `${ui.green}+${ui.reset}`;
|
|
201
|
+
return `${ui.green}+${ui.reset}${rb}`;
|
|
199
202
|
return `${ui.dim} ${ui.reset}`;
|
|
200
203
|
}
|
|
201
204
|
function lineWord(n) {
|
|
@@ -261,7 +264,41 @@ function renderBody(head, counts, items, padW, startLine, lang) {
|
|
|
261
264
|
let overflow = 0;
|
|
262
265
|
const pushBody = (num, op, text) => {
|
|
263
266
|
const numStr = String(num).padStart(padW);
|
|
264
|
-
|
|
267
|
+
// 行级底色:add/del 整行包裹主题底色(addBg/delBg),行末由 codeText 内置 reset 闭合 →
|
|
268
|
+
// bg 不污染下一行。ctx 行不加底色(避免整个 diff 块被背景化,符合 GitHub / VSCode 视觉)。
|
|
269
|
+
// 注意 gutterOf 与 numStr 的 `${ui.reset}` 会清除之前累加的 bg,所以要紧跟一个 bg 恢复 SGR
|
|
270
|
+
// 才能让该行后续字符(代码区)继续带底色。
|
|
271
|
+
// 同时,代码区由 cli-highlight 渲染,内部会反复 ${fg}tok${reset}tok${fg}tok${reset}…
|
|
272
|
+
// 每个 reset 都会清掉外层 bg → 后面的 token 变无色。所以要在每个 reset 后重发 bg SGR
|
|
273
|
+
// (行末那枚 reset 是收尾的,后面紧跟换行而非字符,不需补 bg)。用行末 reset 之外的 reset
|
|
274
|
+
// 计数 = 内部重发点。
|
|
275
|
+
const bg = op === 'add' ? ui.addBg : op === 'del' ? ui.delBg : '';
|
|
276
|
+
if (bg === '') {
|
|
277
|
+
lines.push(`${BODY_INDENT}${ui.gray}${numStr}${ui.reset} ${gutterOf(op)} ${codeText(text, lang)}`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const raw = `${bg}${BODY_INDENT}${ui.gray}${numStr}${ui.reset}${bg} ${gutterOf(op, bg)} ${codeText(text, lang)}${ui.reset}`;
|
|
281
|
+
// 在每个非行末的 ${ui.reset} 后重发 bg。行末 reset 紧跟行尾或换行,无需补。
|
|
282
|
+
// 简单做法:把除最后一个 reset 外的所有 reset 后都补 bg——但要注意 cli-highlight
|
|
283
|
+
// 输出的代码区段内部还有"省略号截断"${ui.dim}…${ui.reset},它的 reset 也需补 bg。
|
|
284
|
+
// 用 split 走一遍:找出所有 reset 位置(除最后那个),在其后插入 bg。
|
|
285
|
+
const resetStr = ui.reset;
|
|
286
|
+
const lastResetIdx = raw.lastIndexOf(resetStr);
|
|
287
|
+
if (lastResetIdx < 0) {
|
|
288
|
+
lines.push(raw);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
// 把 raw 切成 [prefix + 末 reset] + (中间所有 reset 替换为 reset+bg)
|
|
292
|
+
const prefix = raw.slice(0, lastResetIdx);
|
|
293
|
+
const tail = raw.slice(lastResetIdx);
|
|
294
|
+
// 头部所有 reset 之后插 bg(注:用 split 重建)
|
|
295
|
+
const parts = prefix.split(resetStr);
|
|
296
|
+
// parts[i] 是第 i 段,紧跟一段 reset(最后一段后无 reset,故少一个元素)
|
|
297
|
+
let rebuilt = parts[0];
|
|
298
|
+
for (let i = 1; i < parts.length; i++) {
|
|
299
|
+
rebuilt += resetStr + bg + parts[i];
|
|
300
|
+
}
|
|
301
|
+
lines.push(rebuilt + tail);
|
|
265
302
|
};
|
|
266
303
|
for (const it of items) {
|
|
267
304
|
if (shown >= MAX_BODY_LINES) {
|
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 } from './render.js';
|
|
4
|
+
import { displayWidth, truncateDisplay, 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';
|
|
@@ -15,12 +15,16 @@ export async function promptIntervention(req) {
|
|
|
15
15
|
const kind = req.type === 'choice' ? '自动选默认项' : '自动返回空输入';
|
|
16
16
|
stderr.write(`[介入] ${req.title}(非交互环境,${kind})\n`);
|
|
17
17
|
if (req.type === 'choice') {
|
|
18
|
-
|
|
18
|
+
const first = req.options?.[0];
|
|
19
|
+
const value = typeof first === 'string' ? first : first?.label ?? '';
|
|
20
|
+
return { action: 'selected', value };
|
|
19
21
|
}
|
|
20
22
|
return { action: 'submitted', value: req.seed ?? '' };
|
|
21
23
|
}
|
|
22
24
|
const options = req.type === 'choice' && Array.isArray(req.options)
|
|
23
|
-
? req.options
|
|
25
|
+
? req.options
|
|
26
|
+
.map((o) => typeof o === 'string' ? { label: o } : { label: String(o.label ?? ''), detail: o.detail })
|
|
27
|
+
.filter((o) => o.label.length > 0)
|
|
24
28
|
: [];
|
|
25
29
|
// choice 但选项被滤空 → 降级 input(对齐设计文档 §8:ask_human 选项为空数组→input)。
|
|
26
30
|
const startMode = req.type === 'choice' && options.length > 0 ? 'choice' : 'input';
|
|
@@ -36,14 +40,23 @@ export async function promptIntervention(req) {
|
|
|
36
40
|
let resolve;
|
|
37
41
|
// 挂自己监听前快照的现有 keypress 监听(运行态即 onRunningKey),退出时按原序恢复。
|
|
38
42
|
let savedListeners = [];
|
|
39
|
-
/** choice 菜单行:标题(bold)+ detail(dim,行数上限保选项可见)+ 空行 + 选项(▸
|
|
43
|
+
/** choice 菜单行:标题(bold)+ 背景 detail(dim,行数上限保选项可见)+ 空行 + 选项(▸ label,有 detail 的项用全角括号()拼到 label 同行)。 */
|
|
40
44
|
function menuLinesChoice() {
|
|
41
45
|
const g = layout.getGeo();
|
|
42
46
|
const cols = g.cols;
|
|
43
|
-
const
|
|
47
|
+
const allowCustom = req.allowCustom !== false;
|
|
48
|
+
const items = allowCustom ? [...options, { label: CUSTOM_LABEL }] : [...options];
|
|
44
49
|
const optionCount = items.length;
|
|
45
|
-
//
|
|
46
|
-
|
|
50
|
+
// 每项占行数 = label + (detail)整体 wrap 到可用宽度后的实际行数(超长换行,不再省略)。
|
|
51
|
+
// 短 label 仍只占 1 行;长 detail 自然占用多行,菜单总高度随之动态增长。
|
|
52
|
+
const rowsFor = (o) => {
|
|
53
|
+
const prefixWidth = 2 + (optionCount <= 9 ? 3 : 4); // 保守:覆盖 1. 与 10. 两种前缀
|
|
54
|
+
const text = o.label + (o.detail ? `(${o.detail})` : '');
|
|
55
|
+
return Math.max(1, wrapByDisplayWidth(text, Math.max(1, cols - prefixWidth)).length);
|
|
56
|
+
};
|
|
57
|
+
const totalOptionRows = items.reduce((n, o) => n + rowsFor(o), 0);
|
|
58
|
+
// detail 上限:title(1)+detail(D)+空行(1)+options(totalOptionRows) ≤ contentBottom
|
|
59
|
+
const detailCap = Math.max(0, g.contentBottom - 2 - totalOptionRows);
|
|
47
60
|
const lines = [];
|
|
48
61
|
lines.push(`${ui.bold}${truncateDisplay(req.title, cols)}${ui.reset}`);
|
|
49
62
|
if (req.detail) {
|
|
@@ -56,21 +69,47 @@ export async function promptIntervention(req) {
|
|
|
56
69
|
lines.push(`${ui.dim}…${ui.reset}`);
|
|
57
70
|
}
|
|
58
71
|
lines.push(''); // 分隔空行
|
|
59
|
-
// 选项开窗:超屏高时以 selected
|
|
72
|
+
// 选项开窗:超屏高时以 selected 为中心收选中项可见。
|
|
60
73
|
const maxOptRows = Math.max(1, g.contentBottom - lines.length);
|
|
61
74
|
let start = 0;
|
|
62
|
-
|
|
63
|
-
|
|
75
|
+
{
|
|
76
|
+
let acc = 0;
|
|
77
|
+
let s = 0;
|
|
78
|
+
for (let i = 0; i <= selected && i < optionCount; i++)
|
|
79
|
+
acc += rowsFor(items[i]);
|
|
80
|
+
while (acc > maxOptRows && s < selected) {
|
|
81
|
+
acc -= rowsFor(items[s]);
|
|
82
|
+
s++;
|
|
83
|
+
}
|
|
84
|
+
start = s;
|
|
64
85
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
86
|
+
let used = 0;
|
|
87
|
+
let idx = start;
|
|
88
|
+
while (idx < optionCount) {
|
|
89
|
+
const o = items[idx];
|
|
90
|
+
const rows = rowsFor(o);
|
|
91
|
+
if (used + rows > maxOptRows && idx > start)
|
|
92
|
+
break;
|
|
68
93
|
// 选中项:▸ 与正文均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
|
|
69
94
|
const isSel = idx === selected;
|
|
70
95
|
const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
|
|
71
96
|
const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
|
|
72
|
-
|
|
73
|
-
|
|
97
|
+
// 数字前缀:只标真实选项(1-9,与 onKeyChoice 的数字直选对应);"自定义"项不占号。
|
|
98
|
+
const numStr = idx < options.length ? `${idx + 1}. ` : '';
|
|
99
|
+
const prefixWidth = 2 + numStr.length; // marker(1)+空格(1)+numStr
|
|
100
|
+
// 有 detail 的项:把说明拼到 label 后面、用全角括号()包裹;按可用宽度 wrap 多行(超长换行不再省略)。
|
|
101
|
+
// 第 1 行画 marker+numStr,后续行只画占位空白(prefixWidth)使 label 视觉上悬挂缩进、保持对齐。
|
|
102
|
+
// 颜色随 label 走(选中 cyan+bold、未选 dim),保持多行视觉整体性。
|
|
103
|
+
const detailSuffix = o.detail ? `(${o.detail})` : '';
|
|
104
|
+
const fullText = o.label + detailSuffix;
|
|
105
|
+
const wrapped = wrapByDisplayWidth(fullText, Math.max(1, cols - prefixWidth));
|
|
106
|
+
const pad = ' '.repeat(prefixWidth);
|
|
107
|
+
for (let li = 0; li < wrapped.length; li++) {
|
|
108
|
+
const prefix = li === 0 ? `${marker} ${numStr}` : pad;
|
|
109
|
+
lines.push(`${prefix}${color}${wrapped[li]}${ui.reset}`);
|
|
110
|
+
}
|
|
111
|
+
used += rows;
|
|
112
|
+
idx++;
|
|
74
113
|
}
|
|
75
114
|
return lines;
|
|
76
115
|
}
|
|
@@ -102,7 +141,6 @@ export async function promptIntervention(req) {
|
|
|
102
141
|
cursorLine: 0,
|
|
103
142
|
cursorCol: displayWidth(hint),
|
|
104
143
|
menu: { lines: menuLinesChoice() },
|
|
105
|
-
caret: false, // 纯导航(非文本输入):不画输入框块状光标,聚焦由选项 ▸ 标记
|
|
106
144
|
});
|
|
107
145
|
}
|
|
108
146
|
else {
|
|
@@ -182,7 +220,8 @@ export async function promptIntervention(req) {
|
|
|
182
220
|
}
|
|
183
221
|
}
|
|
184
222
|
function onKeyChoice(key) {
|
|
185
|
-
const
|
|
223
|
+
const allowCustom = req.allowCustom !== false;
|
|
224
|
+
const itemCount = allowCustom ? options.length + 1 : options.length;
|
|
186
225
|
switch (key.name) {
|
|
187
226
|
case 'up':
|
|
188
227
|
selected = (selected - 1 + itemCount) % itemCount;
|
|
@@ -194,7 +233,7 @@ export async function promptIntervention(req) {
|
|
|
194
233
|
return;
|
|
195
234
|
case 'return':
|
|
196
235
|
case 'enter':
|
|
197
|
-
if (selected === options.length) {
|
|
236
|
+
if (allowCustom && selected === options.length) {
|
|
198
237
|
// 自定义项 → 切 input 子态(空文本起)
|
|
199
238
|
mode = 'input';
|
|
200
239
|
cameFromChoice = true;
|
|
@@ -203,7 +242,7 @@ export async function promptIntervention(req) {
|
|
|
203
242
|
redraw();
|
|
204
243
|
}
|
|
205
244
|
else {
|
|
206
|
-
finish({ action: 'selected', value: options[selected] });
|
|
245
|
+
finish({ action: 'selected', value: options[selected]?.label });
|
|
207
246
|
}
|
|
208
247
|
return;
|
|
209
248
|
}
|
|
@@ -212,7 +251,7 @@ export async function promptIntervention(req) {
|
|
|
212
251
|
if (s >= '1' && s <= '9') {
|
|
213
252
|
const n = Number(s) - 1;
|
|
214
253
|
if (n < options.length) {
|
|
215
|
-
finish({ action: 'selected', value: options[n] });
|
|
254
|
+
finish({ action: 'selected', value: options[n].label });
|
|
216
255
|
}
|
|
217
256
|
}
|
|
218
257
|
}
|