mocode-ai 0.4.1 → 0.4.3

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
@@ -396,6 +396,30 @@ export function clearContent() {
396
396
  content.reset();
397
397
  stdout.write(esc.home);
398
398
  }
399
+ /**
400
+ * 撤销(rewind)内容区末尾 n 物理行 —— 供 "刚 echoInput 写下用户气泡、用户撤回"
401
+ * 把气泡从可视区与 buffer 一起拿掉。content.rewind 弹出 buffer 末 n 行;layout 这边
402
+ * 同步上溯 contentRow(contentTop 钳位,防下溢)、scrollOffset 钳到新 totalRows、
403
+ * 最后 repaintViewport 重画可视区(running 态回 runningCaretPos,input 态回续写位)。
404
+ *
405
+ * 与 clearContent 不同:只擦最近一段、保留更早内容与 buffer;光标归内容区底,
406
+ * 不是 (1,1)。常发生在 RUNNING 态的 pending 窗口内 recall —— recall 后调用方
407
+ * 通常再 enterInputMode 切回 INPUT 视觉,光标由后续 paintInput 重画。
408
+ */
409
+ export function rewindContent(rowsToRewind) {
410
+ if (!active || rowsToRewind <= 0)
411
+ return;
412
+ content.rewind(rowsToRewind);
413
+ const g = getGeo();
414
+ contentRow = Math.max(g.contentTop, contentRow - rowsToRewind);
415
+ contentCol = 1;
416
+ // 钳 scrollOffset:不能让 viewport 视点超出新 totalRows
417
+ const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
418
+ scrollOffset = Math.min(scrollOffset, maxOff);
419
+ // 末段 frameRow/frameCol 是 spinner 的画位,撤回若跨过 frame 行也不必清——
420
+ // repaintViewport 会按新 buffer 重画整片,旧 frame 自然被覆盖。
421
+ repaintViewport();
422
+ }
399
423
  // ── viewport 滚动回看(Phase 2)──
400
424
  /** 是否处于滚动回看态(offset>0,内容区显历史)。prompt 据此在非滚动键时回尾。 */
401
425
  export function isScrolled() {
@@ -423,7 +447,16 @@ function normalizeSelection() {
423
447
  ? { startLine: a.line, startCol: a.col, endLine: b.line, endCol: b.col }
424
448
  : { startLine: b.line, startCol: b.col, endLine: a.line, endCol: a.col };
425
449
  }
426
- /** 给自洽带色行的显示列区间 [colStart,colEnd) 套反白(\x1B[7m...\x1B[27m),SGR 码原样穿过不受影响。 */
450
+ /** 给自洽带色行的显示列区间 [colStart,colEnd) 套「统一亮黄底 + 黑字」。
451
+ * 强制重设前/背景,无视原 SGR:md 字符常带 ui.dim / ui.cyan / ui.gray 等,
452
+ * 仅靠 SGR 7 反转对比度极弱;改用显式「亮黄底 SGR 103 + 黑前景 SGR 30」,
453
+ * 跨终端一致。退反白用 0 清 SGR,行末 active SGR 自然续接。
454
+ * 关键:反白 active 期间**吃掉所有行内 SGR**(不让前景色干扰)——否则
455
+ * 行内首个 \x1B[2m(dim)/\x1B[36m(cyan)/\x1B[1m(bold) 进选区后仍生效,
456
+ * 前景被压回原色,整片亮黄底被切割、看着「花」;直穿则带 dim 等,对比不足。
457
+ * 反白外 SGR 直穿,保留原色。 */
458
+ const SEL_OPEN = '\x1B[30;103m'; // 30:黑前景 | 103:亮黄背景
459
+ const SEL_OFF = '\x1B[0m'; // 全清 SGR,行末 active 状态续接
427
460
  function highlightRange(line, colStart, colEnd) {
428
461
  if (colEnd <= colStart)
429
462
  return line;
@@ -433,17 +466,20 @@ function highlightRange(line, colStart, colEnd) {
433
466
  let opened = false;
434
467
  for (let i = 0; i < parts.length; i++) {
435
468
  if (i % 2 === 1) {
436
- out += parts[i]; // SGR 码原样穿过
469
+ // SGR 段:反白内直接吃,不让行内前景色进选区;反白外直穿,保留原色。
470
+ if (opened)
471
+ continue;
472
+ out += parts[i];
437
473
  continue;
438
474
  }
439
475
  for (const ch of parts[i]) {
440
476
  const cw = charWidth(ch.codePointAt(0) ?? 0);
441
477
  if (!opened && w >= colStart && w < colEnd) {
442
- out += '\x1B[7m';
478
+ out += SEL_OPEN;
443
479
  opened = true;
444
480
  }
445
481
  if (opened && w >= colEnd) {
446
- out += '\x1B[27m';
482
+ out += SEL_OFF;
447
483
  opened = false;
448
484
  }
449
485
  out += ch;
@@ -451,7 +487,7 @@ function highlightRange(line, colStart, colEnd) {
451
487
  }
452
488
  }
453
489
  if (opened)
454
- out += '\x1B[27m';
490
+ out += SEL_OFF;
455
491
  return out;
456
492
  }
457
493
  /**
@@ -742,29 +778,52 @@ function composeSpinnerLine(status, cols) {
742
778
  const rightStr = tail ? `${ui.yellow}${tail}${ui.reset}` : '';
743
779
  return twoColumn(lead, leadW, rightStr, tailW, cols);
744
780
  }
745
- /** 下线之下那行(model 行):左 = 模式标识;右 = context + cwd,右端对齐。
781
+ /** 下线之下那行(model 行):左 = 模式标识 + 本轮 token chip;右 = context + cwd,右端对齐。
746
782
  * 活跃 plan chip 不再放这里,改放 spinner 行上方的「虚拟空行」(contentBottom+1,见 drawStatusBar),
747
783
  * 既不挤 model 行,又给输入区上方留出可视分隔带。 */
748
784
  function composeModelLine(status, cols) {
749
785
  const ctx = status.contextBar; // 已带色
750
786
  const ctxW = ansiDisplayWidth(ctx);
751
- // 左段:仅模式标识
787
+ // 左段:模式标识 + 本轮 token chip。token chip 仅展示总量,用 mid 灰,不抢主色。
752
788
  const modeTag = status.modeTag ?? '';
753
789
  const modeColor = modeTag === 'plan' ? ui.yellow : ui.brightCyan;
754
- const modePart = modeTag
755
- ? `${modeColor}${modeTag}${ui.reset}`
756
- : '';
757
- const leftStr = modePart;
758
- const leftW = modeTag ? displayWidth(modeTag) : 0;
790
+ const modePart = modeTag ? `${modeColor}${modeTag}${ui.reset}` : '';
791
+ const modeW = modeTag ? displayWidth(modeTag) : 0;
792
+ const tokChip = formatTurnTokenChip(status.lastTurnUsage);
793
+ const tokW = displayWidth(stripAnsi(tokChip));
794
+ // 合并左段:chip 前留 2 空格分隔,无 modeTag 也允许仅显示 chip(兜底边角)
795
+ const sep = modePart && tokChip ? ' ' : '';
796
+ const leftStr = `${modePart}${sep}${tokChip}`;
797
+ const leftW = modeW + (modePart && tokChip ? sep.length : 0) + tokW;
759
798
  // 右段:ctx + sep + cwd,右端对齐。cwd 按预算截断,极窄(<6)隐藏。
799
+ // 任一 chip 极宽时收紧 cwd(toolbar 列挤压场景),先从 cwd 砍、再隐藏 cwd、再隐藏 token chip。
760
800
  const minGap = 2;
761
- const cwdBudget = cols - leftW - minGap - ctxW - STATUS_SEP_W - 1;
762
- const cwd = cwdBudget >= 6 ? truncateDisplay(status.cwd, cwdBudget) : '';
763
- const cwdW = displayWidth(cwd);
764
- const rightStr = `${ctx}${STATUS_SEP}${ui.dim}${cwd}${ui.reset}`;
765
- const rightW = ctxW + STATUS_SEP_W + cwdW;
801
+ let cwdBudget = cols - leftW - minGap - ctxW - STATUS_SEP_W - 1;
802
+ let cwd = cwdBudget >= 6 ? truncateDisplay(status.cwd, cwdBudget) : '';
803
+ let cwdW = displayWidth(cwd);
804
+ let rightStr = `${ctx}${STATUS_SEP}${ui.dim}${cwd}${ui.reset}`;
805
+ let rightW = ctxW + STATUS_SEP_W + cwdW;
806
+ // 极窄(<24 列含 ctx):藏 token chip
807
+ if (modePart && tokChip && rightW + minGap + leftW > cols) {
808
+ return twoColumn(modePart, modeW, rightStr, rightW, cols);
809
+ }
766
810
  return twoColumn(leftStr, leftW, rightStr, rightW, cols);
767
811
  }
812
+ /** 把本轮 token 总量格式化成 chip 文本(纯字符串,带 ANSI 色)。无 usage 返空串。
813
+ * 显示策略:chip 信息密度有限,只显示总量 + 一个 ↻ 标记表示有 cache 命中。
814
+ * 详细分项(↑↓ 计费/↻ 缓存/reasoning)在 turn 末 summary 行展示,不在此处展开。 */
815
+ function formatTurnTokenChip(usage) {
816
+ if (!usage || !usage.totalTokens)
817
+ return '';
818
+ const n = usage.totalTokens;
819
+ const text = n < 1000 ? `${n}` : `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`;
820
+ const cached = usage.cachedTokens ?? 0;
821
+ const cacheTag = cached > 0
822
+ ? ` ↻${cached < 1000 ? cached : `${(cached / 1000).toFixed(cached >= 10000 ? 0 : 1)}k`}`
823
+ : '';
824
+ // chip 用 mid 灰(降优先级)— 模式仍是主色
825
+ return `${ui.dim}${text} tokens${cacheTag}${ui.reset}`;
826
+ }
768
827
  /** spinner 行上方的「虚拟空行」(contentBottom+1)。
769
828
  * - 有活跃 plan:显「plan: <summary> ▸ N. step」整行左对齐(yellow + dim)
770
829
  * - 无活跃 plan:空(保留原分隔视觉,避免内容贴输入区)
@@ -929,7 +988,7 @@ export function clearLiveAtCursor() {
929
988
  frameRow = 0;
930
989
  frameCol = 0;
931
990
  }
932
- /** 更新状态行基线(模型 / context / cwd / 模式标识 / 活跃 plan chip)。repl 在轮次边界与切模式时调。 */
991
+ /** 更新状态行基线(模型 / context / cwd / 模式标识 / 活跃 plan chip / 本轮 token chip)。repl 在轮次边界与切模式时调。 */
933
992
  export function setStatusBase(b) {
934
993
  base = b;
935
994
  }
package/dist/ui/prompt.js CHANGED
@@ -128,9 +128,15 @@ export async function promptWithSlashMenu(opts) {
128
128
  function cursorCol() {
129
129
  return displayWidth(lines[cl].slice(0, cc));
130
130
  }
131
- /** chip 预览前缀:整段扁平化(行界→空格,避免框内折行)取前 ~20 列,超长 truncateDisplay 自带 …;末尾空格与 suffix 分隔。 */
131
+ /** chip 预览前缀:行数 + 字符数(字符 <1K 直出,≥1K 缩为 X.XK)。比"前 20 字符截断"对 CJK 更友好
132
+ * ——后者在中文里 20 列只够 10 个汉字就截没,几乎看不到内容;元信息密度更高、长度可预测。 */
132
133
  function chipPrefix() {
133
- return chip ? `[${truncateDisplay(chip.split('\n').join(' '), 20)}] ` : '';
134
+ if (!chip)
135
+ return '';
136
+ const lines = chip.split('\n').length;
137
+ const chars = chip.length;
138
+ const charStr = chars < 1000 ? `${chars} 字符` : `${(chars / 1000).toFixed(1)}K 字符`;
139
+ return `[📋 ${lines} 行 · ${charStr}] `;
134
140
  }
135
141
  /** chipPre 按行拆分(粘贴发生前光标之前已有的文本,可能多行,原样保留在 chip 之前)。 */
136
142
  function chipPreLines() {
@@ -178,7 +184,7 @@ export async function promptWithSlashMenu(opts) {
178
184
  function applyPastedText(buf) {
179
185
  if (!buf)
180
186
  return;
181
- const isLong = buf.split('\n').length > 8 || buf.length > 400;
187
+ const isLong = buf.split('\n').length > 8 || buf.length > 200;
182
188
  if (isLong) {
183
189
  const before = lines[cl].slice(0, cc);
184
190
  const after = lines[cl].slice(cc);
@@ -202,7 +208,7 @@ export async function promptWithSlashMenu(opts) {
202
208
  computeFiltered();
203
209
  redraw();
204
210
  }
205
- /** 粘贴结束:长粘贴(>8 行或 >400 字符)落/并进 chip(原子,整段封预览),短粘贴落为可编辑文本。 */
211
+ /** 粘贴结束:长粘贴(>8 行或 >200 字符)落/并进 chip(原子,整段封预览),短粘贴落为可编辑文本。 */
206
212
  function finalizePaste() {
207
213
  if (resolved) {
208
214
  pasteParts = [];
@@ -411,9 +417,13 @@ export async function promptWithSlashMenu(opts) {
411
417
  return;
412
418
  }
413
419
  const isReturn = key.name === 'return' || key.name === 'enter';
414
- // 换行:Ctrl+J / Alt+Enter(meta)/ Shift+Enter(终端区分时)/ lone LF
420
+ // 换行:Ctrl+J / Ctrl+Enter / Alt+Enter(meta)/ Shift+Enter(终端区分时)/ lone LF
415
421
  // (粘贴的 CR/LF 已在上方 pasting 分支累积进 pasteParts,不会到此)
422
+ // Ctrl+Enter:readline 解析得到 key.ctrl=true && isReturn 走这条路;少数老 xterm 把
423
+ // Ctrl+Enter 当裸 \r 发(没 ctrl flag)→ 落到下方「提交」分支做 Enter 处理,
424
+ // 此时用 Ctrl+J 兜底。
416
425
  const wantNewline = (key.ctrl && key.name === 'j') ||
426
+ (key.ctrl && isReturn) ||
417
427
  (key.meta && isReturn) ||
418
428
  (key.shift && isReturn) ||
419
429
  (key.sequence === '\n' && !key.ctrl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {