mocode-ai 1.6.4 → 1.6.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.
Files changed (91) hide show
  1. package/README.md +382 -365
  2. package/README.zh-CN.md +19 -2
  3. package/dist/agent/model-turn.js +14 -1
  4. package/dist/agent/run-coordinator.js +7 -1
  5. package/dist/agent/spawn.js +38 -5
  6. package/dist/agent/stages/model-runner.js +1 -1
  7. package/dist/agent/stages/tool-dispatcher.js +1 -0
  8. package/dist/agent/tool-turn.js +2 -0
  9. package/dist/agent/work-discipline.js +3 -1
  10. package/dist/bots/bus.js +117 -0
  11. package/dist/bots/cli.js +102 -0
  12. package/dist/bots/store.js +100 -0
  13. package/dist/config/index.js +53 -2
  14. package/dist/config/presets.js +40 -1
  15. package/dist/config/profiles.js +9 -1
  16. package/dist/context/clearing.js +53 -0
  17. package/dist/context/relevance.js +6 -0
  18. package/dist/context/text-search.js +113 -0
  19. package/dist/headless.js +302 -0
  20. package/dist/index.js +171 -3
  21. package/dist/jobs/approval.js +153 -0
  22. package/dist/jobs/approve-cli.js +30 -0
  23. package/dist/jobs/attach.js +61 -0
  24. package/dist/jobs/checkpoint.js +33 -0
  25. package/dist/jobs/launch.js +107 -0
  26. package/dist/jobs/runner.js +59 -0
  27. package/dist/jobs/store.js +101 -0
  28. package/dist/jobs/worktree.js +63 -0
  29. package/dist/llm/index.js +35 -8
  30. package/dist/llm/providers/anthropic.js +14 -4
  31. package/dist/llm/reasoning.js +141 -0
  32. package/dist/memory/reflect.js +1 -1
  33. package/dist/memory/store.js +31 -28
  34. package/dist/models/budgets.js +11 -0
  35. package/dist/models/catalog.js +130 -0
  36. package/dist/models/map-preset.js +50 -0
  37. package/dist/models/model-panel.js +227 -0
  38. package/dist/models/protocol.js +44 -0
  39. package/dist/models/reasoning-cap.js +88 -0
  40. package/dist/models/search.js +58 -0
  41. package/dist/models/types.js +8 -0
  42. package/dist/notify/index.js +144 -0
  43. package/dist/repl/commands/bots.js +32 -0
  44. package/dist/repl/commands/effort.js +81 -0
  45. package/dist/repl/commands/jobs.js +109 -0
  46. package/dist/repl/commands/model-actions.js +138 -0
  47. package/dist/repl/commands/model-catalog.js +67 -0
  48. package/dist/repl/commands/model-panel-ui.js +107 -0
  49. package/dist/repl/commands/model.js +22 -20
  50. package/dist/repl/commands/registry.js +12 -0
  51. package/dist/repl/commands/schedule.js +37 -0
  52. package/dist/repl/commands/session.js +54 -0
  53. package/dist/repl/commands/stats.js +28 -0
  54. package/dist/repl/commands.js +6 -58
  55. package/dist/repl/runtime.js +5 -1
  56. package/dist/repl/status-bar.js +4 -2
  57. package/dist/rollback/store.js +17 -0
  58. package/dist/runtime/runtime.js +31 -1
  59. package/dist/schedule/cli.js +114 -0
  60. package/dist/schedule/cron.js +79 -0
  61. package/dist/schedule/daemon.js +166 -0
  62. package/dist/schedule/store.js +98 -0
  63. package/dist/session/compact.js +84 -3
  64. package/dist/session/index.js +2 -0
  65. package/dist/session/retention.js +297 -0
  66. package/dist/session/scheduler.js +30 -3
  67. package/dist/session/store.js +108 -6
  68. package/dist/session/usage-stats.js +90 -0
  69. package/dist/skills/activation.js +2 -0
  70. package/dist/skills/builtin-skills.js +153 -0
  71. package/dist/skills/discover.js +3 -1
  72. package/dist/skills/runner.js +4 -1
  73. package/dist/tools/builtins/arena.js +156 -0
  74. package/dist/tools/builtins/index.js +8 -0
  75. package/dist/tools/builtins/message-bus.js +98 -0
  76. package/dist/tools/builtins/read-file.js +19 -1
  77. package/dist/tools/builtins/run-command.js +2 -1
  78. package/dist/tools/builtins/session-search.js +33 -0
  79. package/dist/tools/builtins/task.js +2 -0
  80. package/dist/tools/constants.js +8 -0
  81. package/dist/tools/read-dedup.js +62 -0
  82. package/dist/tools/tool-runtime.js +1 -0
  83. package/dist/ui/batch.js +140 -104
  84. package/dist/ui/composer.js +4 -18
  85. package/dist/ui/fuzzy-picker.js +227 -0
  86. package/dist/ui/hierarchical-picker.js +302 -0
  87. package/dist/ui/intervention.js +17 -1
  88. package/dist/ui/layout-internal/statusbar.js +18 -7
  89. package/dist/ui/prompt-internal/editor.js +10 -27
  90. package/dist/ui/render.js +2 -0
  91. package/package.json +1 -1
@@ -0,0 +1,227 @@
1
+ /**
2
+ * 通用模糊选择器(#模型命令交互)。
3
+ *
4
+ * 把 history-picker 的「输入即过滤 + ↑↓ + Enter」骨架抽成泛型组件:候选是任意对象,
5
+ * 由调用方提供 searchable 文本与渲染行。不绑定「历史/模型」语义,任何「从一大堆项里
6
+ * 挑一个」的场景都能用。
7
+ *
8
+ * 交互契约:
9
+ * - 可打印字符 追加到过滤词,实时模糊过滤(fuzzy.ts)
10
+ * - ↑/↓ 移动高亮(循环;只在可选项间移动)
11
+ * - Enter 确认当前高亮项
12
+ * - Esc / Ctrl+C 取消,返回 null
13
+ * - Backspace 等 常规行编辑;Ctrl+U 清行
14
+ */
15
+ import { stdin } from 'node:process';
16
+ import { ui } from './theme.js';
17
+ import { displayWidth, truncateDisplay } from './render.js';
18
+ import * as layout from './layout.js';
19
+ import { fuzzyMatch } from './fuzzy.js';
20
+ /** 纯函数:按 query 过滤+排序,禁用项沉底并保留(用于呈现,不参与选择)。导出供单测。 */
21
+ export function filterItems(query, items) {
22
+ const q = query.trim();
23
+ const enabled = [];
24
+ const disabled = [];
25
+ for (const item of items) {
26
+ if (!q) {
27
+ (item.disabled ? disabled : enabled).push({ item, score: 0 });
28
+ continue;
29
+ }
30
+ const m = fuzzyMatch(q, item.searchText);
31
+ if (!m)
32
+ continue;
33
+ (item.disabled ? disabled : enabled).push({ item, score: m.score });
34
+ }
35
+ if (q)
36
+ enabled.sort((a, b) => b.score - a.score);
37
+ return [...enabled, ...disabled];
38
+ }
39
+ export async function promptFuzzyPicker(opts) {
40
+ if (!layout.isActive())
41
+ return null;
42
+ const maxVisible = opts.maxVisible ?? 10;
43
+ const emitter = stdin;
44
+ let query = opts.initialQuery ?? '';
45
+ let cursor = query.length;
46
+ let matches = [];
47
+ let selected = 0; // 在 matches 中的下标(可能落在禁用项,移动时跳过)
48
+ let menuTop = 0;
49
+ let resolved = false;
50
+ let resolve;
51
+ /** 可选项(非禁用)下标集合。 */
52
+ function enabledIndices() {
53
+ const out = [];
54
+ matches.forEach((r, i) => {
55
+ if (!r.item.disabled)
56
+ out.push(i);
57
+ });
58
+ return out;
59
+ }
60
+ function clampSelected() {
61
+ const en = enabledIndices();
62
+ if (en.length === 0) {
63
+ selected = matches.length ? 0 : 0;
64
+ return;
65
+ }
66
+ if (!en.includes(selected))
67
+ selected = en[0];
68
+ }
69
+ function recompute() {
70
+ matches = filterItems(query, opts.items);
71
+ clampSelected();
72
+ }
73
+ function menuLines() {
74
+ const cols = layout.getGeo().cols;
75
+ const lines = [`${ui.bold}${truncateDisplay(opts.title, cols)}${ui.reset}`];
76
+ if (opts.hint)
77
+ lines.push(`${ui.dim}${truncateDisplay(opts.hint, cols)}${ui.reset}`);
78
+ const en = enabledIndices();
79
+ if (en.length === 0) {
80
+ lines.push(`${ui.dim}${query.trim() ? '无匹配,按 Esc 返回' : '(空列表)'}${ui.reset}`);
81
+ return lines;
82
+ }
83
+ // 开窗:以 selected 为中心。
84
+ const visibleCount = Math.min(maxVisible, en.length);
85
+ const selPos = en.indexOf(selected);
86
+ let page = Math.floor(selPos / visibleCount);
87
+ const winEn = en.slice(page * visibleCount, page * visibleCount + visibleCount);
88
+ menuTop = winEn[0];
89
+ const hasMore = page * visibleCount + visibleCount < en.length;
90
+ const hasAbove = page > 0;
91
+ winEn.forEach((idx, i) => {
92
+ const r = matches[idx];
93
+ const isSel = idx === selected;
94
+ const color = isSel ? `${ui.accent}${ui.bold}` : '';
95
+ const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
96
+ let scrollHint = '';
97
+ if (i === 0 && hasAbove)
98
+ scrollHint = ' ▲';
99
+ if (i === winEn.length - 1 && hasMore)
100
+ scrollHint = ' ▼';
101
+ const detail = r.item.detail ? ` ${ui.dim}${r.item.detail}${ui.reset}` : '';
102
+ const budget = cols - 4 - scrollHint.length - (r.item.detail ? displayWidth(r.item.detail) + 2 : 0);
103
+ const body = truncateDisplay(r.item.label, Math.max(1, budget));
104
+ lines.push(`${marker} ${color}${body}${ui.reset}${detail}${ui.dim}${scrollHint}${ui.reset}`);
105
+ });
106
+ return lines;
107
+ }
108
+ function redraw() {
109
+ layout.paintInput({
110
+ prompt: '❯ ',
111
+ lines: [query],
112
+ cursorLine: 0,
113
+ cursorCol: displayWidth(query.slice(0, cursor)),
114
+ menu: { lines: menuLines() },
115
+ });
116
+ }
117
+ function finish(v) {
118
+ if (resolved)
119
+ return;
120
+ resolved = true;
121
+ layout.setMouseEnabled(true); // 恢复输入框态鼠标(框选/右键粘贴)
122
+ try {
123
+ stdin.setRawMode(false);
124
+ }
125
+ catch {
126
+ /* 忽略 */
127
+ }
128
+ emitter.removeListener('keypress', onKey);
129
+ stdin.pause();
130
+ // 擦菜单区。
131
+ layout.paintInput({ prompt: '❯ ', lines: [''], cursorLine: 0, cursorCol: 0, menu: null });
132
+ resolve(v);
133
+ }
134
+ function move(delta) {
135
+ const en = enabledIndices();
136
+ if (en.length === 0)
137
+ return;
138
+ const pos = en.indexOf(selected);
139
+ const nextPos = (pos + delta + en.length) % en.length;
140
+ selected = en[nextPos];
141
+ redraw();
142
+ }
143
+ function onKey(_str, key) {
144
+ if (key.name === 'escape')
145
+ return finish(null);
146
+ if (key.ctrl && key.name === 'c')
147
+ return finish(null);
148
+ if (key.name === 'return' || key.name === 'enter') {
149
+ const r = matches[selected];
150
+ if (r && !r.item.disabled)
151
+ finish(r.item.value);
152
+ return;
153
+ }
154
+ switch (key.name) {
155
+ case 'up':
156
+ move(-1);
157
+ return;
158
+ case 'down':
159
+ move(1);
160
+ return;
161
+ case 'left':
162
+ if (cursor > 0) {
163
+ cursor--;
164
+ redraw();
165
+ }
166
+ return;
167
+ case 'right':
168
+ if (cursor < query.length) {
169
+ cursor++;
170
+ redraw();
171
+ }
172
+ return;
173
+ case 'home':
174
+ cursor = 0;
175
+ redraw();
176
+ return;
177
+ case 'end':
178
+ cursor = query.length;
179
+ redraw();
180
+ return;
181
+ case 'backspace':
182
+ if (cursor > 0) {
183
+ query = query.slice(0, cursor - 1) + query.slice(cursor);
184
+ cursor--;
185
+ recompute();
186
+ redraw();
187
+ }
188
+ return;
189
+ case 'delete':
190
+ if (cursor < query.length) {
191
+ query = query.slice(0, cursor) + query.slice(cursor + 1);
192
+ recompute();
193
+ redraw();
194
+ }
195
+ return;
196
+ }
197
+ if (key.ctrl && key.name === 'u') {
198
+ query = '';
199
+ cursor = 0;
200
+ recompute();
201
+ redraw();
202
+ return;
203
+ }
204
+ const s = key.sequence ?? '';
205
+ if (s && s >= ' ' && !key.ctrl && !key.meta) {
206
+ query = query.slice(0, cursor) + s + query.slice(cursor);
207
+ cursor += s.length;
208
+ recompute();
209
+ redraw();
210
+ }
211
+ }
212
+ recompute();
213
+ return new Promise((res) => {
214
+ resolve = res;
215
+ layout.setMouseEnabled(false);
216
+ try {
217
+ stdin.setRawMode(true);
218
+ }
219
+ catch {
220
+ res(null);
221
+ return;
222
+ }
223
+ stdin.resume();
224
+ emitter.on('keypress', onKey);
225
+ redraw();
226
+ });
227
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * 层级模糊选择器(#模型命令交互·两级浏览)。
3
+ *
4
+ * 一个按层级浏览的选择器(厂商 → 模型),搜索栏是**作用域内过滤**,不跨层:
5
+ * - 顶层(厂商列表):打字只匹配**厂商名**,Enter 钻入选中厂商。
6
+ * - 进入厂商后:打字只匹配**该厂商下的模型名**,Enter 确认模型;Backspace 回厂商。
7
+ * 不会把模型和厂商混在一个搜索结果里。
8
+ *
9
+ * 交互契约:
10
+ * - 可打印字符 追加到过滤词,实时过滤当前层级
11
+ * - ↑/↓ 移动高亮(循环;跳过禁用项)
12
+ * - Enter 分支钻入 / 叶子确认
13
+ * - Backspace 编辑过滤词;词为空时退回上一层
14
+ * - Esc 先清过滤词 → 再退回上一层 → 顶层则取消
15
+ * - Ctrl+C 取消,返回 null
16
+ * - Ctrl+U 清空过滤词
17
+ */
18
+ import { stdin } from 'node:process';
19
+ import { ui } from './theme.js';
20
+ import { displayWidth, truncateDisplay } from './render.js';
21
+ import * as layout from './layout.js';
22
+ import { fuzzyMatch } from './fuzzy.js';
23
+ /** 深度优先拍平整棵树(含分支与叶子)。纯函数,导出供单测/外部复用。 */
24
+ export function flattenTree(roots) {
25
+ const out = [];
26
+ const walk = (nodes, path, ancestorLabels) => {
27
+ nodes.forEach((node, i) => {
28
+ const here = [...path, i];
29
+ out.push({ node, path: here, crumb: ancestorLabels.join(' › ') });
30
+ if (node.children && node.children.length) {
31
+ walk(node.children, here, [...ancestorLabels, node.label]);
32
+ }
33
+ });
34
+ };
35
+ walk(roots, [], []);
36
+ return out;
37
+ }
38
+ export async function promptHierarchicalPicker(opts) {
39
+ if (!layout.isActive())
40
+ return null;
41
+ const maxVisible = opts.maxVisible ?? 12;
42
+ const emitter = stdin;
43
+ let query = '';
44
+ let cursor = 0;
45
+ let path = [];
46
+ let cands = [];
47
+ let selected = 0;
48
+ let resolved = false;
49
+ let resolve;
50
+ /** 沿 path 取当前层级节点列表。 */
51
+ function currentNodes() {
52
+ let nodes = opts.roots;
53
+ for (const idx of path) {
54
+ const next = nodes[idx]?.children;
55
+ if (!next)
56
+ return [];
57
+ nodes = next;
58
+ }
59
+ return nodes;
60
+ }
61
+ function enabledIndices() {
62
+ const out = [];
63
+ cands.forEach((c, i) => {
64
+ if (!c.node.disabled)
65
+ out.push(i);
66
+ });
67
+ return out;
68
+ }
69
+ function clampSelected() {
70
+ const en = enabledIndices();
71
+ if (en.length === 0) {
72
+ selected = 0;
73
+ return;
74
+ }
75
+ if (!en.includes(selected))
76
+ selected = en[0];
77
+ }
78
+ /** 只过滤当前层级:空 query 全显示;有 query 在本层节点 searchText 上模糊匹配。 */
79
+ function recompute() {
80
+ const q = query.trim();
81
+ const enabled = [];
82
+ const disabled = [];
83
+ currentNodes().forEach((node, i) => {
84
+ let m = { score: 0 };
85
+ if (q) {
86
+ m = fuzzyMatch(q, node.searchText);
87
+ if (!m)
88
+ return;
89
+ }
90
+ const c = { node, score: m.score };
91
+ if (node.children && node.children.length)
92
+ c.enterPath = [...path, i];
93
+ else
94
+ c.value = node.value;
95
+ (node.disabled ? disabled : enabled).push(c);
96
+ });
97
+ if (q)
98
+ enabled.sort((a, b) => b.score - a.score);
99
+ cands = [...enabled, ...disabled];
100
+ clampSelected();
101
+ }
102
+ function titleLine() {
103
+ const parts = [opts.title];
104
+ let nodes = opts.roots;
105
+ for (const idx of path) {
106
+ parts.push(nodes[idx]?.label ?? '');
107
+ nodes = nodes[idx]?.children ?? [];
108
+ }
109
+ return parts.join(' › ');
110
+ }
111
+ /** 随层级变化的操作提示。 */
112
+ function hintLine() {
113
+ return path.length === 0
114
+ ? '打字搜索厂商 · ↑↓ 选择 · Enter 进入厂商 · Esc 退出'
115
+ : '打字搜索模型 · ↑↓ 选择 · Enter 确认 · Backspace 返回厂商列表';
116
+ }
117
+ function menuLines() {
118
+ const cols = layout.getGeo().cols;
119
+ const lines = [`${ui.bold}${truncateDisplay(titleLine(), cols)}${ui.reset}`];
120
+ lines.push(`${ui.dim}${truncateDisplay(hintLine(), cols)}${ui.reset}`);
121
+ const en = enabledIndices();
122
+ if (en.length === 0) {
123
+ lines.push(`${ui.dim}${query.trim() ? '无匹配,按 Esc 返回' : '(空列表)'}${ui.reset}`);
124
+ return lines;
125
+ }
126
+ const visibleCount = Math.min(maxVisible, en.length);
127
+ const selPos = en.indexOf(selected);
128
+ const page = Math.floor(selPos / visibleCount);
129
+ const winEn = en.slice(page * visibleCount, page * visibleCount + visibleCount);
130
+ const hasMore = page * visibleCount + visibleCount < en.length;
131
+ const hasAbove = page > 0;
132
+ winEn.forEach((idx, i) => {
133
+ const c = cands[idx];
134
+ const isSel = idx === selected;
135
+ const isBranch = !!c.enterPath;
136
+ const color = isSel ? `${ui.accent}${ui.bold}` : '';
137
+ const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
138
+ let scrollHint = '';
139
+ if (i === 0 && hasAbove)
140
+ scrollHint = ' ▲';
141
+ if (i === winEn.length - 1 && hasMore)
142
+ scrollHint = ' ▼';
143
+ // 分支尾部提示「进入」;叶子 detail 原样。
144
+ const detail = [c.node.detail, isBranch ? '进入 →' : ''].filter(Boolean).join(' · ');
145
+ const detailStr = detail ? ` ${ui.dim}${detail}${ui.reset}` : '';
146
+ const budget = cols - 4 - scrollHint.length - (detail ? displayWidth(detail) + 2 : 0);
147
+ const body = truncateDisplay(c.node.label, Math.max(1, budget));
148
+ lines.push(`${marker} ${color}${body}${ui.reset}${detailStr}${ui.dim}${scrollHint}${ui.reset}`);
149
+ });
150
+ return lines;
151
+ }
152
+ function redraw() {
153
+ layout.paintInput({
154
+ prompt: '❯ ',
155
+ lines: [query],
156
+ cursorLine: 0,
157
+ cursorCol: displayWidth(query.slice(0, cursor)),
158
+ menu: { lines: menuLines() },
159
+ });
160
+ }
161
+ function drill(to) {
162
+ path = to;
163
+ query = '';
164
+ cursor = 0;
165
+ recompute();
166
+ redraw();
167
+ }
168
+ function goUp() {
169
+ if (path.length === 0)
170
+ return false;
171
+ path = path.slice(0, -1);
172
+ recompute();
173
+ redraw();
174
+ return true;
175
+ }
176
+ function finish(v) {
177
+ if (resolved)
178
+ return;
179
+ resolved = true;
180
+ layout.setMouseEnabled(true);
181
+ try {
182
+ stdin.setRawMode(false);
183
+ }
184
+ catch {
185
+ /* 忽略 */
186
+ }
187
+ emitter.removeListener('keypress', onKey);
188
+ stdin.pause();
189
+ layout.paintInput({ prompt: '❯ ', lines: [''], cursorLine: 0, cursorCol: 0, menu: null });
190
+ resolve(v);
191
+ }
192
+ function move(delta) {
193
+ const en = enabledIndices();
194
+ if (en.length === 0)
195
+ return;
196
+ const pos = en.indexOf(selected);
197
+ const nextPos = (pos + delta + en.length) % en.length;
198
+ selected = en[nextPos];
199
+ redraw();
200
+ }
201
+ function onKey(_str, key) {
202
+ if (key.name === 'escape') {
203
+ if (query) {
204
+ query = '';
205
+ cursor = 0;
206
+ recompute();
207
+ redraw();
208
+ return;
209
+ }
210
+ if (goUp())
211
+ return;
212
+ return finish(null);
213
+ }
214
+ if (key.ctrl && key.name === 'c')
215
+ return finish(null);
216
+ if (key.name === 'return' || key.name === 'enter') {
217
+ const c = cands[selected];
218
+ if (!c || c.node.disabled)
219
+ return;
220
+ if (c.enterPath)
221
+ return drill(c.enterPath);
222
+ if (c.value !== undefined)
223
+ return finish(c.value);
224
+ return;
225
+ }
226
+ switch (key.name) {
227
+ case 'up':
228
+ move(-1);
229
+ return;
230
+ case 'down':
231
+ move(1);
232
+ return;
233
+ case 'left':
234
+ if (cursor > 0) {
235
+ cursor--;
236
+ redraw();
237
+ }
238
+ return;
239
+ case 'right':
240
+ if (cursor < query.length) {
241
+ cursor++;
242
+ redraw();
243
+ }
244
+ return;
245
+ case 'home':
246
+ cursor = 0;
247
+ redraw();
248
+ return;
249
+ case 'end':
250
+ cursor = query.length;
251
+ redraw();
252
+ return;
253
+ case 'backspace':
254
+ if (cursor > 0) {
255
+ query = query.slice(0, cursor - 1) + query.slice(cursor);
256
+ cursor--;
257
+ recompute();
258
+ redraw();
259
+ }
260
+ else if (!query) {
261
+ goUp();
262
+ }
263
+ return;
264
+ case 'delete':
265
+ if (cursor < query.length) {
266
+ query = query.slice(0, cursor) + query.slice(cursor + 1);
267
+ recompute();
268
+ redraw();
269
+ }
270
+ return;
271
+ }
272
+ if (key.ctrl && key.name === 'u') {
273
+ query = '';
274
+ cursor = 0;
275
+ recompute();
276
+ redraw();
277
+ return;
278
+ }
279
+ const s = key.sequence ?? '';
280
+ if (s && s >= ' ' && !key.ctrl && !key.meta) {
281
+ query = query.slice(0, cursor) + s + query.slice(cursor);
282
+ cursor += s.length;
283
+ recompute();
284
+ redraw();
285
+ }
286
+ }
287
+ recompute();
288
+ return new Promise((res) => {
289
+ resolve = res;
290
+ layout.setMouseEnabled(false);
291
+ try {
292
+ stdin.setRawMode(true);
293
+ }
294
+ catch {
295
+ res(null);
296
+ return;
297
+ }
298
+ stdin.resume();
299
+ emitter.on('keypress', onKey);
300
+ redraw();
301
+ });
302
+ }
@@ -5,12 +5,23 @@ import { displayWidth, truncateDisplay, visColToCharCol, wrapByDisplayWidth } fr
5
5
  import * as layout from './layout.js';
6
6
  import * as mouse from './mouse.js';
7
7
  import { Spinner } from './spinner.js';
8
+ import { resumeSweep, suspendSweep } from './batch.js';
8
9
  import { t } from '../i18n/index.js';
9
10
  const emitter = stdin;
10
11
  /** choice 末尾的本地化“自定义输入”项标签。 */
11
12
  const customLabel = () => t('intervention.custom');
12
13
  /** 弹出介入面板,阻塞直到用户完成选择。 */
13
- export async function promptIntervention(req) {
14
+ export function promptIntervention(req) {
15
+ // P4 闸1:并发子 agent 可能同时弹面板。面板进出场全是进程级单例(stdin 监听集/
16
+ // 鼠标开关/走时计时器/光标处理器),两个面板交错会导致监听恢复错乱与永久泄漏。
17
+ // 用承诺队列串行化:同一时刻只有一个面板,其余排队;非 TTY 分支不碰单例,排队也无害。
18
+ const run = () => promptInterventionInner(req);
19
+ const result = panelQueue.then(run, run);
20
+ panelQueue = result.then(() => undefined, () => undefined);
21
+ return result;
22
+ }
23
+ let panelQueue = Promise.resolve();
24
+ async function promptInterventionInner(req) {
14
25
  // 非 TTY 降级:不阻塞,自动选默认。日志走 stderr(不污染 stdout 内容流)。
15
26
  if (!layout.isActive()) {
16
27
  const kind = req.type === 'choice' ? t('intervention.defaultChoice') : t('intervention.emptyInput');
@@ -211,6 +222,8 @@ export async function promptIntervention(req) {
211
222
  layout.resetScroll();
212
223
  else
213
224
  layout.repaintViewport();
225
+ // 恢复扫光心跳(若仍有在飞批):必须在 repaintViewport 之后——先恢复内容区,再让心跳重画摘要行。
226
+ resumeSweep();
214
227
  // 恢复走时计时器(RUNNING 态):面板期间 stopTurnTimer 停了心跳,退出后恢复状态行 80ms 刷新。
215
228
  layout.startTurnTimerIfRunning();
216
229
  }
@@ -344,6 +357,9 @@ export async function promptIntervention(req) {
344
357
  // 把真光标拉到 runningCaretPos 覆盖 paintInput 的正确光标位)+ 禁鼠标框选(防拖拽 viewport 重画覆盖菜单)+ 回尾(若用户正滚动回看)
345
358
  Spinner.pauseCurrent();
346
359
  layout.stopTurnTimer();
360
+ // 暂停 batch 摘要行扫光:摘要行在内容区底,菜单向上展开会与之重叠,心跳继续重画会
361
+ // 把「◇ 正在探索」帧泄漏到选项之间(cleanup 退出时 resumeSweep 恢复)。
362
+ suspendSweep();
347
363
  layout.setMouseEnabled(false);
348
364
  layout.resetScroll();
349
365
  // 快照现有 keypress 监听(运行态的 onRunningKey)并摘掉,挂自己的 onKey
@@ -89,15 +89,26 @@ export function composeModelLine(status, cols) {
89
89
  const HINT = t('status.modeSwitch');
90
90
  const hintW = modeTag ? displayWidth(HINT) : 0;
91
91
  const hintPart = modeTag ? `${ui.dim}${HINT}${ui.reset}` : '';
92
+ // P3 effort chip:非 auto 时显示,与 modeTag 同级色(运行时状态),优先级在 hint 之上。
93
+ const effortTag = status.effort ?? '';
94
+ // 闪电标识按强度递增:low=1 / medium=2 / high=3,off=0;⚡ 宽度在 charWidth 按 2 格算,
95
+ // 与终端一致,不会再与右侧内容重叠产生残影。标签(low/medium/high)紧随其后明示档位。
96
+ const boltCount = effortTag === 'low' ? 1 : effortTag === 'medium' ? 2 : effortTag === 'high' ? 3 : 0;
97
+ const bolts = '⚡'.repeat(boltCount);
98
+ const effortPart = effortTag ? `${modeColor}${bolts}${effortTag}${ui.reset}` : '';
99
+ const effortW = effortTag ? displayWidth(bolts) + displayWidth(effortTag) : 0;
100
+ const sepME = modePart && effortPart ? STATUS_SEP : '';
92
101
  const tokChip = formatTurnTokenChip(status.lastTurnUsage);
93
102
  const tokW = displayWidth(stripAnsi(tokChip));
94
103
  // 合并左段:段间留 2 空格分隔。极窄时 hint 与 chip 都可能藏掉。
95
104
  // 优先级:modeTag(必) > chip(提示累计 token,有信息量)> hint(纯说明性,窄时最先省)。
96
- const sepMH = modePart && (hintPart || tokChip) ? ' ' : '';
105
+ const sepMH = (modePart || effortPart) && (hintPart || tokChip) ? ' ' : '';
97
106
  const sepHT = hintPart && tokChip ? ' ' : '';
98
- const leftStr = `${modePart}${sepMH}${hintPart}${sepHT}${tokChip}`;
107
+ const leftStr = `${modePart}${sepME}${effortPart}${sepMH}${hintPart}${sepHT}${tokChip}`;
99
108
  const leftW = modeW +
100
- (modePart && (hintPart || tokChip) ? sepMH.length : 0) +
109
+ (sepME ? sepME.length : 0) +
110
+ effortW +
111
+ ((modePart || effortPart) && (hintPart || tokChip) ? sepMH.length : 0) +
101
112
  hintW +
102
113
  (hintPart && tokChip ? sepHT.length : 0) +
103
114
  tokW;
@@ -118,15 +129,15 @@ export function composeModelLine(status, cols) {
118
129
  if (leftW + minGap + rightW > cols) {
119
130
  // 优先藏 hint(纯说明,信息密度最低):仅留 modePart + sep + tokChip
120
131
  if (modePart && tokChip && hintPart) {
121
- const leftStr2 = `${modePart}${sepMH}${tokChip}`;
122
- const leftW2 = modeW + sepMH.length + tokW;
132
+ const leftStr2 = `${modePart}${sepME}${effortPart}${effortPart ? STATUS_SEP : ''}${tokChip}`;
133
+ const leftW2 = modeW + (sepME ? sepME.length : 0) + effortW + (effortPart ? STATUS_SEP_W : 0) + tokW;
123
134
  if (leftW2 + minGap + rightW <= cols) {
124
135
  return twoColumn(leftStr2, leftW2, rightStr, rightW, cols);
125
136
  }
126
137
  }
127
138
  // 再藏 token chip:仅留 modePart
128
- if (modePart && tokChip) {
129
- return twoColumn(modePart, modeW, rightStr, rightW, cols);
139
+ if ((modePart || effortPart) && tokChip) {
140
+ return twoColumn(`${modePart}${sepME}${effortPart}`, modeW + (sepME ? sepME.length : 0) + effortW, rightStr, rightW, cols);
130
141
  }
131
142
  }
132
143
  return twoColumn(leftStr, leftW, rightStr, rightW, cols);
@@ -159,12 +159,6 @@ export async function promptWithSlashMenu(opts) {
159
159
  }
160
160
  return { line: dl, col: displayWidth(dispLines[dl].slice(0, dispColChars)) };
161
161
  }
162
- /** 展示 (行, 显示列) → 段内字符偏移。供 ↑/↓ 移动后重新定位,以及鼠标点击的反向映射。 */
163
- function dispColToCharInLine(dispLine, dispCol) {
164
- const { lines } = buildDisplay();
165
- const ln = lines[dispLine] ?? '';
166
- return visColToCharCol(ln, dispCol);
167
- }
168
162
  /** 展示 (行, 字符内偏移) → 段+偏移。光标永远落在可编辑段;点中 chip 则吸附到最近的可编辑边界。 */
169
163
  function mapDispToCursor(dispLine, charInLine) {
170
164
  const { lines: dispLines, segStart } = buildDisplay();
@@ -732,16 +726,11 @@ export async function promptWithSlashMenu(opts) {
732
726
  redraw();
733
727
  }
734
728
  else {
735
- const { line, col } = cursorDisp();
736
- if (line > 0) {
737
- const m = mapDispToCursor(line - 1, dispColToCharInLine(line - 1, col));
738
- curSeg = m.seg;
739
- curOff = m.off;
740
- redraw();
741
- }
742
- else {
743
- layout.scrollWheel(1); // 等同鼠标滚轮上滚一格
744
- }
729
+ // ↑ 始终跳到全文最前(含跨只读粘贴块,落到首个可编辑段开头)
730
+ const p = firstEditableStart();
731
+ curSeg = p.seg;
732
+ curOff = p.off;
733
+ redraw();
745
734
  }
746
735
  return;
747
736
  case 'down':
@@ -750,17 +739,11 @@ export async function promptWithSlashMenu(opts) {
750
739
  redraw();
751
740
  }
752
741
  else {
753
- const { lines: dispLines } = buildDisplay();
754
- const { line, col } = cursorDisp();
755
- if (line < dispLines.length - 1) {
756
- const m = mapDispToCursor(line + 1, dispColToCharInLine(line + 1, col));
757
- curSeg = m.seg;
758
- curOff = m.off;
759
- redraw();
760
- }
761
- else {
762
- layout.scrollWheel(-1); // 等同鼠标滚轮下滚一格
763
- }
742
+ // ↓ 始终跳到全文最后(含跨只读粘贴块,落到末个可编辑段末尾)
743
+ const p = lastEditableEnd();
744
+ curSeg = p.seg;
745
+ curOff = p.off;
746
+ redraw();
764
747
  }
765
748
  return;
766
749
  case 'tab':
package/dist/ui/render.js CHANGED
@@ -60,6 +60,8 @@ export function charWidth(cp) {
60
60
  return 2; // 表情
61
61
  if (cp >= 0x20000 && cp <= 0x2fffd)
62
62
  return 2; // CJK 扩展 B-F
63
+ if (cp === 0x26a1)
64
+ return 2; // ⚡ 闪电:终端按 emoji 2 格渲染,算 1 会与 chip 重叠产生残影
63
65
  return 1;
64
66
  }
65
67
  export function displayWidth(str) {