mocode-ai 0.4.5 → 0.4.7
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 -219
- package/dist/config/index.js +2 -1
- package/dist/config/presets.js +177 -0
- package/dist/llm/index.js +5 -1
- package/dist/memory/reflect.js +5 -2
- package/dist/repl/index.js +216 -7
- package/dist/tools/builtins/ask-human.js +70 -43
- package/dist/ui/batch.js +21 -4
- package/dist/ui/diff.js +41 -4
- package/dist/ui/intervention.js +59 -19
- package/dist/ui/layout.js +258 -59
- package/dist/ui/theme.js +22 -0
- package/dist/updater/index.js +4 -2
- package/package.json +1 -1
|
@@ -1,72 +1,85 @@
|
|
|
1
1
|
import { promptIntervention } from '../../ui/intervention.js';
|
|
2
2
|
import { sendState } from '../../pet/bridge.js';
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
* 直接 String(obj) 会变成 "[object Object]"——这里智能提取可读字段。 */
|
|
6
|
-
function optionToString(o) {
|
|
3
|
+
/** 把单个选项元素安全地转成 {label, detail}。LLM 有时传对象而不是字符串,这里提取可读字段。 */
|
|
4
|
+
function optionToChoice(o) {
|
|
7
5
|
if (o === null || o === undefined)
|
|
8
|
-
return '';
|
|
6
|
+
return { label: '' };
|
|
9
7
|
if (typeof o === 'string')
|
|
10
|
-
return o;
|
|
8
|
+
return { label: o };
|
|
11
9
|
if (typeof o === 'number' || typeof o === 'boolean')
|
|
12
|
-
return String(o);
|
|
10
|
+
return { label: String(o) };
|
|
13
11
|
if (typeof o === 'object') {
|
|
14
12
|
const obj = o;
|
|
15
|
-
|
|
13
|
+
const pickString = (source, keys) => {
|
|
14
|
+
for (const k of keys) {
|
|
15
|
+
const v = source[k];
|
|
16
|
+
if (typeof v === 'string' && v.trim())
|
|
17
|
+
return v.trim();
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
};
|
|
16
21
|
const labelKeys = ['label', 'name', 'title', 'text', 'option', 'choice', 'value', 'key'];
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
let label = pickString(obj, labelKeys);
|
|
23
|
+
const nestedOptions = obj.options;
|
|
24
|
+
if (!label && nestedOptions && typeof nestedOptions === 'object' && !Array.isArray(nestedOptions)) {
|
|
25
|
+
label = pickString(nestedOptions, labelKeys);
|
|
21
26
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (
|
|
26
|
-
return
|
|
27
|
+
const desc = pickString(obj, ['description', 'desc', 'detail', 'details', 'reason']);
|
|
28
|
+
if (label && desc && label !== desc)
|
|
29
|
+
return { label, detail: desc };
|
|
30
|
+
if (label)
|
|
31
|
+
return { label };
|
|
32
|
+
if (desc)
|
|
33
|
+
return { label: desc };
|
|
34
|
+
const nestedKeys = ['options', 'option', 'choice', 'value'];
|
|
35
|
+
for (const k of nestedKeys) {
|
|
36
|
+
const nested = obj[k];
|
|
37
|
+
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
|
38
|
+
const c = optionToChoice(nested);
|
|
39
|
+
if (c.label && !c.label.startsWith('{'))
|
|
40
|
+
return c;
|
|
41
|
+
}
|
|
27
42
|
}
|
|
28
|
-
// 兜底:JSON 序列化(去掉大括号让它看起来不像代码)
|
|
29
43
|
try {
|
|
44
|
+
// 兜底:仍 stringify 原值,以便上游能看到真实内容做调试/日志;但若得到字面"{}"或"[]"
|
|
45
|
+
// (说明 obj 本身就是个空对象),返回空 label 让 coerceOptions 的 length>0 filter 自然过滤掉,
|
|
46
|
+
// 避免终端上显示「1. {}」这种对用户无意义的内容。
|
|
30
47
|
const s = JSON.stringify(obj);
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
if (s === '{}' || s === '[]')
|
|
49
|
+
return { label: '' };
|
|
50
|
+
return { label: s };
|
|
33
51
|
}
|
|
34
52
|
catch {
|
|
35
|
-
return String(o);
|
|
53
|
+
return { label: String(o) };
|
|
36
54
|
}
|
|
37
55
|
}
|
|
38
|
-
return String(o);
|
|
56
|
+
return { label: String(o) };
|
|
39
57
|
}
|
|
40
58
|
/** 公开以便 check-ask-human-options.ts 单元测试。 */
|
|
41
59
|
export function coerceOptions(raw) {
|
|
42
|
-
// 路径 1:本身就是数组,map 成字符串。
|
|
43
60
|
if (Array.isArray(raw)) {
|
|
44
|
-
// 子路径 1a:LLM 把真数组包成字符串塞在单元素里(本次 bug 现场)
|
|
45
61
|
if (raw.length === 1 && typeof raw[0] === 'string') {
|
|
46
62
|
const t = raw[0].trim();
|
|
47
63
|
if (t.startsWith('[') && t.endsWith(']')) {
|
|
48
64
|
try {
|
|
49
65
|
const parsed = JSON.parse(t);
|
|
50
66
|
if (Array.isArray(parsed))
|
|
51
|
-
return parsed.map(
|
|
67
|
+
return parsed.map(optionToChoice).filter((o) => o.label.length > 0);
|
|
52
68
|
}
|
|
53
69
|
catch {
|
|
54
70
|
// 不是合法 JSON 数组,降级原值
|
|
55
71
|
}
|
|
56
72
|
}
|
|
57
73
|
}
|
|
58
|
-
return raw.map(
|
|
74
|
+
return raw.map(optionToChoice).filter((o) => o.label.length > 0);
|
|
59
75
|
}
|
|
60
|
-
// 路径 2:LLM 直接把整个数组 stringify 成单字符串塞 options 字段(JSON.parse 出来是字符串)
|
|
61
|
-
// 例如 GLM 系经常这么做,arg h['options']='["A","B"]' → args.options='["A","B"]'
|
|
62
|
-
// 这里解开成真数组再转。
|
|
63
76
|
if (typeof raw === 'string') {
|
|
64
77
|
const t = raw.trim();
|
|
65
78
|
if (t.startsWith('[') && t.endsWith(']')) {
|
|
66
79
|
try {
|
|
67
80
|
const parsed = JSON.parse(t);
|
|
68
81
|
if (Array.isArray(parsed))
|
|
69
|
-
return parsed.map(
|
|
82
|
+
return parsed.map(optionToChoice).filter((o) => o.label.length > 0);
|
|
70
83
|
}
|
|
71
84
|
catch {
|
|
72
85
|
// 不是合法 JSON,保留为单元素数组(对应 input 模式)
|
|
@@ -79,11 +92,11 @@ export function coerceOptions(raw) {
|
|
|
79
92
|
export const askHumanTool = {
|
|
80
93
|
name: 'ask_human',
|
|
81
94
|
description: [
|
|
82
|
-
'Present the user with a menu of choices to pick from
|
|
83
|
-
' DEFAULT: pass 2
|
|
84
|
-
'
|
|
85
|
-
' (
|
|
86
|
-
' DO NOT call when the task is clear and you can pick a sensible default
|
|
95
|
+
'Present the user with a menu of choices to pick from.',
|
|
96
|
+
' DEFAULT: pass 2-4 concrete options via `options`; each is a string, or { label, description } when the choice has a non-obvious tradeoff.',
|
|
97
|
+
' STRICT: object form requires a non-empty `label` — never emit {} or omit label (renders as a literal "{}" menu item). Only `label` and `description` are recognized — no extra fields.',
|
|
98
|
+
' FREE-TEXT (omit `options`): only when the answer truly cannot be reduced to a few choices (e.g. "paste the error message").',
|
|
99
|
+
' DO NOT call when the task is clear and you can pick a sensible default.',
|
|
87
100
|
].join(' '),
|
|
88
101
|
parameters: {
|
|
89
102
|
type: 'object',
|
|
@@ -94,25 +107,39 @@ export const askHumanTool = {
|
|
|
94
107
|
},
|
|
95
108
|
options: {
|
|
96
109
|
type: 'array',
|
|
97
|
-
items: {
|
|
98
|
-
|
|
110
|
+
items: {
|
|
111
|
+
anyOf: [
|
|
112
|
+
{ type: 'string' },
|
|
113
|
+
{
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
label: {
|
|
117
|
+
type: 'string',
|
|
118
|
+
minLength: 1,
|
|
119
|
+
description: 'Short option title (1-5 words), shown as the choice itself.',
|
|
120
|
+
},
|
|
121
|
+
description: {
|
|
122
|
+
type: 'string',
|
|
123
|
+
description: 'What picking this option means or implies; explain the tradeoff when the label alone leaves the user unsure.',
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
required: ['label'],
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
description: '2-4 concrete choices. Each is a plain string, or { label, description } when the tradeoff is non-obvious. Required in most cases; omit only for free-form input.',
|
|
99
131
|
},
|
|
100
132
|
context: {
|
|
101
133
|
type: 'string',
|
|
102
|
-
description: 'Background explanation
|
|
134
|
+
description: 'Background explanation shown under the title; may be multiline.',
|
|
103
135
|
},
|
|
104
136
|
},
|
|
105
137
|
required: ['question'],
|
|
106
138
|
},
|
|
107
139
|
async execute(args) {
|
|
108
140
|
const question = String(args.question ?? '');
|
|
109
|
-
// 容错:部分 LLM(尤其 GLM 系)把数组/对象 stringify 后塞进来,这里识别「长得很像 JSON
|
|
110
|
-
// 数组的单字符串元素」并解开,避免菜单只剩一行 [object Object]、逼用户手动输入。
|
|
111
|
-
// 任何一步失败 / 解出非数组:降级 input,与原代码语义一致。
|
|
112
141
|
const options = coerceOptions(args.options);
|
|
113
142
|
const context = args.context ? String(args.context) : undefined;
|
|
114
|
-
// 桌宠:面板弹出期间广播 waiting_human(红灯闪烁,提示需要人工介入);拿到响应后 sendState 会被
|
|
115
|
-
// 下一个 hook 事件(如 onToolDone→tool_call)覆盖,这里不用手动切回——与其它工具状态转移逻辑一致。
|
|
116
143
|
sendState('waiting_human');
|
|
117
144
|
const result = await promptIntervention({
|
|
118
145
|
type: options.length > 0 ? 'choice' : 'input',
|
package/dist/ui/batch.js
CHANGED
|
@@ -88,10 +88,10 @@ function buildSummaryLine(entries) {
|
|
|
88
88
|
/** 把 batch 的详情行展开成自洽行数组(供 layout.contentInsertAfter 走 mid-buffer 插入)。
|
|
89
89
|
* 每行末尾必须以 \x1B[0m 收尾(SGR 自洽模型),行内允许含 SGR(行末 reset 不影响行内样式),
|
|
90
90
|
* 但**绝不**带 \n——rows[] 是行数组,不是流输出。 */
|
|
91
|
-
function buildExpandedLines(entries) {
|
|
91
|
+
function buildExpandedLines(entries, indent = ' ') {
|
|
92
92
|
const lines = [];
|
|
93
93
|
for (const e of entries) {
|
|
94
|
-
lines.push(
|
|
94
|
+
lines.push(`${indent}${ui.brightMagenta}●${ui.reset} ${ui.cyan}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}\x1B[0m`);
|
|
95
95
|
if (e.diffBlock) {
|
|
96
96
|
// diff 块多行文本(由 renderFileChange 渲染);按 \n 拆成物理行,
|
|
97
97
|
// 每行单独入 rows[]。行末 reset 由本函数统一追加(若原行已带 reset,终端合并即可)。
|
|
@@ -105,7 +105,7 @@ function buildExpandedLines(entries) {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
else if (e.resultSummary) {
|
|
108
|
-
lines.push(
|
|
108
|
+
lines.push(`${indent}${ui.gray}↳ ${e.resultSummary}${ui.reset}\x1B[0m`);
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
return lines;
|
|
@@ -117,6 +117,17 @@ export function endBatch(id, layout) {
|
|
|
117
117
|
return; // 幂等
|
|
118
118
|
// 含 mutation(write_file/edit_file)时强制展开——写盘操作必须让用户看到 diff
|
|
119
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
|
+
}
|
|
120
131
|
const summary = buildSummaryLine(b.entries);
|
|
121
132
|
// 写摘要行(以 \n 收尾;contentWrite 会 breakRow 让其成为完整物理行)
|
|
122
133
|
layout.contentWrite(summary + '\n');
|
|
@@ -204,8 +215,14 @@ export function shiftBatchesAfter(absIdx, delta) {
|
|
|
204
215
|
/** 把已构造好的 BatchEntry[] 直接落成摘要行(用于 renderHistory 回放;不记录 id 也不需可切换)。
|
|
205
216
|
* 含 mutation(write_file/edit_file)时整批展开——与实时 endBatch 行为一致。 */
|
|
206
217
|
export function writeSummaryOnly(entries, layout) {
|
|
207
|
-
layout.contentWrite(buildSummaryLine(entries) + '\n');
|
|
208
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');
|
|
209
226
|
if (hasMutation) {
|
|
210
227
|
// 回放时同步展开:重新调 expand 路径需要 BatchRecord,此处直接拼 line 插入
|
|
211
228
|
const summaryIdx = Math.max(0, layout.totalRows() - 2);
|
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
|
}
|
|
@@ -181,7 +220,8 @@ export async function promptIntervention(req) {
|
|
|
181
220
|
}
|
|
182
221
|
}
|
|
183
222
|
function onKeyChoice(key) {
|
|
184
|
-
const
|
|
223
|
+
const allowCustom = req.allowCustom !== false;
|
|
224
|
+
const itemCount = allowCustom ? options.length + 1 : options.length;
|
|
185
225
|
switch (key.name) {
|
|
186
226
|
case 'up':
|
|
187
227
|
selected = (selected - 1 + itemCount) % itemCount;
|
|
@@ -193,7 +233,7 @@ export async function promptIntervention(req) {
|
|
|
193
233
|
return;
|
|
194
234
|
case 'return':
|
|
195
235
|
case 'enter':
|
|
196
|
-
if (selected === options.length) {
|
|
236
|
+
if (allowCustom && selected === options.length) {
|
|
197
237
|
// 自定义项 → 切 input 子态(空文本起)
|
|
198
238
|
mode = 'input';
|
|
199
239
|
cameFromChoice = true;
|
|
@@ -202,7 +242,7 @@ export async function promptIntervention(req) {
|
|
|
202
242
|
redraw();
|
|
203
243
|
}
|
|
204
244
|
else {
|
|
205
|
-
finish({ action: 'selected', value: options[selected] });
|
|
245
|
+
finish({ action: 'selected', value: options[selected]?.label });
|
|
206
246
|
}
|
|
207
247
|
return;
|
|
208
248
|
}
|
|
@@ -211,7 +251,7 @@ export async function promptIntervention(req) {
|
|
|
211
251
|
if (s >= '1' && s <= '9') {
|
|
212
252
|
const n = Number(s) - 1;
|
|
213
253
|
if (n < options.length) {
|
|
214
|
-
finish({ action: 'selected', value: options[n] });
|
|
254
|
+
finish({ action: 'selected', value: options[n].label });
|
|
215
255
|
}
|
|
216
256
|
}
|
|
217
257
|
}
|