mocode-ai 1.7.6 → 1.7.8

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.
@@ -22,6 +22,13 @@ const ATTACHMENT_TAIL = '. Analyze the attached image content directly.';
22
22
  const UNKNOWN_FRAME_NAME = 'frame';
23
23
  /** 恢复提示只写进**第一条**被剪占位,避免 N 条占位重复同一句话侵蚀 token。 */
24
24
  const RECOVERY_HINT = ' Call computer{screenshot} if you need the current screen.';
25
+ /**
26
+ * 帧被剪枝后占位行的固定前缀(见 pruneFrameMessage,完整行为 `[screenshot omitted: <帧名> · step N]`)。
27
+ * 剪枝会把合成附件消息整个 content 换成占位(前言 ATTACHMENT_PREAMBLE 随之消失),
28
+ * rollback 对齐真实用户轮时必须认出这个占位、不把它算成一轮——那里因循环依赖无法
29
+ * import 本模块,故在 rollback/store.ts 内联了同一行格式(按整行正则判定),修改此串时两处必须同步。
30
+ */
31
+ const PRUNED_FRAME_PREFIX = '[screenshot omitted: ';
25
32
  /**
26
33
  * 白名单:参与滑动窗口的屏幕帧名前缀。
27
34
  *
@@ -139,11 +146,11 @@ function pruneFrameMessage(message, names, step, withHint) {
139
146
  for (const part of parts) {
140
147
  if (part.type !== 'image_url')
141
148
  continue;
142
- lines.push(`[screenshot omitted: ${names[index] ?? UNKNOWN_FRAME_NAME} · step ${step}]`);
149
+ lines.push(`${PRUNED_FRAME_PREFIX}${names[index] ?? UNKNOWN_FRAME_NAME} · step ${step}]`);
143
150
  index++;
144
151
  }
145
152
  if (lines.length === 0)
146
- lines.push(`[screenshot omitted: ${names[0] ?? UNKNOWN_FRAME_NAME} · step ${step}]`);
153
+ lines.push(`${PRUNED_FRAME_PREFIX}${names[0] ?? UNKNOWN_FRAME_NAME} · step ${step}]`);
147
154
  if (withHint)
148
155
  lines[0] = `${lines[0]}${RECOVERY_HINT}`;
149
156
  const content = [{ type: 'text', text: lines.join('\n') }];
@@ -161,6 +161,7 @@ const zhCN = {
161
161
  'intervention.emptyInput': '自动返回空输入',
162
162
  'intervention.nonInteractive': '[介入] {title}(非交互环境,{kind})',
163
163
  'repl.idle': '空闲',
164
+ 'repl.queuedCommandHeld': '排队的是斜杠命令,已填入输入框,请确认后手动发送',
164
165
  'repl.loadFailed': '(加载失败)',
165
166
  'repl.resumeAnchor': '已恢复会话 {id} · {count} 条消息 · 当前模型 {model}(/model switch 可切换)',
166
167
  'repl.noSessions': '(没有已保存的会话)',
@@ -208,6 +209,8 @@ const zhCN = {
208
209
  'banner.disabled': '关闭',
209
210
  'status.modeSwitch': 'Shift+Tab 切换',
210
211
  'status.history': '历史 ↑{count}(PgDn 回底)',
212
+ 'status.queued': '排队 ·',
213
+ 'status.queuePlanTag': '(Plan)',
211
214
  'status.measured': '实测',
212
215
  'status.estimated': '估算',
213
216
  'status.messages': '{count} 条消息',
@@ -548,6 +551,7 @@ const en = {
548
551
  'intervention.emptyInput': 'automatically returned empty input',
549
552
  'intervention.nonInteractive': '[intervention] {title} (non-interactive; {kind})',
550
553
  'repl.idle': 'Idle',
554
+ 'repl.queuedCommandHeld': 'Queued text is a slash command; filled into the input for you to send manually',
551
555
  'repl.loadFailed': '(load failed)',
552
556
  'repl.resumeAnchor': 'Resumed session {id} · {count} messages · model {model} (/model switch to change)',
553
557
  'repl.noSessions': '(no saved sessions)',
@@ -595,6 +599,8 @@ const en = {
595
599
  'banner.disabled': 'Disabled',
596
600
  'status.modeSwitch': 'Shift+Tab Switch',
597
601
  'status.history': 'History ↑{count} (PgDn to bottom)',
602
+ 'status.queued': 'queued ·',
603
+ 'status.queuePlanTag': ' (Plan)',
598
604
  'status.measured': 'measured',
599
605
  'status.estimated': 'estimated',
600
606
  'status.messages': '{count} messages',
@@ -4,6 +4,7 @@ import * as layout from '../ui/layout.js';
4
4
  import * as mouse from '../ui/mouse.js';
5
5
  import { promptComposer } from '../ui/composer.js';
6
6
  import { appendCurrentSessionRuntimeEvent } from '../session/index.js';
7
+ import { getAgentMode } from '../agent/mode.js';
7
8
  const emitter = stdin;
8
9
  // ── 运行态交互(typeahead 输入 + 滚动回看 + Ctrl+C 中断 + Ctrl+G 输入面板)──
9
10
  // 只在 await runAgent() 期间挂载;/resume /rollback /compact 等走 askLine(cooked readline)的分支不挂(避免抢 stdin)。
@@ -12,6 +13,12 @@ let runningCursor = 0; // 缓冲内光标字符索引(0..len);运行态支持任
12
13
  let runningPlaceholder = '';
13
14
  let currentAbort = null;
14
15
  let listening = false; // onRunningKey 是否处于挂载期(start → stop 之间)
16
+ /**
17
+ * 运行态「预加载/排队」消息:运行中打字后按 Enter 入队(单条,再次入队即替换),
18
+ * 当前 agent 结束后由主循环自动发送,无需用户在 INPUT 态再按一次 Enter。
19
+ * null = 没有排队。mode 快照入队那一刻的模式,自动执行时沿用(用户选「沿用当前模式」)。
20
+ */
21
+ let queuedTurn = null;
15
22
  /** 运行态打开的输入面板(Ctrl+G);非 null = 面板仍开着。主循环进 INPUT 态前须等它关闭,否则两个 keypress 监听抢键。 */
16
23
  let composerPending = null;
17
24
  /** 获取运行中输入缓冲(agent 结束后预填下一轮 INPUT 态)。 */
@@ -23,6 +30,20 @@ export function clearRunningInput() {
23
30
  runningInput = '';
24
31
  runningCursor = 0;
25
32
  }
33
+ /** 当前排队消息(运行中按 Enter 预加载);无则 null。主循环在 agent 结束后取走并自动发送。 */
34
+ export function getQueuedTurn() {
35
+ return queuedTurn;
36
+ }
37
+ /** 取走并清空排队消息(消费一次)。 */
38
+ export function consumeQueuedTurn() {
39
+ const queued = queuedTurn;
40
+ queuedTurn = null;
41
+ return queued;
42
+ }
43
+ /** 取消排队(运行态 Esc 在已有排队时优先取消排队,而不是只清 typeahead)。 */
44
+ export function clearQueuedTurn() {
45
+ queuedTurn = null;
46
+ }
26
47
  /**
27
48
  * 等待运行态打开的输入面板关闭(无则立即返回)。
28
49
  * agent 可能在面板打开期间结束:主循环在进入 INPUT 态(promptWithSlashMenu 挂自己的 keypress)前必须 await,
@@ -99,6 +120,11 @@ function onRunningKey(_str, key) {
99
120
  paint();
100
121
  }
101
122
  else if (currentAbort && !currentAbort.signal.aborted) {
123
+ // 中断当前任务 = 明确要停下,排队的下一跳也一并取消,避免 agent 一停又自动发新需求。
124
+ if (queuedTurn) {
125
+ queuedTurn = null;
126
+ layout.setQueuedTurn(undefined); // 只撤状态行 chip,不在内容区写提示
127
+ }
102
128
  appendCurrentSessionRuntimeEvent('abort', { phase: 'requested', source: 'keyboard' });
103
129
  currentAbort.abort();
104
130
  }
@@ -153,13 +179,32 @@ function onRunningKey(_str, key) {
153
179
  return;
154
180
  }
155
181
  if (key.name === 'escape') {
156
- runningInput = '';
157
- runningCursor = 0;
158
- paint();
182
+ // 有排队消息时 Esc 只取消排队,不清空 typeahead 草稿(避免误删正在打的字);
183
+ // 无排队时维持原语义:清空 typeahead。
184
+ if (queuedTurn) {
185
+ queuedTurn = null;
186
+ layout.setQueuedTurn(undefined); // 只撤状态行 chip,不在内容区写提示
187
+ paint();
188
+ }
189
+ else {
190
+ runningInput = '';
191
+ runningCursor = 0;
192
+ paint();
193
+ }
159
194
  return;
160
195
  }
161
- // Enter / Ctrl+J:运行中 no-op(单行 typeahead;agent 结束后预填,用户在 INPUT 态按 Enter 提交)
196
+ // Enter / Ctrl+J:把 typeahead 内容入队(单条,再次入队即替换)。当前 agent 结束后
197
+ // 主循环经 consumeQueuedTurn 自动发送,无需在 INPUT 态再按一次 Enter。空内容 no-op。
162
198
  if (key.name === 'return' || key.name === 'enter' || (key.ctrl && key.name === 'j')) {
199
+ const text = runningInput.trim();
200
+ if (!text)
201
+ return;
202
+ queuedTurn = { text: runningInput, mode: getAgentMode() }; // 单条:再次入队即替换
203
+ runningInput = '';
204
+ runningCursor = 0;
205
+ // 入队只反映在状态行 `● 状态` 右侧的常驻 chip(含预览/模式),不往 agent 输出区写行。
206
+ layout.setQueuedTurn(queuedTurn.text, queuedTurn.mode);
207
+ paint(); // 清空底部 typeahead 回显
163
208
  return;
164
209
  }
165
210
  // 可打印字符(>= 空格,非 ctrl/meta)→ 光标处插入 + 非 dim 回显(与空闲态一致)
@@ -32,7 +32,7 @@ import { PROMPT, buildSlashCommands, LLM_ERROR_HINT_KEYS, isCommandShape, sugges
32
32
  import { dispatchCommand } from './commands/registry.js';
33
33
  import { settlePlanStatus, refreshStatusBase, runningStateFor } from './status-bar.js';
34
34
  import { messageAttachments, formatUserMessage, textOf, queryHistoryFromMessages, renderHistory, } from './message-format.js';
35
- import { startRunningListener, stopRunningListener, getRunningInput, clearRunningInput, waitForRunningComposer, } from './running-input.js';
35
+ import { startRunningListener, stopRunningListener, getRunningInput, clearRunningInput, waitForRunningComposer, consumeQueuedTurn, } from './running-input.js';
36
36
  const emitter = stdin;
37
37
  // ── 运行态交互已移到 ./running-input.ts ──
38
38
  // ── pendingPrefill / pendingTimer / pendingAttachments 保留在此(awaitPendingRecall / echoInput 用)
@@ -352,12 +352,6 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
352
352
  layout.contentWrite(`${ui.dim}(没有可回滚的轮次)${ui.reset}\n`);
353
353
  return;
354
354
  }
355
- // 各轮 user 全文(预填用;按 user 消息顺序与 turnList 对齐)
356
- const userTexts = [];
357
- for (const m of history) {
358
- if (m.role === 'user')
359
- userTexts.push(textOf(m.content));
360
- }
361
355
  const items = turnList.map((t) => ({ firstLine: t.firstLine }));
362
356
  let picked;
363
357
  try {
@@ -368,8 +362,11 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
368
362
  }
369
363
  if (picked === null)
370
364
  return; // Esc 取消
371
- // picked(0-based)= 第 (picked+1) 轮:删该轮及之后(planRollback(picked) 保 1..picked),预填该轮 user 输入
372
- const prefillText = userTexts[picked] ?? '';
365
+ // picked(0-based)= 第 (picked+1) 轮:删该轮及之后(planRollback(picked) 保前 picked 轮),
366
+ // 预填该轮 user 输入。必须经 store 按"尾部对齐的真实用户轮消息"定位——直接按 history 里
367
+ // role==='user' 数下标会把工具附件的合成 user 消息/被剪枝老轮算进去,预填错轮。
368
+ const pickedMessage = runtime.rollback.turnUserMessage(picked, history);
369
+ const prefillText = pickedMessage ? textOf(pickedMessage.content) : '';
373
370
  const plan = runtime.rollback.plan(picked, history);
374
371
  // 清屏(擦选轮菜单 + /rollback 回显)+ 复位 lastView(dim 空),给文件询问一个干净、resize 安全的画面
375
372
  layout.clearContent();
@@ -679,7 +676,19 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
679
676
  // 运行态 Ctrl+G 打开的输入面板可能在 agent 结束时仍开着:等它关闭再进 INPUT 态,
680
677
  // 否则面板与 promptWithSlashMenu 两个 keypress 监听抢键;面板确认的内容随后经 getRunningInput 预填。
681
678
  await waitForRunningComposer();
682
- // INPUT 态:画底栏输入框 + 状态行,光标入输入框
679
+ // 消息预加载:上一轮运行中用户按 Enter 排了队(consumeQueuedTurn),agent 一结束就自动发送,
680
+ // 跳过 INPUT 态输入框与撤回窗口(入队那一刻即已确认)。非空才自动发;排队时正在打的 typeahead
681
+ // 草稿经 pendingPrefill 保住,留到下一轮输入框。
682
+ const queued = consumeQueuedTurn();
683
+ const autoSend = !!queued && queued.text.trim().length > 0;
684
+ if (queued)
685
+ layout.setQueuedTurn(undefined); // chip 已被消费(或为空丢弃),从状态行撤下
686
+ if (autoSend && queued) {
687
+ // 排队时仍在输入框草稿里的字(typeahead)不能丢:回填为下一轮预填。
688
+ if (getRunningInput().trim())
689
+ pendingPrefill = [getRunningInput()];
690
+ clearRunningInput();
691
+ }
683
692
  refreshStatusBase(history);
684
693
  // 后台反思若已完成(上轮 fire-and-forget),在安全点 flush 一行 dim 摘要:
685
694
  // 不在 RUNNING 态写——防与正在跑的 agent 争屏(contentWrite / 状态行)。
@@ -688,39 +697,54 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
688
697
  layout.contentWrite(` ${ui.gray}↳ ${formatReflectResult(reflectRes)}${ui.reset}\n`);
689
698
  clearLastReflectResult();
690
699
  }
691
- // 所有斜杠命令和 Agent 轮次共用同一个输出→输入边界,避免某条命令漏写第二个 \n
692
- // 后下一条 ❯ 气泡紧贴确认文案;已有多余空行也会收敛为恰好一行。
693
- if (hasSubmittedInput)
694
- layout.normalizeInputBoundary();
695
- layout.enterInputMode(t('repl.idle'));
696
- let input = null;
697
- try {
698
- input = await promptWithSlashMenu({
699
- prompt: PROMPT,
700
- commands: buildSlashCommands(),
701
- onCycleMode: cycleMode,
702
- // Ctrl+R / Ctrl+P 历史搜索候选源:惰性工厂——当前会话内存 queryHistory + 最近落盘会话,
703
- // 聚合要读盘,只有面板真正打开(Ctrl+R/P)才求值,平常轮次零开销
704
- history: () => collectQueryHistory(queryHistory).map((e) => e.text),
705
- // /rollback 预填优先;否则上一轮运行中 typeahead 打的字 → 预填进输入框,用户可改可发
706
- ...(pendingPrefill
707
- ? { initialLines: pendingPrefill }
708
- : getRunningInput()
709
- ? { initialLines: [getRunningInput()] }
710
- : {}),
711
- });
700
+ let input;
701
+ let queuedMode = null;
702
+ if (autoSend && queued) {
703
+ input = queued.text.split('\n');
704
+ queuedMode = queued.mode;
712
705
  }
713
- catch {
714
- break; // Ctrl+C(SIGINT)/ 异常 → 退出
706
+ else {
707
+ // 所有斜杠命令和 Agent 轮次共用同一个输出→输入边界,避免某条命令漏写第二个 \n
708
+ // 后下一条 ❯ 气泡紧贴确认文案;已有多余空行也会收敛为恰好一行。
709
+ if (hasSubmittedInput)
710
+ layout.normalizeInputBoundary();
711
+ layout.enterInputMode(t('repl.idle'));
712
+ try {
713
+ const picked = await promptWithSlashMenu({
714
+ prompt: PROMPT,
715
+ commands: buildSlashCommands(),
716
+ onCycleMode: cycleMode,
717
+ // Ctrl+R / Ctrl+P 历史搜索候选源:惰性工厂——当前会话内存 queryHistory + 最近落盘会话,
718
+ // 聚合要读盘,只有面板真正打开(Ctrl+R/P)才求值,平常轮次零开销
719
+ history: () => collectQueryHistory(queryHistory).map((e) => e.text),
720
+ // /rollback 预填优先;否则上一轮运行中 typeahead 打的字 → 预填进输入框,用户可改可发
721
+ ...(pendingPrefill
722
+ ? { initialLines: pendingPrefill }
723
+ : getRunningInput()
724
+ ? { initialLines: [getRunningInput()] }
725
+ : {}),
726
+ });
727
+ if (picked === null)
728
+ break; // 空 prompt Ctrl+D
729
+ input = picked;
730
+ }
731
+ catch {
732
+ break; // Ctrl+C(SIGINT)/ 异常 → 退出
733
+ }
734
+ pendingPrefill = null; // 预填已消费,清空
735
+ clearRunningInput(); // 预填已消费,清空(下轮运行态从空开始)
715
736
  }
716
- pendingPrefill = null; // 预填已消费,清空
717
- clearRunningInput(); // 预填已消费,清空(下轮运行态从空开始)
718
- if (input === null)
719
- break; // 空 prompt Ctrl+D
720
737
  let joined = input.join('\n');
721
738
  const line = joined.trim();
722
739
  if (!line)
723
740
  continue;
741
+ // 排队预加载只对「发给 agent 的需求」自动执行;斜杠命令不静默自动跑
742
+ // (否则排队 /clear、/exit、/rollback 会在用户没看着时触发),改为填回输入框由用户显式确认。
743
+ if (autoSend && isCommandShape(line.split(/\s+/)[0] ?? '')) {
744
+ pendingPrefill = input;
745
+ layout.contentWrite(`${ui.dim} ↳ ${t('repl.queuedCommandHeld')}${ui.reset}\n`);
746
+ continue;
747
+ }
724
748
  hasSubmittedInput = true;
725
749
  if (line === '/exit' || line === '/quit')
726
750
  break;
@@ -833,7 +857,8 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
833
857
  continue; // 与其他命令一致:回 INPUT 态由循环顶部的 normalizeInputBoundary + enterInputMode 负责
834
858
  }
835
859
  const bubbleRows = input.length + 2 + pendingAttachments.length; // N 行 message + 2 行尾随空(含 \n\n 留下的 open current 行) + 每附件 1 行
836
- const shouldCommit = await awaitPendingRecall(input);
860
+ // 排队消息入队时即已确认 → 跳过 500ms 撤回窗口,直接发送。
861
+ const shouldCommit = autoSend || (await awaitPendingRecall(input));
837
862
  if (!shouldCommit) {
838
863
  // 撤回:气泡从内容区擦掉 + 行放回输入框(下轮 promptWithSlashMenu 经 initialLines 消费)+ 切回 INPUT 视觉。
839
864
  // pendingAttachments **保留** —— 撤回的是输入文本不是意图,再发时随消息一起带走
@@ -845,7 +870,8 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
845
870
  }
846
871
  // 只记录已过撤回窗口的真实用户 query;slash 命令和合成执行轮不会走到这里。
847
872
  queryHistory.push(joined);
848
- const initialPlan = getAgentMode() === 'plan'; // 轮首模式(在 runTurn 之前读)
873
+ // 排队消息沿用「入队那一刻」的模式(用户在运行中预加载时选的模式);普通消息读当前模式。
874
+ const initialPlan = (queuedMode ?? getAgentMode()) === 'plan';
849
875
  const turn = await runTurn(joined, initialPlan, placeholder);
850
876
  if (turn.routedCancel) {
851
877
  // 路由阶段 Ctrl+C 撤回:与 500ms 撤回窗口同语义——气泡擦掉 + 行回填输入框
@@ -47,6 +47,61 @@ const EXCLUDED_WORKSPACE_DIRS = Object.freeze([
47
47
  '.ruff_cache',
48
48
  '.gradle',
49
49
  ]);
50
+ /**
51
+ * 工具经 modelAttachments 注入的合成 user 消息前言(见 agent/tool-turn.ts 的
52
+ * ATTACHMENT_PREAMBLE)。这类消息 role 也是 'user',但不对应 beginTurn 的真实用户轮,
53
+ * 回滚按 role==='user' 数轮次会与 turns 数组错位,导致"回退一条却连带删掉好几轮"。
54
+ * 两处常量必须保持一致;不 import tool-turn 是为避免 rollback→agent 的层级反向依赖。
55
+ */
56
+ const TOOL_ATTACHMENT_PREAMBLE = 'The read_file tool loaded the following visual input: ';
57
+ /**
58
+ * 视觉滑动窗口剪枝占位的机器固定格式(必须与 context/vision-window.ts 的
59
+ * pruneFrameMessage 输出逐字一致):一行或多行 `[screenshot omitted: <帧名> · step <数字>]`,
60
+ * 首行可能再跟固定恢复提示后缀。用整串正则而不是前缀匹配,避免把恰好以
61
+ * "[screenshot omitted: " 开头的真实用户输入误判成占位。
62
+ *
63
+ * 这里内联而不是 import vision-window:vision-window → llm → … → rollback/index 会形成
64
+ * 循环依赖,在共享进程的测试里触发 "Cannot access 'defaultRollbackStore' before initialization"。
65
+ */
66
+ const PRUNED_FRAME_LINE_RE = /^\[screenshot omitted: .+? · step \d+\]$/;
67
+ const PRUNED_FRAME_HINT_SUFFIX = ' Call computer{screenshot} if you need the current screen.';
68
+ function isPrunedFrameText(text) {
69
+ if (typeof text !== 'string')
70
+ return false;
71
+ const lines = text.split('\n');
72
+ return lines.every((line, i) => {
73
+ const core = i === 0 && line.endsWith(PRUNED_FRAME_HINT_SUFFIX) ? line.slice(0, -PRUNED_FRAME_HINT_SUFFIX.length) : line;
74
+ return PRUNED_FRAME_LINE_RE.test(core);
75
+ });
76
+ }
77
+ /**
78
+ * 判断一条 user 消息是否对应一个真实用户轮(= beginTurn 记进 turns 的那一条)。
79
+ *
80
+ * 排除两种合成 user 消息(都是 role:'user' 但不计轮次):
81
+ * 1. 工具批次提交时由 modelAttachments 注入的附件消息:ContentPart[] 的 text part
82
+ * 以工具附件前言开头;
83
+ * 2. 上述附件消息经视觉滑动窗口剪枝后的占位:整个 content 被替换成
84
+ * "[screenshot omitted: …]" 文本,前言已不存在(见 vision-window.pruneFrameMessage),
85
+ * 形态可能是数组 text part,也可能是纯字符串。
86
+ *
87
+ * 用户自己发的多模态消息没有这两个标记,不会被误判。
88
+ */
89
+ function isRealTurnUserMessage(message) {
90
+ if (message.role !== 'user')
91
+ return false;
92
+ const content = message.content;
93
+ if (typeof content === 'string')
94
+ return !isPrunedFrameText(content);
95
+ if (!Array.isArray(content))
96
+ return false;
97
+ for (const part of content) {
98
+ if (part && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string') {
99
+ if (part.text.startsWith(TOOL_ATTACHMENT_PREAMBLE) || isPrunedFrameText(part.text))
100
+ return false;
101
+ }
102
+ }
103
+ return true;
104
+ }
50
105
  const CAPTURE_FILE_LIMIT = 1024 * 1024;
51
106
  const CAPTURE_TOTAL_LIMIT = 32 * 1024 * 1024;
52
107
  const CAPTURE_ENTRY_LIMIT = 20000;
@@ -440,6 +495,32 @@ export class RollbackStore {
440
495
  listTurns() {
441
496
  return this.turns.slice();
442
497
  }
498
+ /**
499
+ * 取菜单第 pickedIndex(0-based)轮对应的真实用户消息(用于 /rollback 后预填)。
500
+ * 与 findCutoffIndex 同一套尾对齐规则。无对应消息(续接历史缺轮)时返回 undefined。
501
+ */
502
+ turnUserMessage(pickedIndex, history) {
503
+ const index = this.findTurnUserIndex(pickedIndex, history);
504
+ return index < 0 ? undefined : history[index];
505
+ }
506
+ /**
507
+ * 菜单第 pickedIndex(0-based)轮对应的真实用户消息在 history 中的下标。
508
+ * turns 是真实轮次的尾部连续子集(滚动剪枝/压缩只丢旧轮),故菜单第 k 轮 =
509
+ * history 中倒数第 (turns.length-k) 条真实用户轮消息。找不到返回 -1。
510
+ */
511
+ findTurnUserIndex(pickedIndex, history) {
512
+ let wantReverseRank = this.turns.length - pickedIndex;
513
+ if (wantReverseRank <= 0)
514
+ return -1;
515
+ for (let i = history.length - 1; i >= 0; i--) {
516
+ if (!isRealTurnUserMessage(history[i]))
517
+ continue;
518
+ wantReverseRank -= 1;
519
+ if (wantReverseRank === 0)
520
+ return i;
521
+ }
522
+ return -1;
523
+ }
443
524
  /**
444
525
  * 只读侧门(供 /diff 渲染):返回 turnId > sinceTurnId 的全部快照,浅拷贝数组。
445
526
  * 快照内容含 before 原文,调用方不得 mutate 返回值;用于展示,不影响回滚语义。
@@ -447,19 +528,21 @@ export class RollbackStore {
447
528
  snapshotsSince(sinceTurnId) {
448
529
  return this.snapshots.filter((snapshot) => snapshot.turnId > sinceTurnId);
449
530
  }
531
+ /**
532
+ * 回滚切点:保留菜单前 n 轮,从第 n+1 轮(菜单下标 n,0-based)的真实 user 消息处切断。
533
+ *
534
+ * turns(菜单)= 当前仍可回滚的轮次,是真实轮次的**尾部连续子集**(滚动剪枝/压缩只丢旧轮),
535
+ * 故从尾部倒数定位,而不是正数第 n 条——压缩的「user 逐字通道」会把更老的 user 原文
536
+ * 捞回保留区,正数下标与 turns 不再连续对齐。找不到(无第 n+1 轮)时返回 history.length。
537
+ */
450
538
  findCutoffIndex(n, history) {
451
- let seen = 0;
452
- for (let i = 0; i < history.length; i++) {
453
- if (history[i].role === 'user') {
454
- seen += 1;
455
- if (seen === n + 1)
456
- return i;
457
- }
458
- }
459
- return history.length;
539
+ const index = this.findTurnUserIndex(n, history);
540
+ return index < 0 ? history.length : index;
460
541
  }
461
542
  planRollback(n, history) {
462
543
  const cutoffTurnId = this.turns[n - 1]?.turnId ?? 0;
544
+ // turns 与 history 真实 user 消息尾对齐后再取切点:滚动剪枝(>50 轮)/压缩/续接后
545
+ // turns 只是尾部子集,不能再把 n 当成全局第 n 条 user 消息,否则会多删 offset 轮。
463
546
  const cutoffIndex = this.findCutoffIndex(n, history);
464
547
  const order = [];
465
548
  const map = new Map();
@@ -587,7 +670,9 @@ export class RollbackStore {
587
670
  return { deletedMsgs, revertedFiles, conflictedFiles };
588
671
  }
589
672
  pruneAfterCompaction(history) {
590
- const count = history.filter((message) => message.role === 'user').length;
673
+ // 只数真实用户轮(排除工具附件合成 user 消息);压缩会整体丢弃旧轮,
674
+ // 保留下来的总是最近的轮次,故 turns 同样从尾部对齐截取。
675
+ const count = history.filter((message) => isRealTurnUserMessage(message)).length;
591
676
  this.turns = count >= this.turns.length ? this.turns : this.turns.slice(-count);
592
677
  const alive = new Set(this.turns.map((turn) => turn.turnId));
593
678
  this.snapshots = this.snapshots.filter((snapshot) => alive.has(snapshot.turnId));
@@ -603,7 +688,9 @@ export class RollbackStore {
603
688
  rebuildFromHistory(history) {
604
689
  const rebuilt = [];
605
690
  for (const message of history) {
606
- if (message.role !== 'user')
691
+ // 跳过工具附件注入的合成 user 消息,否则 turns 比真实轮次多,
692
+ // /rollback 菜单与 history 切点(planRollback 按 turns[n-1])整体错位。
693
+ if (!isRealTurnUserMessage(message))
607
694
  continue;
608
695
  const first = toText(message.content).split('\n')[0] ?? '';
609
696
  rebuilt.push({ turnId: rebuilt.length + 1, firstLine: truncateDisplay(first, 40) });
@@ -90,6 +90,10 @@ export class Runtime {
90
90
  this.assertOpen();
91
91
  return this.context.rollbackStore.listTurns();
92
92
  },
93
+ turnUserMessage: (pickedIndex, history) => {
94
+ this.assertOpen();
95
+ return this.context.rollbackStore.turnUserMessage(pickedIndex, history);
96
+ },
93
97
  plan: (n, history) => {
94
98
  this.assertIdle('plan rollback');
95
99
  const plan = this.context.rollbackStore.planRollback(n, history);
@@ -30,7 +30,7 @@ import { paintInput, repaint } from './input-paint.js';
30
30
  import { WHEEL_LINES, screenRowToAbsLine, repaintViewport, scrollBy, scrollWheel, resetScroll, lockScrollToBottom, } from './scroll.js';
31
31
  import { contentWrite, contentInsertAfter, contentDeleteFrom, contentReplaceLine, reflowContentForResize, flushMdSegment, } from './content-write.js';
32
32
  export { isTuiActive, getGeo, setRegion, contentMode, isStreamingPaused } from './screen.js';
33
- export { drawStatusBar, setStatus, stopTurnTimer, startTurnTimerIfRunning, paintLiveAtCursor, clearLiveAtCursor, setLiveUsage, setStatusBase, } from './statusbar.js';
33
+ export { drawStatusBar, setStatus, stopTurnTimer, startTurnTimerIfRunning, paintLiveAtCursor, clearLiveAtCursor, setLiveUsage, setQueuedTurn, setStatusBase, } from './statusbar.js';
34
34
  export { paintInput, paintRunningInput, repaint } from './input-paint.js';
35
35
  export { isScrolled, repaintViewport, repaintContentLine, scrollBy, scrollWheel, resetScroll, lockScrollToBottom, unlockScroll, isScrollLocked, } from './scroll.js';
36
36
  export { contentWrite, writeBanner, rewriteBanner, bannerHeight, contentWriteMd, contentWriteMdOnce, clearContent, rewindContent, writeWelcomeBlock, dismissWelcomeBlock, contentInsertAfter, contentDeleteFrom, totalRows, isLastContentRowBlank, normalizeMutationBoundary, normalizeInputBoundary, contentReplaceLine, notifyContentReset, } from './content-write.js';
@@ -61,6 +61,10 @@ export const state = {
61
61
  * composeModelLine 在 RUNNING 态把它画成 chip 放 context 进度条左侧;
62
62
  * 不主动触发重画——RUNNING 态 turnTimer 80ms 心跳重画自然取最新值。 */
63
63
  liveUsage: undefined,
64
+ /** 运行态已排队、当前任务结束后自动发送的下一条消息(纯文本摘要,无=undefined)。 */
65
+ queuedText: undefined,
66
+ /** 排队消息入队时的模式,用于在 chip 上标注 Plan/Auto。 */
67
+ queuedMode: undefined,
64
68
  statusText: '',
65
69
  spinnerFrame: undefined,
66
70
  turnStart: null, // RUNNING 态起点(Date.now());INPUT 态为 null。composeStatus 据此拼走时。
@@ -64,6 +64,12 @@ export function composeSpinnerLine(status, cols) {
64
64
  lead = `${ui.accent}●${ui.reset} ${ui.dim}${status.status}${ui.reset}`;
65
65
  leadW = 1 + 1 + displayWidth(status.status);
66
66
  }
67
+ // 排队下一跳:紧跟「● 状态(+走时)」右侧显示,运行中持续可见,当前任务结束即自动发送。
68
+ const queued = queuedChip();
69
+ if (queued) {
70
+ lead += ` ${queued.str}`;
71
+ leadW += 2 + queued.w;
72
+ }
67
73
  // ── 右段:仅滚动回看时显历史指示 ──
68
74
  let tail = '';
69
75
  let tailW = 0;
@@ -371,6 +377,28 @@ export function clearLiveAtCursor() {
371
377
  export function setLiveUsage(u) {
372
378
  state.liveUsage = u;
373
379
  }
380
+ /**
381
+ * 设置/清除运行态已排队的下一条消息 chip(显示在 `● 状态` 右侧)。
382
+ * 立即重画一次保证入队瞬间可见;之后 RUNNING 态 80ms 心跳会自然带上。
383
+ */
384
+ export function setQueuedTurn(text, mode) {
385
+ state.queuedText = text;
386
+ state.queuedMode = mode;
387
+ drawStatusBar();
388
+ }
389
+ /** 排队 chip:▸ 排队: <首行…>(Plan)。只拼左段,宽度另计(不含 ANSI)。 */
390
+ function queuedChip() {
391
+ const raw = state.queuedText;
392
+ if (!raw)
393
+ return null;
394
+ const firstLine = raw.split('\n')[0] ?? '';
395
+ const preview = truncateDisplay(firstLine.trim(), 32);
396
+ const modeTag = state.queuedMode === 'plan' ? t('status.queuePlanTag') : '';
397
+ const label = t('status.queued');
398
+ const plain = `${label} ${preview}${modeTag}`;
399
+ const str = `${ui.yellow}▸ ${plain}${ui.reset}`;
400
+ return { str, w: 2 + displayWidth(plain) };
401
+ }
374
402
  /** 更新状态行基线(模型 / context / cwd / 模式标识 / 活跃 plan chip / 本轮 token chip)。repl 在轮次边界与切模式时调。 */
375
403
  export function setStatusBase(b) {
376
404
  state.base = b;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.7.6",
3
+ "version": "1.7.8",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 27 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {