mocode-ai 1.6.5 → 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.
@@ -0,0 +1,98 @@
1
+ // 调度计划存储:<sandboxRoot>/.mocode/schedules/<id>.json。
2
+ // 一个 schedule 有两种触发方式:cron(周期)或 webhook(仅 HTTP 触发,cron 为空串)。
3
+ // 同一 schedule 也可两者并存(cron 表达式 + webhookToken 都配)。
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { getSandboxRoot } from '../sandbox/index.js';
8
+ import { parseCron } from './cron.js';
9
+ function schedulesRoot() {
10
+ return path.join(getSandboxRoot() ?? process.cwd(), '.mocode', 'schedules');
11
+ }
12
+ function recordPath(id) {
13
+ return path.join(schedulesRoot(), `${id}.json`);
14
+ }
15
+ export function createScheduleId() {
16
+ return `sch-${Date.now()}-${randomBytes(2).toString('hex')}`;
17
+ }
18
+ /** 校验候选字段;非法(cron 语法错 / 无任何触发器)抛错。 */
19
+ function validate(cron, webhookToken) {
20
+ if (cron.trim() !== '')
21
+ parseCron(cron);
22
+ if (!cron.trim() && !webhookToken) {
23
+ throw new Error('schedule needs a cron expression or a webhook token (no trigger configured)');
24
+ }
25
+ }
26
+ export function saveSchedule(record) {
27
+ fs.mkdirSync(schedulesRoot(), { recursive: true });
28
+ const target = recordPath(record.id);
29
+ const tmp = `${target}.tmp`;
30
+ fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
31
+ fs.renameSync(tmp, target);
32
+ }
33
+ export function getSchedule(id) {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(recordPath(id), 'utf8'));
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** 创建并落盘一个 schedule。 */
42
+ export function createSchedule(input) {
43
+ const cron = (input.cron ?? '').trim();
44
+ const webhookToken = input.webhook ? randomBytes(8).toString('hex') : undefined;
45
+ validate(cron, webhookToken);
46
+ const record = {
47
+ id: createScheduleId(),
48
+ name: input.name,
49
+ prompt: input.prompt,
50
+ cron,
51
+ enabled: true,
52
+ ...(webhookToken ? { webhookToken } : {}),
53
+ ...(input.worktree ? { worktree: true } : {}),
54
+ ...(input.sessionDir ? { sessionDir: input.sessionDir } : {}),
55
+ createdAt: new Date().toISOString(),
56
+ };
57
+ saveSchedule(record);
58
+ return record;
59
+ }
60
+ export function updateSchedule(id, patch) {
61
+ const current = getSchedule(id);
62
+ if (!current)
63
+ return null;
64
+ const next = { ...current, ...patch };
65
+ validate(next.cron, next.webhookToken);
66
+ saveSchedule(next);
67
+ return next;
68
+ }
69
+ export function deleteSchedule(id) {
70
+ try {
71
+ fs.rmSync(recordPath(id));
72
+ return true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ export function listSchedules() {
79
+ let files;
80
+ try {
81
+ files = fs.readdirSync(schedulesRoot());
82
+ }
83
+ catch {
84
+ return [];
85
+ }
86
+ const records = [];
87
+ for (const file of files) {
88
+ if (!file.endsWith('.json'))
89
+ continue;
90
+ try {
91
+ records.push(JSON.parse(fs.readFileSync(path.join(schedulesRoot(), file), 'utf8')));
92
+ }
93
+ catch {
94
+ // skip corrupt
95
+ }
96
+ }
97
+ return records.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
98
+ }
@@ -0,0 +1,156 @@
1
+ import { spawnAgent } from '../../agent/spawn.js';
2
+ import { chat } from '../../llm/index.js';
3
+ import { effectiveSystemPrompt } from '../../skills/index.js';
4
+ // ---------- arena ----------
5
+ // D2 Arena:同一任务并行派生 N 个独立子 agent(各自独立 history、不同随机分支),
6
+ // 收齐后用一次模型调用当 judge,按给定标准对候选排序,返回排名 + 最优方案。
7
+ //
8
+ // 定位:方案 / 答案的“多跑几遍选最好”(生成候选文本)。N 个 agent 并发跑,
9
+ // 因此不要让它们并发改同一工作区文件——适合探索、设计、解题、给方案。
10
+ export const arenaTool = {
11
+ name: 'arena',
12
+ risk: 'dangerous',
13
+ description: [
14
+ 'Run the same task N times in parallel as independent workers, then have a judge model rank every candidate and return the best one with the full ranking.',
15
+ 'Use for design/exploration/problem-solving where sampling several independent attempts and picking the best beats a single attempt. Not for parallel file edits.',
16
+ ].join(''),
17
+ parameters: {
18
+ type: 'object',
19
+ properties: {
20
+ prompt: {
21
+ type: 'string',
22
+ description: 'The task given identically to every candidate worker.',
23
+ },
24
+ candidates: {
25
+ type: 'number',
26
+ description: 'Number of parallel attempts (default 3, max 6).',
27
+ },
28
+ criteria: {
29
+ type: 'string',
30
+ description: 'Optional judging criteria the judge ranks candidates against.',
31
+ },
32
+ maxSteps: {
33
+ type: 'number',
34
+ description: 'Optional per-candidate step ceiling.',
35
+ },
36
+ },
37
+ required: ['prompt'],
38
+ },
39
+ async execute(args, ctx) {
40
+ const prompt = String(args.prompt ?? '');
41
+ if (!prompt)
42
+ return 'arena requires a prompt';
43
+ const n = Math.min(6, Math.max(2, Math.floor(Number(args.candidates) || 3)));
44
+ const criteria = typeof args.criteria === 'string' ? args.criteria : 'correctness, completeness, clarity';
45
+ const maxSteps = typeof args.maxSteps === 'number' && args.maxSteps > 0 ? Math.floor(args.maxSteps) : undefined;
46
+ // 并行派生,quiet:不刷屏,结果只在本工具内汇总。
47
+ const settled = await Promise.allSettled(Array.from({ length: n }, (_, i) => spawnAgent({
48
+ prompt: `[Arena candidate #${i + 1} of ${n}]\n${prompt}`,
49
+ quiet: true,
50
+ maxSteps,
51
+ signal: ctx?.signal,
52
+ parentAllowedToolNames: ctx?.allowedToolNames,
53
+ })));
54
+ const candidates = [];
55
+ settled.forEach((r, i) => {
56
+ const label = `candidate-${i + 1}`;
57
+ if (r.status === 'fulfilled' && r.value.summary) {
58
+ candidates.push({ label, text: r.value.summary, tokens: r.value.usage.totalTokens });
59
+ }
60
+ else {
61
+ candidates.push({
62
+ label,
63
+ text: r.status === 'rejected' ? `(worker threw: ${String(r.reason).slice(0, 120)})` : '(no result)',
64
+ tokens: 0,
65
+ });
66
+ }
67
+ });
68
+ // judge:一次性模型调用,要求返回严格 JSON 排名。
69
+ const judgeMessages = [
70
+ {
71
+ role: 'system',
72
+ content: effectiveSystemPrompt('You are an impartial judge ranking candidate solutions. Output ONLY minified JSON of the form ' +
73
+ '{"ranking":[{"label":"candidate-1","score":0-100,"reason":"..."}],"winner":"candidate-1"}. ' +
74
+ 'Rank best first. Every label must be one of the provided candidate labels.'),
75
+ },
76
+ {
77
+ role: 'user',
78
+ content: [
79
+ `Task:\n${prompt}`,
80
+ `Ranking criteria: ${criteria}`,
81
+ ...candidates.map((c) => `--- ${c.label} ---\n${c.text.slice(0, 6000)}`),
82
+ 'Rank the candidates and pick a winner. Respond with JSON only.',
83
+ ].join('\n\n'),
84
+ },
85
+ ];
86
+ let rankingText;
87
+ let judgeUsage;
88
+ try {
89
+ const res = await chat(judgeMessages, {}, ctx?.signal);
90
+ rankingText = res.content ?? '';
91
+ judgeUsage = res.usage;
92
+ }
93
+ catch (e) {
94
+ rankingText = '';
95
+ process.stderr.write(`[arena] judge call failed: ${e instanceof Error ? e.message : String(e)}\n`);
96
+ }
97
+ const parsed = parseJudgeJson(rankingText, candidates.map((c) => c.label));
98
+ const lines = [`Arena: ${n} candidates for: ${prompt.split('\n')[0].slice(0, 100)}`, ''];
99
+ if (parsed) {
100
+ for (const [i, item] of parsed.ranking.entries()) {
101
+ lines.push(`${i + 1}. ${item.label} score=${item.score} ${item.reason}`);
102
+ }
103
+ lines.push('', `WINNER: ${parsed.winner}`, '', `--- ${parsed.winner} full output ---`);
104
+ const winner = candidates.find((c) => c.label === parsed.winner);
105
+ lines.push(winner?.text ?? '(missing)');
106
+ }
107
+ else {
108
+ lines.push('(judge JSON unparseable; showing candidates in spawn order)');
109
+ for (const c of candidates)
110
+ lines.push('', `--- ${c.label} (tokens=${c.tokens}) ---`, c.text);
111
+ }
112
+ const output = lines.join('\n');
113
+ // 汇总全部 candidate + judge 的用量。
114
+ const sum = (k) => settled.reduce((acc, r) => acc + (r.status === 'fulfilled' ? (r.value.usage?.[k] ?? 0) : 0), judgeUsage?.[k] ?? 0);
115
+ const outcome = {
116
+ status: 'success',
117
+ code: 'OK',
118
+ retryable: false,
119
+ output,
120
+ usage: {
121
+ promptTokens: sum('promptTokens'),
122
+ completionTokens: sum('completionTokens'),
123
+ totalTokens: sum('totalTokens'),
124
+ cachedTokens: sum('cachedTokens'),
125
+ reasoningTokens: sum('reasoningTokens'),
126
+ },
127
+ };
128
+ return outcome;
129
+ },
130
+ };
131
+ function parseJudgeJson(text, validLabels) {
132
+ if (!text)
133
+ return null;
134
+ const match = text.match(/\{[\s\S]*\}/);
135
+ if (!match)
136
+ return null;
137
+ try {
138
+ const obj = JSON.parse(match[0]);
139
+ if (!Array.isArray(obj.ranking) || typeof obj.winner !== 'string')
140
+ return null;
141
+ const ranking = obj.ranking
142
+ .filter((r) => typeof r.label === 'string' && validLabels.includes(r.label))
143
+ .map((r) => ({
144
+ label: String(r.label),
145
+ score: typeof r.score === 'number' ? r.score : Number(r.score) || 0,
146
+ reason: typeof r.reason === 'string' ? r.reason.slice(0, 160) : '',
147
+ }));
148
+ if (ranking.length === 0)
149
+ return null;
150
+ const winner = validLabels.includes(obj.winner) ? obj.winner : (ranking[0]?.label ?? validLabels[0]);
151
+ return { ranking, winner: winner ?? validLabels[0] };
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
@@ -22,6 +22,8 @@ import { memoryUpdateTool } from './memory-update.js';
22
22
  import { memoryForgetTool } from './memory-forget.js';
23
23
  import { memoryGraphTool } from './memory-graph.js';
24
24
  import { subAgentTool } from './task.js';
25
+ import { arenaTool } from './arena.js';
26
+ import { messageBusTool } from './message-bus.js';
25
27
  import { computerTool } from './computer.js';
26
28
  import { sessionSearchTool } from './session-search.js';
27
29
  /**
@@ -113,6 +115,8 @@ const rawBuiltinTools = [
113
115
  memoryForgetTool,
114
116
  memoryGraphTool,
115
117
  subAgentTool,
118
+ arenaTool,
119
+ messageBusTool,
116
120
  computerTool,
117
121
  sessionSearchTool,
118
122
  ];
@@ -0,0 +1,98 @@
1
+ import { sendMessage, inbox, markRead, messageHistory, getIdentity } from '../../bots/bus.js';
2
+ // ---------- message_bus ----------
3
+ // D1:通过持久消息总线与其他具名 bot 异步通信。消息落盘,对方不必同时在线——
4
+ // worker bot 之后被 scheduler / bg 唤起时拉 inbox、处理、回结果(再 send 一条 to=原发送者)。
5
+ //
6
+ // action:
7
+ // send {to, body, inReplyTo?} 给某 bot 发消息
8
+ // inbox 拉取当前身份的未读消息(默认拉取后自动 ack)
9
+ // ack {ids:[...]} 手动标记已读
10
+ // history 与当前身份相关的全部消息
11
+ export const messageBusTool = {
12
+ name: 'message_bus',
13
+ description: [
14
+ 'Asynchronous persistent messaging between named bots (and the main user). Messages are stored; the recipient need not be running and reads its inbox later.',
15
+ 'Actions: send {to, body, inReplyTo?}, inbox (unread, auto-ack by default), ack {ids}, history. Use for durable hand-offs rather than synchronous sub-agent calls.',
16
+ ].join(''),
17
+ parameters: {
18
+ type: 'object',
19
+ properties: {
20
+ action: {
21
+ type: 'string',
22
+ enum: ['send', 'inbox', 'ack', 'history'],
23
+ description: 'Bus operation.',
24
+ },
25
+ to: { type: 'string', description: 'Recipient bot name (send).' },
26
+ body: { type: 'string', description: 'Message body (send).' },
27
+ inReplyTo: { type: 'string', description: 'Id of the message being replied to.' },
28
+ ids: {
29
+ type: 'array',
30
+ items: { type: 'string' },
31
+ description: 'Message ids to acknowledge (ack).',
32
+ },
33
+ autoAck: {
34
+ type: 'boolean',
35
+ description: 'With inbox: mark pulled messages read (default true).',
36
+ },
37
+ },
38
+ required: ['action'],
39
+ },
40
+ async execute(args) {
41
+ const action = String(args.action ?? '');
42
+ const identity = getIdentity();
43
+ const formatMsg = (m) => `[${m.id}] ${m.from} -> ${m.to} @ ${m.createdAt}\n${m.body}`;
44
+ let output;
45
+ try {
46
+ if (action === 'send') {
47
+ const to = String(args.to ?? '');
48
+ const body = String(args.body ?? '');
49
+ if (!to || !body)
50
+ return 'message_bus send requires to and body';
51
+ const msg = sendMessage({
52
+ to,
53
+ body,
54
+ ...(typeof args.inReplyTo === 'string' ? { inReplyTo: args.inReplyTo } : {}),
55
+ });
56
+ output = `sent ${msg.id} to ${msg.to}`;
57
+ }
58
+ else if (action === 'inbox') {
59
+ const msgs = inbox(identity);
60
+ if (msgs.length === 0) {
61
+ output = `(no unread messages for ${identity})`;
62
+ }
63
+ else {
64
+ output = [`inbox for ${identity}: ${msgs.length} unread`, '', ...msgs.map(formatMsg)].join('\n');
65
+ if (args.autoAck !== false) {
66
+ const marked = markRead(msgs.map((m) => m.id), identity);
67
+ output += `\n\n(acked ${marked.length})`;
68
+ }
69
+ }
70
+ }
71
+ else if (action === 'ack') {
72
+ const ids = Array.isArray(args.ids) ? args.ids.filter((x) => typeof x === 'string') : [];
73
+ const marked = markRead(ids, identity);
74
+ output = `acked ${marked.length} message(s)`;
75
+ }
76
+ else if (action === 'history') {
77
+ const msgs = messageHistory(identity);
78
+ output = msgs.length
79
+ ? [`history for ${identity}: ${msgs.length}`, '', ...msgs.map(formatMsg)].join('\n')
80
+ : `(no messages for ${identity})`;
81
+ }
82
+ else {
83
+ return `unknown message_bus action: ${action}`;
84
+ }
85
+ }
86
+ catch (e) {
87
+ const outcome = {
88
+ status: 'error',
89
+ code: 'EXECUTION_ERROR',
90
+ retryable: false,
91
+ output: e instanceof Error ? e.message : String(e),
92
+ };
93
+ return outcome;
94
+ }
95
+ const outcome = { status: 'success', code: 'OK', retryable: false, output };
96
+ return outcome;
97
+ },
98
+ };
@@ -73,6 +73,7 @@ export async function runCommandRaw(command, timeout = DEFAULT_COMMAND_TIMEOUT_M
73
73
  // cmd.exe 需要 verbatim:否则 Node 重新引号化参数,`node -e "..."` 会退化成
74
74
  // 字符串字面量并 exit 0,造成 Windows 上的假阳性验证。其它 shell 必须关。
75
75
  windowsVerbatimArguments: spec.windowsVerbatimArguments,
76
+ windowsHide: true,
76
77
  });
77
78
  const output = new BoundedCommandOutput();
78
79
  let finished = false;
@@ -80,7 +81,7 @@ export async function runCommandRaw(command, timeout = DEFAULT_COMMAND_TIMEOUT_M
80
81
  try {
81
82
  if (isWin) {
82
83
  if (child.pid != null) {
83
- spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
84
+ spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
84
85
  }
85
86
  }
86
87
  else {
package/dist/ui/batch.js CHANGED
@@ -72,6 +72,8 @@ export function reset() {
72
72
  expandedBatches.clear();
73
73
  callToBatch.clear();
74
74
  groupChildIndexByCall.clear();
75
+ // 复位外部暂停态(兜底:面板异常退出未走 cleanup 时,避免暂停泄漏到后续 turn)。
76
+ _sweepSuspended = false;
75
77
  // 扫光心跳随运行态批自停:batch 全清后 hasRunningBatch 必为 false。
76
78
  syncSweepTimer();
77
79
  }
@@ -234,6 +236,12 @@ let _sweepTimer = null;
234
236
  * 存 layout 而不自己 import 判断 TUI 是否激活,是沿用「agent 侧注入」的既有形态。
235
237
  */
236
238
  let _sweepLayout = null;
239
+ /**
240
+ * 扫光被外部暂停(介入面板 ask_human 期间):摘要行已在内容区底部,而菜单同样从
241
+ * contentBottom 向上展开,心跳若继续逐帧重画该行会写进菜单区、把「◇ 正在探索」
242
+ * 泄漏到选项之间(用户截图实证)。暂停只冻结心跳,不清状态;resumeSweep 后立即恢复。
243
+ */
244
+ let _sweepSuspended = false;
237
245
  /**
238
246
  * 批是否「在飞」(需要扫光与实时耗时)。判据是**已收口即落定**:
239
247
  * `endBatch` 必设 finishedAt,故先看它 —— 否则任何「endBatch 了但 running 标志残留」
@@ -257,7 +265,7 @@ function hasLiveBatch() {
257
265
  }
258
266
  /** 扫光可用性:TUI(非 TTY 输出纯文本,不能塞动画转义)+ 有在飞批 + 有可写回的 layout。 */
259
267
  function sweepEnabled() {
260
- return ui.isTTY && _sweepLayout != null && hasLiveBatch();
268
+ return !_sweepSuspended && ui.isTTY && _sweepLayout != null && hasLiveBatch();
261
269
  }
262
270
  /**
263
271
  * 同步扫光心跳开关(建批/收批时调)。心跳随最后一个运行态批结束自停,不常驻空转。
@@ -302,6 +310,28 @@ export function syncSweepTimer() {
302
310
  return timer;
303
311
  })();
304
312
  }
313
+ /**
314
+ * 暂停扫光心跳(介入面板进入时调):syncSweepTimer 经 sweepEnabled 判定立即清 timer;
315
+ * 暂停期间 showLiveBatch/endBatch 再调 syncSweepTimer 也不会重启。幂等:重复挂起不抖状态。
316
+ */
317
+ export function suspendSweep() {
318
+ if (_sweepSuspended)
319
+ return;
320
+ _sweepSuspended = true;
321
+ syncSweepTimer();
322
+ }
323
+ /** 恢复扫光(介入面板退出时调):若仍有在飞批则心跳立即重建;无在飞批则保持停止。幂等。 */
324
+ export function resumeSweep() {
325
+ if (!_sweepSuspended)
326
+ return;
327
+ _sweepSuspended = false;
328
+ syncSweepTimer();
329
+ }
330
+ /** 测试钩子:扫光当前是否处于外部暂停态(介入面板泄漏回归用)。 */
331
+ export const __sweepTest = {
332
+ isSuspended: () => _sweepSuspended,
333
+ timerActive: () => _sweepTimer !== null,
334
+ };
305
335
  /**
306
336
  * 计算本帧高亮带覆盖的字符区间 `[start, end)`(纯函数,导出供帧推进/带宽测试)。
307
337
  *
@@ -344,22 +344,6 @@ export async function promptComposer(opts = {}) {
344
344
  }
345
345
  return rows.length - 1;
346
346
  }
347
- /** 上下移动一行(按展示行)。 */
348
- function moveVertically(delta, extend) {
349
- const rows = dispRows();
350
- const idx = cursorDispIdx(rows);
351
- const target = Math.max(0, Math.min(rows.length - 1, idx + delta));
352
- const r = rows[target];
353
- if (!r)
354
- return;
355
- // 保持视觉列:当前光标视觉列 → 目标展示行同列
356
- const curRow = rows[idx];
357
- const curText = curRow ? curRow.text : '';
358
- const curOff = cur.col - (curRow ? curRow.start : 0);
359
- const vis = displayWidth([...curText].slice(0, Math.max(0, curOff)).join(''));
360
- const charCol = visColToCharCol(r.text, vis);
361
- moveTo({ line: r.li, col: r.start + charCol }, extend);
362
- }
363
347
  function moveByPage(delta, extend) {
364
348
  const rows = dispRows();
365
349
  const visible = Math.max(1, textRowCount());
@@ -716,10 +700,12 @@ export async function promptComposer(opts = {}) {
716
700
  }
717
701
  return;
718
702
  case 'up':
719
- moveVertically(-1, extend);
703
+ // ↑ 始终跳到全文最前(Shift 扩展选区)
704
+ moveTo({ line: 0, col: 0 }, extend);
720
705
  return;
721
706
  case 'down':
722
- moveVertically(1, extend);
707
+ // ↓ 始终跳到全文最后(Shift 扩展选区)
708
+ moveTo({ line: lines.length - 1, col: lineLen(lines.length - 1) }, extend);
723
709
  return;
724
710
  case 'home':
725
711
  if (key.ctrl) {
@@ -5,6 +5,7 @@ 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 末尾的本地化“自定义输入”项标签。 */
@@ -221,6 +222,8 @@ async function promptInterventionInner(req) {
221
222
  layout.resetScroll();
222
223
  else
223
224
  layout.repaintViewport();
225
+ // 恢复扫光心跳(若仍有在飞批):必须在 repaintViewport 之后——先恢复内容区,再让心跳重画摘要行。
226
+ resumeSweep();
224
227
  // 恢复走时计时器(RUNNING 态):面板期间 stopTurnTimer 停了心跳,退出后恢复状态行 80ms 刷新。
225
228
  layout.startTurnTimerIfRunning();
226
229
  }
@@ -354,6 +357,9 @@ async function promptInterventionInner(req) {
354
357
  // 把真光标拉到 runningCaretPos 覆盖 paintInput 的正确光标位)+ 禁鼠标框选(防拖拽 viewport 重画覆盖菜单)+ 回尾(若用户正滚动回看)
355
358
  Spinner.pauseCurrent();
356
359
  layout.stopTurnTimer();
360
+ // 暂停 batch 摘要行扫光:摘要行在内容区底,菜单向上展开会与之重叠,心跳继续重画会
361
+ // 把「◇ 正在探索」帧泄漏到选项之间(cleanup 退出时 resumeSweep 恢复)。
362
+ suspendSweep();
357
363
  layout.setMouseEnabled(false);
358
364
  layout.resetScroll();
359
365
  // 快照现有 keypress 监听(运行态的 onRunningKey)并摘掉,挂自己的 onKey
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.6.5",
3
+ "version": "1.6.6",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 25 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {