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.
@@ -1,72 +1,85 @@
1
1
  import { promptIntervention } from '../../ui/intervention.js';
2
2
  import { sendState } from '../../pet/bridge.js';
3
- /** 将单个选项元素安全地转为可读字符串。
4
- * LLM 有时会传对象( {name/label/title:"xxx", desc/description:"yyy"})而不是纯字符串,
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
- for (const k of labelKeys) {
18
- const v = obj[k];
19
- if (typeof v === 'string' && v.trim())
20
- return v;
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
- // 其次尝试 "label + description" 组合
23
- const label = obj.label ?? obj.name ?? obj.title;
24
- const desc = obj.description ?? obj.desc ?? obj.detail;
25
- if (typeof label === 'string' && typeof desc === 'string') {
26
- return `${label}: ${desc}`;
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
- return s;
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(optionToString);
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(optionToString);
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(optionToString);
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 — not a generic "ask" tool.',
83
- ' DEFAULT: pass 2–6 concrete options via `options`; the user picks one and the pick comes back.',
84
- ' FREE-TEXT (omit `options`): only when the answer truly cannot be reduced to a few choices',
85
- ' (e.g. "paste the error message", "enter the exact URL") this blocks with a text input.',
86
- ' DO NOT call when the task is clear and you can pick a sensible default — decide and proceed.',
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: { type: 'string' },
98
- description: '2–6 concrete choices the user can pick with one click. Required in most cases; omit only when free-form text is genuinely needed.',
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 for the question (optional; helps the user understand why their decision is needed; shown under the title, may be multiline)',
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(` ${ui.brightMagenta}●${ui.reset} ${ui.cyan}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}\x1B[0m`);
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(` ${ui.gray}↳ ${e.resultSummary}${ui.reset}\x1B[0m`);
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
- lines.push(`${BODY_INDENT}${ui.gray}${numStr}${ui.reset} ${gutterOf(op)} ${codeText(text, lang)}`);
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) {
@@ -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
- return { action: 'selected', value: req.options?.[0] ?? '' };
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.map((o) => String(o)).filter((s) => s.length > 0)
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,行数上限保选项可见)+ 空行 + 选项(▸ 选中/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 items = [...options, CUSTOM_LABEL];
47
+ const allowCustom = req.allowCustom !== false;
48
+ const items = allowCustom ? [...options, { label: CUSTOM_LABEL }] : [...options];
44
49
  const optionCount = items.length;
45
- // detail 上限:title(1)+detail(D)+空行(1)+options(O) contentBottom D ≤ contentBottom-2-O
46
- const detailCap = Math.max(0, g.contentBottom - 2 - optionCount);
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
- if (optionCount > maxOptRows) {
63
- start = Math.max(0, Math.min(selected - Math.floor(maxOptRows / 2), optionCount - maxOptRows));
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
- const count = Math.min(maxOptRows, optionCount);
66
- for (let i = 0; i < count; i++) {
67
- const idx = start + i;
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
- const body = truncateDisplay(items[idx], cols - 2);
73
- lines.push(`${marker} ${color}${body}${ui.reset}`);
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 itemCount = options.length + 1; // +自定义项
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
  }