mocode-ai 0.4.9 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui/batch.js CHANGED
@@ -26,6 +26,8 @@ const MUTATION_TOOLS = new Set(['write_file', 'edit_file']);
26
26
  function isMutationTool(name) {
27
27
  return MUTATION_TOOLS.has(name);
28
28
  }
29
+ /** 展开时完整输出的最大行数;超出截断,避免巨型输出撑爆 viewport。 */
30
+ const MAX_EXPAND_LINES = 200;
29
31
  /** 通知 buffer 整体清空(clearContent / exitAltScreen / 新一轮 turn)——本模块状态同步归零。 */
30
32
  export function reset() {
31
33
  batches.clear();
@@ -45,8 +47,9 @@ export function recordCall(id, name, callSummary) {
45
47
  return;
46
48
  b.entries.push({ name, callSummary, resultSummary: '', diffBlock: null });
47
49
  }
48
- /** 记一条工具结果(diff 块或单行 preview);agent 在 onToolResult 时调,匹配最后一条未填的 entry。 */
49
- export function recordResult(id, name, resultSummary, diffBlock) {
50
+ /** 记一条工具结果(diff 块或单行 preview);agent 在 onToolResult 时调,匹配最后一条未填的 entry。
51
+ * fullOutput:工具原始完整输出(纯文本),展开时显示;mutation 工具的 diff 块已自含无需传。 */
52
+ export function recordResult(id, name, resultSummary, diffBlock, fullOutput) {
50
53
  const b = batches.get(id);
51
54
  if (!b || b.entries.length === 0)
52
55
  return;
@@ -55,6 +58,7 @@ export function recordResult(id, name, resultSummary, diffBlock) {
55
58
  if (b.entries[i].name === name && !b.entries[i].resultSummary) {
56
59
  b.entries[i].resultSummary = resultSummary;
57
60
  b.entries[i].diffBlock = diffBlock;
61
+ b.entries[i].fullOutput = fullOutput;
58
62
  return;
59
63
  }
60
64
  }
@@ -63,6 +67,7 @@ export function recordResult(id, name, resultSummary, diffBlock) {
63
67
  if (!last.resultSummary) {
64
68
  last.resultSummary = resultSummary;
65
69
  last.diffBlock = diffBlock;
70
+ last.fullOutput = fullOutput;
66
71
  }
67
72
  }
68
73
  // ── 摘要行文本生成 ──
@@ -104,6 +109,18 @@ function buildExpandedLines(entries, indent = ' ') {
104
109
  lines.push(line.endsWith('\x1B[0m') ? line : line + '\x1B[0m');
105
110
  }
106
111
  }
112
+ else if (e.fullOutput) {
113
+ // 完整工具输出(纯文本):按行展开,每行缩进 + dim 样式;长输出截断到 MAX_EXPAND_LINES 行
114
+ const rawLines = e.fullOutput.split('\n');
115
+ const truncated = rawLines.length > MAX_EXPAND_LINES;
116
+ const displayLines = truncated ? rawLines.slice(0, MAX_EXPAND_LINES) : rawLines;
117
+ for (const line of displayLines) {
118
+ lines.push(`${indent}${ui.gray}${line}${ui.reset}\x1B[0m`);
119
+ }
120
+ if (truncated) {
121
+ lines.push(`${indent}${ui.dim}… (${rawLines.length - MAX_EXPAND_LINES} more lines)${ui.reset}\x1B[0m`);
122
+ }
123
+ }
107
124
  else if (e.resultSummary) {
108
125
  lines.push(`${indent}${ui.gray}↳ ${e.resultSummary}${ui.reset}\x1B[0m`);
109
126
  }
@@ -136,6 +136,60 @@ export function lineAt(abs) {
136
136
  const all = snapshot();
137
137
  return abs >= 0 && abs < all.length ? all[abs] : null;
138
138
  }
139
+ /**
140
+ * 找「绝对行索引 < absStart 的最近一条用户消息」的文本(用于滚动回看时的「我刚发的
141
+ * 请求」sticky banner)。算法:从 absStart - 1 往上扫,识别「用户气泡行」(由 repl
142
+ * formatUserMessage 写入,剥 SGR 后首字符 = ❯);再从此行往下吞连续的同类行
143
+ * 收集多行消息,join('\n')。
144
+ *
145
+ * 检测 user-bubble 行靠「剥 SGR 后以 ❯ 开头」而非 userBg SGR 前缀:rowStartSgr 继承自
146
+ * 上一行末(可能残留 dim/cyan),且 bubble 起手有 userBg 包裹,直接 startsWith(userBg)
147
+ * 在残留 rowStartSgr 场景会漏判。剥光所有 SGR 后 → 首字符稳定为 ❯ (repl.PROMPT)。
148
+ *
149
+ * 返回的文本已经剥离 ANSI + 提示符,可直接给 banner 渲染(再做截断 / 折叠)。
150
+ * absStart ≤ 0 返 null(没东西在视口上方)。
151
+ */
152
+ export function lastUserMessageBefore(absStart) {
153
+ if (absStart <= 0)
154
+ return null;
155
+ const all = snapshot();
156
+ const SGR = /\x1B\[[0-9;]*m/g;
157
+ const isUserBubbleRow = (row) => {
158
+ // 剥光 SGR 后首字符 = ❯(repl.PROMPT 首字)→ 是 user bubble;
159
+ // agent 行首字符可能是 ● / ╭ / │ / 数字 / 字母,绝不会撞 ❯。
160
+ const visible = row.replace(SGR, '');
161
+ return visible.startsWith('❯');
162
+ };
163
+ // 1) 从 absStart - 1 往上扫,找第一条 user bubble 行(最近的 user-bubble 的最后一行)
164
+ let bubbleEnd = -1;
165
+ for (let i = Math.min(absStart, all.length) - 1; i >= 0; i--) {
166
+ if (isUserBubbleRow(all[i])) {
167
+ bubbleEnd = i;
168
+ break;
169
+ }
170
+ }
171
+ if (bubbleEnd < 0)
172
+ return null;
173
+ // 2) 往上找气泡起点(连续 user-bubble 行块 = 同一条 user 消息)
174
+ let bubbleStart = bubbleEnd;
175
+ while (bubbleStart - 1 >= 0 && isUserBubbleRow(all[bubbleStart - 1])) {
176
+ bubbleStart--;
177
+ }
178
+ // 3) 收文本:按显示字符剥 SGR + 续行/末填充空格 + 首行 prompt
179
+ // 首行剥前导 '❯ ' 序列 → banner 自己再加回 [banner_prompt] + <text>;
180
+ // 一次剥光所有重复的 ❯(防止用户手敲 prompt / 多重回显 → 出现 '❯ ❯ ...' 双提示符)。
181
+ const joinedText = all
182
+ .slice(bubbleStart, bubbleEnd + 1)
183
+ .map((raw, idx) => {
184
+ let stripped = raw.replace(SGR, '');
185
+ if (idx === 0)
186
+ stripped = stripped.replace(/^(?:❯\s*)+/, ''); // 剥光所有前导 ❯(含每个后面的可选空格)
187
+ return stripped.trimEnd();
188
+ })
189
+ .join('\n')
190
+ .replace(/\n+$/, '');
191
+ return joinedText || null;
192
+ }
139
193
  /**
140
194
  * 在绝对行索引 after(0-based,已 commit)之后插入 N 条自洽行。
141
195
  * 用于「折叠摘要行下展开明细」——把详情行在已写入摘要行后面塞入缓冲,
package/dist/ui/layout.js CHANGED
@@ -34,6 +34,8 @@ let runningFrame = -1;
34
34
  let userActiveUntil = 0; // 打字活跃截止时刻(Date.now()+PAUSE);0=未活跃
35
35
  let flushTimer = null; // 用户停手后 flush 缓冲内容(repaintViewport)
36
36
  const USER_ACTIVE_PAUSE_MS = 1500;
37
+ /** Sticky banner 的 ❯ 前缀(同 repl 的 PROMPT;常量统一视觉)。 */
38
+ const BANNER_PROMPT = '❯ ';
37
39
  let lastView = null;
38
40
  let lastMenuStartRow = 0; // 上次菜单起始屏行(供擦除)
39
41
  let lastMenuRows = 0;
@@ -572,6 +574,12 @@ function highlightRange(line, colStart, colEnd) {
572
574
  * 重画内容区 viewport:按 scrollOffset 取缓冲尾窗,逐行 cup+clearline+rowtext 映射到屏 1..contentBottom。
573
575
  * offset=0 即尾窗(== 实时屏,resize / 回尾时用)。清行含 contentBottom——顺带擦 WT 边距漏影(状态行重复)。
574
576
  * 有活跃选区(鼠标拖拽中)时,对选中范围套反白——纯视觉,不影响缓冲内容。
577
+ *
578
+ * **滚动回看 sticky banner(输入框上方固定标题的姊妹需求)**:
579
+ * 仅在 scrollOffset > 0 时,在 viewport 第 1 行顶部叠一行横幅,显示「当前 viewport 顶上
580
+ * 那条用户消息」的预览。用户上滑翻历史时,横幅内容随滚动到的 user→agent 对应关系变化;
581
+ * 滑到底(offset===0)自动消失(实时屏可见,无需 banner)。满宽 padding + 底色对比反色
582
+ * 提示「↑ 这是上方滚走的内容」,不与内容区行内 SGR 冲突。
575
583
  */
576
584
  export function repaintViewport() {
577
585
  if (!active)
@@ -580,11 +588,27 @@ export function repaintViewport() {
580
588
  const h = g.contentBottom;
581
589
  const slice = content.sliceFromEnd(scrollOffset, h);
582
590
  const sel = normalizeSelection();
583
- const absStart = sel ? viewportAbsStart() : 0;
591
+ // viewportAbsStart() 无选区时也用:sticky banner 必须按真实窗口头算,不能降级 0(否则 banner 永不出现)。
592
+ const absStart = viewportAbsStart();
593
+ // sticky banner:仅 scrollOffset>0 时显示;offset=0 即实时屏,user 气泡本来就在视口内,无需 banner。
594
+ const bannerText = scrollOffset > 0
595
+ ? content.lastUserMessageBefore(absStart)
596
+ : null;
597
+ const BANNER_ROW = 1; // 横幅占 viewport 第 1 行(会把原第 1 行内容遮住 —— 1 行换"我在看啥"的可读性,可接受)
584
598
  let p = '';
585
599
  for (let r = 1; r <= h; r++) {
586
600
  let line = slice[r - 1] ?? '';
587
- if (sel) {
601
+ if (bannerText && r === BANNER_ROW) {
602
+ // banner 行:满宽 userBg + ❯ + 单行截断(多行 ⏎ 折叠)+ userBg 补到底 + 底线分隔短横
603
+ const cols = g.cols;
604
+ const oneLine = bannerText.replace(/\s*\n\s*/g, ' ⏎ ');
605
+ const promptW = displayWidth(BANNER_PROMPT);
606
+ const avail = Math.max(1, cols - promptW);
607
+ const truncated = truncateDisplayHead(oneLine, avail);
608
+ const padCount = Math.max(0, cols - promptW - displayWidth(truncated));
609
+ line = `${ui.userBg}${BANNER_PROMPT}${truncated}${' '.repeat(padCount)}${ui.reset}`;
610
+ }
611
+ else if (sel) {
588
612
  const absLine = absStart + r - 1;
589
613
  if (absLine >= sel.startLine && absLine <= sel.endLine) {
590
614
  const lineW = ansiDisplayWidth(line);
@@ -1143,28 +1167,50 @@ function composeSpinnerLine(status, cols) {
1143
1167
  function composeModelLine(status, cols) {
1144
1168
  const ctx = status.contextBar; // 已带色
1145
1169
  const ctxW = ansiDisplayWidth(ctx);
1146
- // 左段:模式标识 + 本轮 token chip。token chip 仅展示总量,用 mid 灰,不抢主色。
1170
+ // 左段:模式标识 + 切换提示(灰)+ 本轮 token chip。token chip 仅展示总量,用 mid 灰,不抢主色。
1147
1171
  const modeTag = status.modeTag ?? '';
1148
1172
  const modeColor = modeTag === 'plan' ? ui.yellow : ui.brightCyan;
1149
1173
  const modePart = modeTag ? `${modeColor}${modeTag}${ui.reset}` : '';
1150
1174
  const modeW = modeTag ? displayWidth(modeTag) : 0;
1175
+ // 切换提示:告诉用户怎么切模式。灰(dim)降优先级,不与 modeTag 抢色;只在有 modeTag 时出现。
1176
+ const HINT = 'Shift+Tab 切换模式';
1177
+ const hintW = modeTag ? displayWidth(HINT) : 0;
1178
+ const hintPart = modeTag ? `${ui.dim}${HINT}${ui.reset}` : '';
1151
1179
  const tokChip = formatTurnTokenChip(status.lastTurnUsage);
1152
1180
  const tokW = displayWidth(stripAnsi(tokChip));
1153
- // 合并左段:chip 前留 2 空格分隔,无 modeTag 也允许仅显示 chip(兜底边角)。
1154
- const sep = modePart && tokChip ? ' ' : '';
1155
- const leftStr = `${modePart}${sep}${tokChip}`;
1156
- const leftW = modeW + (modePart && tokChip ? sep.length : 0) + tokW;
1181
+ // 合并左段:段间留 2 空格分隔。极窄时 hint 与 chip 都可能藏掉。
1182
+ // 优先级:modeTag(必) > chip(提示累计 token,有信息量)> hint(纯说明性,窄时最先省)。
1183
+ const sepMH = modePart && (hintPart || tokChip) ? ' ' : '';
1184
+ const sepHT = (hintPart && tokChip) ? ' ' : '';
1185
+ const leftStr = `${modePart}${sepMH}${hintPart}${sepHT}${tokChip}`;
1186
+ const leftW = modeW
1187
+ + (modePart && (hintPart || tokChip) ? sepMH.length : 0)
1188
+ + hintW
1189
+ + ((hintPart && tokChip) ? sepHT.length : 0)
1190
+ + tokW;
1157
1191
  // 右段:ctx + sep + cwd,右端对齐。cwd 按预算截断,极窄(<6)隐藏。
1158
- // 任一 chip 极宽时收紧 cwd(toolbar 列挤压场景),先从 cwd 砍、再隐藏 cwd、再隐藏 token chip。
1192
+ // 任一 chip 极宽时收紧 cwd(toolbar 列挤压场景),先从 cwd 砍、再隐藏 cwd、再按 hint→chip 顺序省。
1159
1193
  const minGap = 2;
1160
1194
  let cwdBudget = cols - leftW - minGap - ctxW - STATUS_SEP_W - 1;
1161
1195
  let cwd = cwdBudget >= 6 ? truncateDisplay(status.cwd, cwdBudget) : '';
1162
1196
  let cwdW = displayWidth(cwd);
1163
1197
  let rightStr = `${ctx}${STATUS_SEP}${ui.dim}${cwd}${ui.reset}`;
1164
1198
  let rightW = ctxW + STATUS_SEP_W + cwdW;
1165
- // 极窄(<24 列含 ctx):藏 token chip
1166
- if (modePart && tokChip && rightW + minGap + leftW > cols) {
1167
- return twoColumn(modePart, modeW, rightStr, rightW, cols);
1199
+ // 极窄:逐步降级——先藏 hint,再藏 token chip,只剩 modeTag 与右段挤。
1200
+ // 这样 80 列宽终端下 hint 和 chip 都能稳住,只 <50 列才退化到只剩 modeTag。
1201
+ if (leftW + minGap + rightW > cols) {
1202
+ // 优先藏 hint(纯说明,信息密度最低):仅留 modePart + sep + tokChip
1203
+ if (modePart && tokChip && hintPart) {
1204
+ const leftStr2 = `${modePart}${sepMH}${tokChip}`;
1205
+ const leftW2 = modeW + sepMH.length + tokW;
1206
+ if (leftW2 + minGap + rightW <= cols) {
1207
+ return twoColumn(leftStr2, leftW2, rightStr, rightW, cols);
1208
+ }
1209
+ }
1210
+ // 再藏 token chip:仅留 modePart
1211
+ if (modePart && tokChip) {
1212
+ return twoColumn(modePart, modeW, rightStr, rightW, cols);
1213
+ }
1168
1214
  }
1169
1215
  return twoColumn(leftStr, leftW, rightStr, rightW, cols);
1170
1216
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {