mocode-ai 0.5.4 → 0.5.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.
package/dist/ui/layout.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { stdin, stdout } from 'node:process';
2
- import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, stripAnsi, sliceByDisplayCol, } from './render.js';
2
+ import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, truncateAnsi, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, stripAnsi, sliceByDisplayCol, } from './render.js';
3
3
  import { ui } from './theme.js';
4
4
  import * as content from './content.js';
5
5
  import * as mouse from './mouse.js';
6
+ import { shiftBatchesAfter } from './batch.js';
6
7
  import { copyToClipboard, readClipboard } from './clipboard.js';
7
8
  import { renderMarkdown } from './markdown.js';
8
9
  // ── 内部状态 ──
@@ -47,7 +48,6 @@ let sigwinchHandler = null;
47
48
  // 非 md 写(contentWrite)先 commitMd 收尾(清 segMark,后续写不再被 setLines 截断)。
48
49
  let mdActive = false;
49
50
  let mdBuf = '';
50
- let mdLeadingBlank = false;
51
51
  let selection = null;
52
52
  let selecting = false; // 左键按下中(press→release 之间)
53
53
  let mouseEnabled = true; // 导航菜单(picker)期间置 false:只吞报表不做选区/滚动,防菜单被 viewport 重画覆盖
@@ -313,11 +313,10 @@ export function contentWrite(s) {
313
313
  }
314
314
  }
315
315
  /** 进入 markdown 流式段:标记段起点(layout 续写位 + content.beginSegment),清 accumulator。 */
316
- function beginMdSegment(leadingBlank = false) {
316
+ function beginMdSegment() {
317
317
  segmentStartRow = contentRow;
318
318
  content.beginSegment();
319
319
  mdBuf = '';
320
- mdLeadingBlank = leadingBlank;
321
320
  mdActive = true;
322
321
  }
323
322
  /** 提交 markdown 段:清 accumulator + content.commitSegment(后续非 md 写不再被 setLines 截断)。 */
@@ -326,7 +325,6 @@ function commitMd() {
326
325
  return;
327
326
  mdActive = false;
328
327
  mdBuf = '';
329
- mdLeadingBlank = false;
330
328
  content.commitSegment();
331
329
  }
332
330
  // ── Banner(启动横幅/模式切换横幅)固定顶部行 ──
@@ -406,15 +404,13 @@ export function bannerHeight() {
406
404
  * 物理重画用 repaintViewport(全内容区,原子一次 write 无闪烁)— md 段是缓冲尾,viewport 显尾即显段。
407
405
  * 滚动回看(scrollOffset>0)只更新缓冲不物理写(回尾时显);打字中照常物理写——单次 write 结尾 cup 回输入框,IME 锚定不动。
408
406
  */
409
- export function contentWriteMd(s, leadingBlank = false) {
407
+ export function contentWriteMd(s) {
410
408
  if (!active || !ui.isTTY) {
411
409
  stdout.write(s);
412
410
  return;
413
411
  }
414
412
  if (!mdActive)
415
- beginMdSegment(leadingBlank);
416
- else if (leadingBlank)
417
- mdLeadingBlank = true;
413
+ beginMdSegment();
418
414
  mdBuf += s;
419
415
  const g = getGeo();
420
416
  // 滚动回看冻结(同 contentWrite):scrollOffset>0 时 setLines 替换段会改缓冲行数,若 offset 不变,
@@ -422,12 +418,7 @@ export function contentWriteMd(s, leadingBlank = false) {
422
418
  // offset += delta(可负:setLines 重渲染可能缩行)冻住视图。md 段在尾,窗口在上方,冻结后不重叠。
423
419
  const scrolled = scrollOffset > 0;
424
420
  const totalBefore = scrolled ? content.totalRows() : 0;
425
- // 工具→正文边界由本层统一提供一个空行;模型偶尔自带 \n/\n\n,先剥掉,避免随机叠成两行。
426
- const markdownSource = mdLeadingBlank
427
- ? mdBuf.replace(/^(?:[ \t]*\r?\n)+/, '')
428
- : mdBuf;
429
- const rendered = renderMarkdown(markdownSource, g.cols);
430
- const lines = mdLeadingBlank ? [ui.reset, ...rendered] : rendered;
421
+ const lines = renderMarkdown(mdBuf, g.cols);
431
422
  content.setLines(lines);
432
423
  const segRows = lines.length;
433
424
  const available = g.contentBottom - segmentStartRow + 1;
@@ -479,8 +470,6 @@ export function clearContent() {
479
470
  scrollLockUntil = 0;
480
471
  mdActive = false;
481
472
  mdBuf = '';
482
- mdLeadingBlank = false;
483
- mdLeadingBlank = false;
484
473
  content.reset();
485
474
  notifyContentReset(); // batch 渲染器同步重置(batch 摘要行索引全部失效)
486
475
  stdout.write(esc.home);
@@ -553,8 +542,9 @@ export function contentInsertAfter(after, lines) {
553
542
  scrollOffset = Math.min(delta, Math.max(0, content.totalRows() - g.contentBottom));
554
543
  }
555
544
  }
556
- // batch 摘要行索引平移
557
- void import('./batch.js').then((m) => m.shiftBatchesAfter(after, delta));
545
+ // 必须同步平移 batch 索引。若延迟到 dynamic import.then,下一条 mutation 可能已经
546
+ // 按插入后的 buffer 创建;旧回调会再平移它一次,导致详情插到空行之后。
547
+ shiftBatchesAfter(after, delta);
558
548
  repaintViewport();
559
549
  }
560
550
  /**
@@ -582,20 +572,42 @@ export function contentDeleteFrom(startIdx, n) {
582
572
  if (scrolled) {
583
573
  scrollOffset = Math.max(0, scrollOffset - delta);
584
574
  }
585
- // batch 摘要行索引平移(用 -delta 表示后段索引前移)
586
- void import('./batch.js').then((m) => m.shiftBatchesAfter(startIdx, -delta));
575
+ // batch 摘要行索引平移(用 -delta 表示后段索引前移),同插入路径必须同步。
576
+ shiftBatchesAfter(startIdx, -delta);
587
577
  repaintViewport();
588
578
  }
589
579
  /** 当前内容物理行总数(含当前未提交行)。供 batch 渲染器在 endBatch 时定位摘要行索引。 */
590
580
  export function totalRows() {
591
581
  return content.totalRows();
592
582
  }
593
- /** 原位替换一条已提交的内容行并重画;供连续工具链更新同一条 batch 摘要。 */
594
- export function contentReplaceLine(absIdx, line) {
595
- content.replaceHead(absIdx, [line.endsWith(ui.reset) ? line : line + ui.reset]);
596
- if (active && scrollOffset === 0)
583
+ /** 正文→mutation 首摘要前,把尾部间距强制归一为一条视觉空行。 */
584
+ export function normalizeMutationBoundary() {
585
+ if (!active || !ui.isTTY)
586
+ return;
587
+ if (mdActive)
588
+ commitMd();
589
+ const totalBefore = content.totalRows();
590
+ content.normalizeTrailingBlankRows(1);
591
+ const g = getGeo();
592
+ const delta = content.totalRows() - totalBefore;
593
+ if (scrollOffset > 0 && delta !== 0) {
594
+ scrollOffset = Math.max(0, Math.min(scrollOffset + delta, Math.max(0, content.totalRows() - g.contentBottom)));
595
+ }
596
+ contentRow = Math.min(content.committedRows() + 1, g.contentBottom);
597
+ contentCol = 1;
598
+ if (scrollOffset === 0)
597
599
  repaintViewport();
598
600
  }
601
+ /** 原地刷新一条内容行(行数不变),用于运行中的工具 batch 更新计数。 */
602
+ export function contentReplaceLine(absIdx, line) {
603
+ if (!active)
604
+ return;
605
+ // 原地更新的 batch 摘要可能随追加工具而变长。缓冲区仍把它当作一行,但若直接
606
+ // 交给终端超过 cols,终端会自动折行,造成视觉上多出空行且续写位与缓冲失步。
607
+ // 在写回缓冲前按 ANSI 可见宽度截断,确保“一条逻辑行 = 一条物理行”。
608
+ content.replaceLine(absIdx, truncateAnsi(line, getGeo().cols));
609
+ repaintViewport();
610
+ }
599
611
  /** 清空内容区时通知 batch 渲染器重置(摘要行映射与展开态)。 */
600
612
  export function notifyContentReset() {
601
613
  // 动态 import 避免循环;模块级 reset() 只清映射,不动 batch 内部数据(id 与 entries 仍可重用)
@@ -1153,18 +1165,25 @@ function handleMouseEvent(e) {
1153
1165
  void (async () => {
1154
1166
  try {
1155
1167
  const m = await import('./batch.js');
1156
- const hit = m.findBatchHit(absClick);
1157
- if (hit) {
1158
- const batchLayout = {
1168
+ const entry = m.findEntryByAbsLine(absClick);
1169
+ if (entry) {
1170
+ // 先清选区再改 buffer:contentInsert/Delete 内会立即 repaintViewport;若此时仍保留
1171
+ // 旧绝对行选区,会短暂画出一帧错位高亮,随后二次重画,视觉上就是抖动。
1172
+ selection = null;
1173
+ m.toggleEntry(entry.batchId, entry.entryIndex, {
1159
1174
  contentInsertAfter: (after, lines) => contentInsertAfter(after, lines),
1160
1175
  contentDeleteFrom: (start, n) => contentDeleteFrom(start, n),
1161
- };
1162
- if (hit.entryIndex === null)
1163
- m.toggleBatch(hit.id, batchLayout);
1164
- else
1165
- m.toggleEntry(hit.id, hit.entryIndex, batchLayout);
1176
+ });
1177
+ repaint();
1178
+ return;
1179
+ }
1180
+ const id = m.findBatchByAbsLine(absClick);
1181
+ if (id) {
1166
1182
  selection = null;
1167
- repaintViewport();
1183
+ m.toggleBatch(id, {
1184
+ contentInsertAfter: (after, lines) => contentInsertAfter(after, lines),
1185
+ contentDeleteFrom: (start, n) => contentDeleteFrom(start, n),
1186
+ });
1168
1187
  repaint();
1169
1188
  return;
1170
1189
  }
@@ -1274,11 +1293,11 @@ function composeModelLine(status, cols) {
1274
1293
  const ctxW = ansiDisplayWidth(ctx);
1275
1294
  // 左段:模式标识 + 切换提示(灰)+ 本轮 token chip。token chip 仅展示总量,用 mid 灰,不抢主色。
1276
1295
  const modeTag = status.modeTag ?? '';
1277
- const modeColor = modeTag === 'plan' ? ui.yellow : ui.accent;
1296
+ const modeColor = modeTag === 'Plan' ? ui.yellow : ui.accent;
1278
1297
  const modePart = modeTag ? `${modeColor}${modeTag}${ui.reset}` : '';
1279
1298
  const modeW = modeTag ? displayWidth(modeTag) : 0;
1280
1299
  // 切换提示:告诉用户怎么切模式。灰(dim)降优先级,不与 modeTag 抢色;只在有 modeTag 时出现。
1281
- const HINT = 'Shift+Tab 切换模式';
1300
+ const HINT = 'Shift+Tab 切换';
1282
1301
  const hintW = modeTag ? displayWidth(HINT) : 0;
1283
1302
  const hintPart = modeTag ? `${ui.dim}${HINT}${ui.reset}` : '';
1284
1303
  const tokChip = formatTurnTokenChip(status.lastTurnUsage);
@@ -1872,7 +1891,6 @@ export function enterAltScreen() {
1872
1891
  scrollLockUntil = 0;
1873
1892
  mdActive = false;
1874
1893
  mdBuf = '';
1875
- mdLeadingBlank = false;
1876
1894
  selection = null;
1877
1895
  selecting = false;
1878
1896
  content.reset();
@@ -13,7 +13,7 @@
13
13
  // clipAnsiLine 兜底,绝不溢出。颜色一律嵌入式 ANSI(ui.*)写进字符串。
14
14
  import { highlight, supportsLanguage } from 'cli-highlight';
15
15
  import { ui, getThemeVersion } from './theme.js';
16
- import { charWidth, displayWidth, ansiDisplayWidth } from './render.js';
16
+ import { charWidth, displayWidth, ansiDisplayWidth, stripAnsi } from './render.js';
17
17
  const RESET = '\x1b[0m';
18
18
  // theme 无 italic/strike,自备(TTY 感知:!isTTY 退化为空串,同 ui.* 契约)。
19
19
  const ITALIC = ui.isTTY ? '\x1b[3m' : '';
@@ -574,11 +574,17 @@ function renderMarkdownImpl(text, cols) {
574
574
  flushPara();
575
575
  if (inFence)
576
576
  flushCode(); // EOF 仍 inFence:流式中未闭合 fence → 照常 emit 进行中代码块
577
+ // 独立 markdown 段的外部间距由 agent/batch 边界统一管理。流式后端可能把
578
+ // "\n\n" 与首段正文拆成不同 chunk;仅清洗首个正文 chunk 不够,因为 mdBuf
579
+ // 累积重渲染时这些换行仍会变成段首空行。这里最终兜底,正文段永不自带前导空行。
580
+ const isVisualBlank = (line) => stripAnsi(line).trim().length === 0;
581
+ while (out.length > 0 && isVisualBlank(out[0]))
582
+ out.shift();
577
583
  // 末尾不留空行:agent onText 后接 onToolCall 的 contentWrite('\n') 会补 1 空行分隔正文与 ● 行;
578
584
  // 若 md 末尾自带空行(段落/代码块后)则叠成 2 空行。裁掉末尾连续空行,让 onToolCall / 轮末
579
585
  // contentWrite('\n') 恰好补 1 行(与改造前 raw 文本行为一致)。块间空行(flushPara/flushCode 中段
580
586
  // push 的)不受影响——只裁末尾。
581
- while (out.length > 0 && out[out.length - 1] === '')
587
+ while (out.length > 0 && isVisualBlank(out[out.length - 1]))
582
588
  out.pop();
583
589
  return out;
584
590
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {