mocode-ai 0.4.9 → 0.5.0

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
@@ -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;
@@ -320,6 +322,67 @@ function commitMd() {
320
322
  mdBuf = '';
321
323
  content.commitSegment();
322
324
  }
325
+ // ── Banner(启动横幅/模式切换横幅)固定顶部行 ──
326
+ // 与 contentWrite 的「续写式追加」不同:banner 自带模式行集合(MoCode logo + 信息行),
327
+ // 反复 writeBanner(同一启动会话内同一份 banner 反复画 — 启动后、换 model 后、刷新 snapshot 后)
328
+ // 应让 buffer 顶部始终是「最新的一份 banner」,而不是堆 5 份。
329
+ // rewriteBanner 在 banner 已建好时原地等长替换顶部 bannerH 行,viewport 自动从 rows[] 头读新版。
330
+ //
331
+ // bannerH = bannerLines(banner()) 的行数(目前 5:4 行 logo+info + 1 空行分隔)。如未来
332
+ // bannerString 变化,调用方需重调 writeBanner 重设 bannerH。
333
+ let bannerH = 0;
334
+ let bannerRows = []; // 最新写入的 banner 行(供 rewriteBanner 复用 / 调试)
335
+ /**
336
+ * 在 content 缓冲顶部建一份 banner;**首次**调用建好(bannerH = lines.length),**重复**调用
337
+ * 等价 rewriteBanner(lines)(覆盖式刷新,供启动后再写 banner 也安全)。
338
+ *
339
+ * lines 必须与首次写入等长(否则 content.replaceHead 抛错)— 调用方应统一传 bannerLines(banner())。
340
+ * 滚动回看冻结(scrollOffset+=delta / 改 offset 不变):行数不变 offset 不动 → 视图冻结,无须 repaint 风暴。
341
+ * 仅当 scrollOffset === 0 时 repaintViewport 让新版 banner 立刻进 viewport 顶部。
342
+ */
343
+ export function writeBanner(lines) {
344
+ if (bannerH === 0) {
345
+ // 首次:buffer 通常空(启动最早时),用 setLines 灌入;若 buffer 非空(如 reseed)说明 banner
346
+ // 已被前置写过,throw 阻止静默错位 — 调用方应先 reset banner 然后 writeBanner。
347
+ if (mdActive)
348
+ commitMd();
349
+ const existed = content.totalRows();
350
+ if (existed !== 0) {
351
+ throw new Error(`writeBanner: 首次调用时 buffer 已有 ${existed} 行,需先 clearContent 再 writeBanner(否则会覆盖已有内容)`);
352
+ }
353
+ content.setLines(lines);
354
+ bannerH = lines.length;
355
+ bannerRows = lines;
356
+ if (scrollOffset === 0)
357
+ repaintViewport();
358
+ return;
359
+ }
360
+ // 后续:等价 rewriteBanner
361
+ rewriteBanner(lines);
362
+ }
363
+ /**
364
+ * 把 banner 顶部 bannerH 行**等长替换**为新行;banner 后的内容(对话历史)位置不动。
365
+ * 行数与首次 writeBanner 不一致 → 抛错(避免 banner 区错位);如必须改,先清空 content 后再 writeBanner。
366
+ * 滚动回看冻结:行数不变,offset 不动。等 scrollOffset 回到 0 时 viewport 自动显新版 banner。
367
+ */
368
+ export function rewriteBanner(lines) {
369
+ if (bannerH === 0) {
370
+ // 没建过 banner:等价首次 writeBanner(允许首次调用直接进 rewriteBanner 的别名入口)
371
+ writeBanner(lines);
372
+ return;
373
+ }
374
+ if (lines.length !== bannerH) {
375
+ throw new Error(`rewriteBanner: 行数 ${lines.length} ≠ 已建 bannerH ${bannerH}(调用方需统一行数)`);
376
+ }
377
+ content.replaceHead(0, lines);
378
+ bannerRows = lines;
379
+ if (scrollOffset === 0)
380
+ repaintViewport();
381
+ }
382
+ /** 测试 / 调试用:当前 banner 占多少行;0 表示未启用。 */
383
+ export function bannerHeight() {
384
+ return bannerH;
385
+ }
323
386
  /**
324
387
  * markdown 正文写(替代 contentWrite 用于 agent onText):累积 chunk 到 mdBuf,每 chunk 把整段
325
388
  * mdBuf 经 renderMarkdown 渲成自洽 ANSI 行,replace 缓冲段(content.setLines 截旧 + 写新),
@@ -456,6 +519,19 @@ export function contentInsertAfter(after, lines) {
456
519
  if (scrolled) {
457
520
  scrollOffset = Math.max(0, Math.min(scrollOffset + delta, Math.max(0, content.totalRows() - g.contentBottom)));
458
521
  }
522
+ // 展开工具信息时保持视口位置:当 scrollOffset===0(未滚动)且插入点在视口内时,
523
+ // 计算 scrollOffset 让视口锚定在插入前的绝对行位置,展开内容在下面自然展开,
524
+ // 而不是自动跳到展开内容底部(用户体验:点击摘要行,视口不动,详情在下方展开)。
525
+ // 关键:插入点绝对行 = after;插入前视口尾行绝对行 = totalBefore - 1;
526
+ // 插入后要让原视口尾行仍在屏底 → scrollOffset = 插入后新增的、在原视口尾行之后的行数。
527
+ if (!scrolled && after < totalBefore) {
528
+ // 插入点在原缓冲内(非追加到末尾),计算需要滚动的偏移量
529
+ const insertedAfterViewport = after >= (totalBefore - g.contentBottom);
530
+ if (insertedAfterViewport) {
531
+ // 插入点在视口内:保持视口位置,scrollOffset = 插入行数(展开内容在视口下方)
532
+ scrollOffset = Math.min(delta, Math.max(0, content.totalRows() - g.contentBottom));
533
+ }
534
+ }
459
535
  // batch 摘要行索引平移
460
536
  void import('./batch.js').then((m) => m.shiftBatchesAfter(after, delta));
461
537
  repaintViewport();
@@ -572,6 +648,12 @@ function highlightRange(line, colStart, colEnd) {
572
648
  * 重画内容区 viewport:按 scrollOffset 取缓冲尾窗,逐行 cup+clearline+rowtext 映射到屏 1..contentBottom。
573
649
  * offset=0 即尾窗(== 实时屏,resize / 回尾时用)。清行含 contentBottom——顺带擦 WT 边距漏影(状态行重复)。
574
650
  * 有活跃选区(鼠标拖拽中)时,对选中范围套反白——纯视觉,不影响缓冲内容。
651
+ *
652
+ * **滚动回看 sticky banner(输入框上方固定标题的姊妹需求)**:
653
+ * 仅在 scrollOffset > 0 时,在 viewport 第 1 行顶部叠一行横幅,显示「当前 viewport 顶上
654
+ * 那条用户消息」的预览。用户上滑翻历史时,横幅内容随滚动到的 user→agent 对应关系变化;
655
+ * 滑到底(offset===0)自动消失(实时屏可见,无需 banner)。满宽 padding + 底色对比反色
656
+ * 提示「↑ 这是上方滚走的内容」,不与内容区行内 SGR 冲突。
575
657
  */
576
658
  export function repaintViewport() {
577
659
  if (!active)
@@ -580,11 +662,27 @@ export function repaintViewport() {
580
662
  const h = g.contentBottom;
581
663
  const slice = content.sliceFromEnd(scrollOffset, h);
582
664
  const sel = normalizeSelection();
583
- const absStart = sel ? viewportAbsStart() : 0;
665
+ // viewportAbsStart() 无选区时也用:sticky banner 必须按真实窗口头算,不能降级 0(否则 banner 永不出现)。
666
+ const absStart = viewportAbsStart();
667
+ // sticky banner:仅 scrollOffset>0 时显示;offset=0 即实时屏,user 气泡本来就在视口内,无需 banner。
668
+ const bannerText = scrollOffset > 0
669
+ ? content.lastUserMessageBefore(absStart)
670
+ : null;
671
+ const BANNER_ROW = 1; // 横幅占 viewport 第 1 行(会把原第 1 行内容遮住 —— 1 行换"我在看啥"的可读性,可接受)
584
672
  let p = '';
585
673
  for (let r = 1; r <= h; r++) {
586
674
  let line = slice[r - 1] ?? '';
587
- if (sel) {
675
+ if (bannerText && r === BANNER_ROW) {
676
+ // banner 行:满宽 userBg + ❯ + 单行截断(多行 ⏎ 折叠)+ userBg 补到底 + 底线分隔短横
677
+ const cols = g.cols;
678
+ const oneLine = bannerText.replace(/\s*\n\s*/g, ' ⏎ ');
679
+ const promptW = displayWidth(BANNER_PROMPT);
680
+ const avail = Math.max(1, cols - promptW);
681
+ const truncated = truncateDisplayHead(oneLine, avail);
682
+ const padCount = Math.max(0, cols - promptW - displayWidth(truncated));
683
+ line = `${ui.userBg}${BANNER_PROMPT}${truncated}${' '.repeat(padCount)}${ui.reset}`;
684
+ }
685
+ else if (sel) {
588
686
  const absLine = absStart + r - 1;
589
687
  if (absLine >= sel.startLine && absLine <= sel.endLine) {
590
688
  const lineW = ansiDisplayWidth(line);
@@ -912,6 +1010,24 @@ function handleMouseEvent(e) {
912
1010
  scrollBy(e.dir * WHEEL_LINES);
913
1011
  return;
914
1012
  }
1013
+ // 输入行左键点击:即使 mouseEnabled=false(如 intervention 面板期间)也允许定位光标,
1014
+ // 提升用户体验(点哪插哪)。拖拽/右键/内容区点击仍受 mouseEnabled 限制。
1015
+ if (e.button === 0 && e.type === 'press' && isInputRow(e.row)) {
1016
+ setInputCursorFromClick(e.row, e.col);
1017
+ const pos = inputScreenToInputPos(e.row, e.col);
1018
+ if (pos) {
1019
+ if (selection) {
1020
+ selection = null;
1021
+ }
1022
+ inputSelection = {
1023
+ anchor: { line: pos.line, col: pos.displayCol },
1024
+ end: { line: pos.line, col: pos.displayCol },
1025
+ dragged: false,
1026
+ };
1027
+ paintInput(lastView);
1028
+ }
1029
+ return;
1030
+ }
915
1031
  if (!mouseEnabled)
916
1032
  return;
917
1033
  const g = getGeo();
@@ -947,26 +1063,8 @@ function handleMouseEvent(e) {
947
1063
  if (e.button !== 0)
948
1064
  return; // 其余中/右键 press/drag 不处理(终端原生右键菜单等不受影响)
949
1065
  if (e.type === 'press') {
950
- if (isInputRow(e.row)) {
951
- // 左键落输入行:进输入态 + 光标定位到点击位 + 开输入框反白选区(起点 = 终点 = 点击位)。
952
- // 不再 return——后续 drag/release 分支也要知道选区在输入框。点击未拖动时 release 端会清掉选区,
953
- // 与"点一下就定位光标、起手打字"的习惯一致。
954
- setInputCursorFromClick(e.row, e.col);
955
- const pos = inputScreenToInputPos(e.row, e.col);
956
- if (pos) {
957
- // 清掉内容区旧选区(若有):按一下输入框不应同时保留内容区反白,视觉混淆。
958
- if (selection) {
959
- selection = null;
960
- }
961
- inputSelection = {
962
- anchor: { line: pos.line, col: pos.displayCol },
963
- end: { line: pos.line, col: pos.displayCol },
964
- dragged: false,
965
- };
966
- paintInput(lastView);
967
- }
968
- return;
969
- }
1066
+ // 输入行左键已在上方 mouseEnabled 检查之前处理(支持 intervention 面板期间点击定位光标)
1067
+ // 此处只处理内容区选区。
970
1068
  selecting = true;
971
1069
  const rowInContent = Math.max(1, Math.min(e.row, g.contentBottom));
972
1070
  const absLine = screenRowToAbsLine(rowInContent);
@@ -1105,26 +1203,26 @@ function composeSpinnerLine(status, cols) {
1105
1203
  // 滚动回看:左段 = 符号(◆ 或 心跳帧) + 状态名(灰,无走时);右段 = 历史指示。
1106
1204
  // 跟非回看的 INPUT/RUNNING 态保持一致——避免「◆ 留下、状态字蒸发」的视觉错觉。
1107
1205
  const symbol = spinning
1108
- ? `${ui.brightMagenta}${RUNNING_FRAMES[runningFrame]}${ui.reset}`
1109
- : `${ui.brightCyan}◆${ui.reset}`;
1206
+ ? `${ui.bold}${ui.accent}${RUNNING_FRAMES[runningFrame]}${ui.reset}`
1207
+ : `${ui.accent}◆${ui.reset}`;
1110
1208
  lead = `${symbol} ${ui.dim}${status.status}${ui.reset}`;
1111
1209
  leadW = 1 + 1 + displayWidth(status.status);
1112
1210
  }
1113
1211
  else if (hasSpinner) {
1114
1212
  // spinner 激活(思考中/执行工具…):帧 + 状态 + 走时
1115
1213
  const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
1116
- lead = `${ui.brightMagenta}${status.spinnerFrame}${ui.reset} ${ui.dim}${status.status}${ui.reset}${ePart}`;
1214
+ lead = `${ui.bold}${ui.accent}${status.spinnerFrame}${ui.reset} ${ui.dim}${status.status}${ui.reset}${ePart}`;
1117
1215
  leadW = 1 + 1 + displayWidth(status.status) + (elapsed ? 1 + displayWidth(elapsed) : 0);
1118
1216
  }
1119
1217
  else if (spinning) {
1120
1218
  // 运行态心跳帧(流式输出中):帧 + 走时
1121
1219
  const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
1122
- lead = `${ui.brightMagenta}${RUNNING_FRAMES[runningFrame]}${ui.reset}${ePart}`;
1220
+ lead = `${ui.bold}${ui.accent}${RUNNING_FRAMES[runningFrame]}${ui.reset}${ePart}`;
1123
1221
  leadW = 1 + (elapsed ? 1 + displayWidth(elapsed) : 0);
1124
1222
  }
1125
1223
  else {
1126
1224
  // INPUT 态:◆ + 状态文字(无走时)
1127
- lead = `${ui.brightCyan}◆${ui.reset} ${ui.dim}${status.status}${ui.reset}`;
1225
+ lead = `${ui.accent}◆${ui.reset} ${ui.dim}${status.status}${ui.reset}`;
1128
1226
  leadW = 1 + 1 + displayWidth(status.status);
1129
1227
  }
1130
1228
  // ── 右段:仅滚动回看时显历史指示 ──
@@ -1143,28 +1241,50 @@ function composeSpinnerLine(status, cols) {
1143
1241
  function composeModelLine(status, cols) {
1144
1242
  const ctx = status.contextBar; // 已带色
1145
1243
  const ctxW = ansiDisplayWidth(ctx);
1146
- // 左段:模式标识 + 本轮 token chip。token chip 仅展示总量,用 mid 灰,不抢主色。
1244
+ // 左段:模式标识 + 切换提示(灰)+ 本轮 token chip。token chip 仅展示总量,用 mid 灰,不抢主色。
1147
1245
  const modeTag = status.modeTag ?? '';
1148
- const modeColor = modeTag === 'plan' ? ui.yellow : ui.brightCyan;
1246
+ const modeColor = modeTag === 'plan' ? ui.yellow : ui.accent;
1149
1247
  const modePart = modeTag ? `${modeColor}${modeTag}${ui.reset}` : '';
1150
1248
  const modeW = modeTag ? displayWidth(modeTag) : 0;
1249
+ // 切换提示:告诉用户怎么切模式。灰(dim)降优先级,不与 modeTag 抢色;只在有 modeTag 时出现。
1250
+ const HINT = 'Shift+Tab 切换模式';
1251
+ const hintW = modeTag ? displayWidth(HINT) : 0;
1252
+ const hintPart = modeTag ? `${ui.dim}${HINT}${ui.reset}` : '';
1151
1253
  const tokChip = formatTurnTokenChip(status.lastTurnUsage);
1152
1254
  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;
1255
+ // 合并左段:段间留 2 空格分隔。极窄时 hint chip 都可能藏掉。
1256
+ // 优先级:modeTag(必) > chip(提示累计 token,有信息量)> hint(纯说明性,窄时最先省)。
1257
+ const sepMH = modePart && (hintPart || tokChip) ? ' ' : '';
1258
+ const sepHT = (hintPart && tokChip) ? ' ' : '';
1259
+ const leftStr = `${modePart}${sepMH}${hintPart}${sepHT}${tokChip}`;
1260
+ const leftW = modeW
1261
+ + (modePart && (hintPart || tokChip) ? sepMH.length : 0)
1262
+ + hintW
1263
+ + ((hintPart && tokChip) ? sepHT.length : 0)
1264
+ + tokW;
1157
1265
  // 右段:ctx + sep + cwd,右端对齐。cwd 按预算截断,极窄(<6)隐藏。
1158
- // 任一 chip 极宽时收紧 cwd(toolbar 列挤压场景),先从 cwd 砍、再隐藏 cwd、再隐藏 token chip。
1266
+ // 任一 chip 极宽时收紧 cwd(toolbar 列挤压场景),先从 cwd 砍、再隐藏 cwd、再按 hint→chip 顺序省。
1159
1267
  const minGap = 2;
1160
1268
  let cwdBudget = cols - leftW - minGap - ctxW - STATUS_SEP_W - 1;
1161
1269
  let cwd = cwdBudget >= 6 ? truncateDisplay(status.cwd, cwdBudget) : '';
1162
1270
  let cwdW = displayWidth(cwd);
1163
1271
  let rightStr = `${ctx}${STATUS_SEP}${ui.dim}${cwd}${ui.reset}`;
1164
1272
  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);
1273
+ // 极窄:逐步降级——先藏 hint,再藏 token chip,只剩 modeTag 与右段挤。
1274
+ // 这样 80 列宽终端下 hint chip 都能稳住,只 <50 列才退化到只剩 modeTag。
1275
+ if (leftW + minGap + rightW > cols) {
1276
+ // 优先藏 hint(纯说明,信息密度最低):仅留 modePart + sep + tokChip
1277
+ if (modePart && tokChip && hintPart) {
1278
+ const leftStr2 = `${modePart}${sepMH}${tokChip}`;
1279
+ const leftW2 = modeW + sepMH.length + tokW;
1280
+ if (leftW2 + minGap + rightW <= cols) {
1281
+ return twoColumn(leftStr2, leftW2, rightStr, rightW, cols);
1282
+ }
1283
+ }
1284
+ // 再藏 token chip:仅留 modePart
1285
+ if (modePart && tokChip) {
1286
+ return twoColumn(modePart, modeW, rightStr, rightW, cols);
1287
+ }
1168
1288
  }
1169
1289
  return twoColumn(leftStr, leftW, rightStr, rightW, cols);
1170
1290
  }
@@ -1247,13 +1367,18 @@ function startTurnTimer() {
1247
1367
  }, 200);
1248
1368
  turnTimer.unref();
1249
1369
  }
1250
- /** 停走时计时器。enterInputMode / exitAltScreen 调。 */
1251
- function stopTurnTimer() {
1370
+ /** 停走时计时器。enterInputMode / exitAltScreen 调;intervention 面板进入时也调(防 drawStatusBar 200ms 心跳把光标拉到 runningCaretPos,覆盖 paintInput 的正确光标位)。 */
1371
+ export function stopTurnTimer() {
1252
1372
  if (turnTimer) {
1253
1373
  clearInterval(turnTimer);
1254
1374
  turnTimer = null;
1255
1375
  }
1256
1376
  }
1377
+ /** 恢复走时计时器(若 RUNNING 态)。intervention 面板退出时调,恢复状态行走时心跳。 */
1378
+ export function startTurnTimerIfRunning() {
1379
+ if (active && mode === 'running')
1380
+ startTurnTimer();
1381
+ }
1257
1382
  /**
1258
1383
  * 在续写位画一行瞬时活动文本(spinner 帧):不进缓冲、不推进续写位,逐行 clearLine 重画。
1259
1384
  * 仅 TTY + offset=0(实时尾)+ 非打字暂停态时物理写屏;滚动态跳过(由状态行 spinner 兜底,且避免覆盖 viewport 历史行)。
@@ -1494,7 +1619,7 @@ export function paintInput(view) {
1494
1619
  buf += cup(spinnerRow, 1) + esc.clearLine + composeSpinnerLine(status, g.cols);
1495
1620
  buf += cup(modelRow, 1) + esc.clearLine + composeModelLine(status, g.cols);
1496
1621
  // 3b. 上线(输入框顶):满屏宽细线 ─(cyan),框住输入区上边界
1497
- buf += cup(g.contentBottom + 3, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
1622
+ buf += cup(g.contentBottom + 3, 1) + esc.clearLine + ui.accent + '─'.repeat(g.cols) + ui.reset;
1498
1623
  // 4. 输入行(g.contentBottom+4 .. rows-1)——按可视行画,首行带 prompt、其余缩进 promptW
1499
1624
  const firstInputRow = g.contentBottom + 4;
1500
1625
  const inputRowsAvail = g.footerH - 5; // 去掉虚拟空/spinner行/上线/下线/model行,留输入行
@@ -1552,7 +1677,7 @@ export function paintInput(view) {
1552
1677
  buf += cup(r, 1) + esc.clearLine + text;
1553
1678
  }
1554
1679
  // 4b. 下线(输入框底):满屏宽细线 ─(cyan),在 model 行上一行(model 行占屏底 rows)
1555
- buf += cup(g.rows - 1, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
1680
+ buf += cup(g.rows - 1, 1) + esc.clearLine + ui.accent + '─'.repeat(g.cols) + ui.reset;
1556
1681
  // 5. 向上菜单(画在内容区底,底栏正上方)
1557
1682
  if (view.menu && view.menu.lines.length > 0) {
1558
1683
  const menuRows = Math.min(view.menu.lines.length, g.contentBottom);
package/dist/ui/prompt.js CHANGED
@@ -84,7 +84,7 @@ export async function promptWithSlashMenu(opts) {
84
84
  let menuOpen = false;
85
85
  let selected = 0;
86
86
  let filtered = [];
87
- const MENU_MAX_VISIBLE = 5;
87
+ const MENU_MAX_VISIBLE = 7;
88
88
  let menuTop = 0; // 窗口首项在 filtered 中的索引,菜单最多显示 MENU_MAX_VISIBLE 条
89
89
  let resolved = false;
90
90
  let resolve;
@@ -108,8 +108,8 @@ export async function promptWithSlashMenu(opts) {
108
108
  const globalIdx = menuTop + i;
109
109
  // 选中项:▸ 与文字均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
110
110
  const isSel = globalIdx === selected;
111
- const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
112
- const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
111
+ const color = isSel ? `${ui.accent}${ui.bold}` : ui.dim;
112
+ const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
113
113
  const name = padEndDisplay(c.name, maxName);
114
114
  // 首/末附加滚动指示(▲/▼)而非替换整行
115
115
  let desc = c.desc;
@@ -699,8 +699,8 @@ export async function promptTurnPicker(items) {
699
699
  const idx = start + i;
700
700
  // 选中项:▸/序号/正文均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
701
701
  const isSel = idx === selected;
702
- const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
703
- const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
702
+ const color = isSel ? `${ui.accent}${ui.bold}` : ui.dim;
703
+ const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
704
704
  const num = `${color}${idx + 1}${ui.reset}`;
705
705
  const text = truncateDisplay(items[idx].firstLine, cols - 6);
706
706
  return `${marker} ${num} ${color}${text}${ui.reset}`;
@@ -826,8 +826,8 @@ export async function promptSessionPicker(items, recentCap = 10) {
826
826
  const idx = start + i;
827
827
  // 选中项:▸/序号/正文/副标题均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
828
828
  const isSel = idx === selected;
829
- const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
830
- const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
829
+ const color = isSel ? `${ui.accent}${ui.bold}` : ui.dim;
830
+ const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
831
831
  const num = String(idx + 1);
832
832
  const it = vis[idx];
833
833
  const title = it.title || '(无)';
@@ -965,8 +965,8 @@ export async function promptThemePicker(items) {
965
965
  return Array.from({ length: count }, (_, i) => {
966
966
  const idx = start + i;
967
967
  const isSel = idx === selected;
968
- const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
969
- const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
968
+ const color = isSel ? `${ui.accent}${ui.bold}` : ui.dim;
969
+ const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
970
970
  const num = String(idx + 1);
971
971
  const it = items[idx];
972
972
  const title = it.title || '(无)';
@@ -1089,8 +1089,8 @@ export async function promptRevertChoice(fileCount) {
1089
1089
  const cols = layout.getGeo().cols;
1090
1090
  return items.map((text, idx) => {
1091
1091
  const isSel = idx === selected;
1092
- const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
1093
- const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
1092
+ const color = isSel ? `${ui.accent}${ui.bold}` : ui.dim;
1093
+ const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
1094
1094
  const num = `${color}${idx + 1}${ui.reset}`;
1095
1095
  const t = truncateDisplay(text, cols - 6);
1096
1096
  return `${marker} ${num} ${color}${t}${ui.reset}`;
package/dist/ui/render.js CHANGED
@@ -231,42 +231,68 @@ export function wrapByDisplayWidth(text, width) {
231
231
  }
232
232
  const BOX_W = 60; // 内容区显示宽度(logo 区 + 信息区)
233
233
  const MARGIN = ' '; // 盒外左缩进
234
- // ── 坐姿 logo(乌龟风,加双手)──
235
- const LOGO_W = 11; // logo 区显示宽度
236
- const LOGO_GAP = 4; // logo 与信息区之间的间隔
237
- const LOGO_PAD = ' '.repeat(LOGO_W + LOGO_GAP); // logo 行的缩进(对齐信息区)
238
- // 坐着的小人:[●ᴗ●] 头 / | | 身 / ╲| |╱ 双手外撑 / | | 身 / OO 脚
234
+ // ── MoCode 块字符 logo(4 行,neofetch 风)──
235
+ const LOGO_W = 35; // logo 区显示宽度
236
+ const LOGO_GAP = 0; // logo 与信息区之间的间隔
237
+ // 块字符画 4 行:M / o / C / o / d / e,块字符上半/下半合并两个 ASCII 行
239
238
  const LOGO_LINES = [
240
- ' [◕ᴗ◕] ',
241
- ' ●| |● ',
242
- ' OO ',
239
+ '█▀█▀█ █▀▀▀█ █ ',
240
+ ' █ █ █▀▀█ █ █▀▀█ █▀▀█ █▀▀█',
241
+ '█ █ █ █ █ █ ▄ █ █ █ █ █▀▀▀',
242
+ '▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀',
243
243
  ];
244
- /** 取第 idx 行 logo(着色 + 补宽);越界返空格(logo 下方行仍缩进对齐)。 */
244
+ /** 取第 idx 行 logo(着色 + 补宽 + LOGO_GAP 间隔),与 info 行直接拼接(neofetch )。 */
245
245
  function logoLine(idx) {
246
- if (idx >= LOGO_LINES.length)
247
- return LOGO_PAD;
248
- return `${ui.brightCyan}${padEndDisplay(LOGO_LINES[idx], LOGO_W)}${ui.reset}${' '.repeat(LOGO_GAP)}`;
246
+ return `${ui.accent}${padEndDisplay(LOGO_LINES[idx], LOGO_W)}${ui.reset}${' '.repeat(LOGO_GAP)}`;
249
247
  }
250
248
  function labelContent(label, value) {
251
249
  return `${ui.dim}${padEndDisplay(label, 6)}${ui.reset}${value}`;
252
250
  }
251
+ /** 把布尔转成显式中文:banner 上避免「是/否」歧义,用「开/关」。 */
252
+ function memoryLabel(on) {
253
+ return on ? '开启' : '关闭';
254
+ }
255
+ /** 记忆状态染色:开启用 ui.accent(亮),关闭用 ui.dim(弱),让远观有差别。
256
+ * banner 里整体仍走 ui.reset 兜底——染色只贴在值那一列。 */
257
+ function memoryValue(on) {
258
+ return on ? `${ui.accent}${memoryLabel(on)}${ui.reset}` : `${ui.dim}${memoryLabel(on)}${ui.reset}`;
259
+ }
253
260
  /** 横幅纯文本(带 ANSI 颜色,不写出)——供 TUI 经 contentWrite 写入内容区以跟踪续写位。
254
- * 无边框:左侧实心小熊 + 右侧标题/信息(模型/目录),末尾一行提示。 */
261
+ * 布局:大字 logo(4 行,块字符)左对齐,右侧并排放标题/信息(neofetch 风) */
255
262
  export function bannerString(info) {
256
- const title = `${ui.bold}${ui.brightCyan}◆ MoCode${ui.reset} ${ui.dim}v${VERSION}${ui.reset}`;
263
+ const title = `${ui.bold}${ui.accent}◆ MoCode${ui.reset} ${ui.dim}v${VERSION}${ui.reset}`;
264
+ // 信息行 = 标题 + 模型 + 目录 + 记忆(共 4 行,与 4 行 logo 对齐)
257
265
  const rows = [
258
266
  logoLine(0) + title,
259
267
  logoLine(1) + labelContent('模型', info.model),
260
268
  logoLine(2) + labelContent('目录', truncateDisplay(info.cwd, 48)),
269
+ logoLine(3) + labelContent('记忆', memoryValue(info.memoryEnabled)),
261
270
  ];
262
- return (rows.map((r) => MARGIN + r).join('\n') +
263
- '\n\n' +
264
- `${MARGIN}${ui.dim}直接描述任务,agent 会自动读写文件与执行命令。${ui.reset}\n`);
271
+ return rows.map((r) => MARGIN + r).join('\n') + '\n\n';
265
272
  }
266
- /** 启动横幅:小熊 logo + 标题/信息 + 一行提示。纯渲染,不依赖 config / 业务。 */
273
+ /** 启动横幅:大字 logo + 标题/信息。纯渲染,不依赖 config / 业务。 */
267
274
  export function printBanner(info) {
268
275
  stdout.write(bannerString(info));
269
276
  }
277
+ /**
278
+ * 与 bannerString 等价但返**自洽 ANSI 行数组**(每行均以 \x1B[0m 收尾),供 layout.writeBanner
279
+ * 直接灌入 content 缓冲,免去 contentWrite 走字符流再 breakRow 的列宽折行问题。
280
+ *
281
+ * 行集合 = bannerString 解析后的所有物理行。layout 记录此长度作
282
+ * bannerH,rewriteBanner 必须按等长替换(否则 layout 抛错)。
283
+ *
284
+ * 返回前会 normalize:每行若未以 \x1B[0m 收尾则补,统一自洽,避免 setLines / replaceHead
285
+ * 内部 ANSI 状态机把后续 SGR 当 curSgr 残留继承下去。
286
+ */
287
+ export function bannerLines(info) {
288
+ const raw = bannerString(info);
289
+ // bannerString 形如 "行1\n行2\n行3\n行4\n",split('\n') 长度 = 5(末尾 \n 后空串)
290
+ // 行集合真实包含 4 行(4 行 logo+info);filter(Boolean?) 不可 — 空行也要算
291
+ // 仅过滤最后那个 \n 切出的尾空
292
+ const split = raw.split('\n');
293
+ const lines = split.length > 0 && split[split.length - 1] === '' ? split.slice(0, -1) : split;
294
+ return lines.map((s) => (s.endsWith('\x1B[0m') ? s : `${s}\x1B[0m`));
295
+ }
270
296
  // ── 工具调用 / 结果 摘要 ──
271
297
  function tryParse(raw) {
272
298
  try {
@@ -53,7 +53,7 @@ export class Spinner {
53
53
  }
54
54
  render() {
55
55
  const f = FRAMES[this.frame];
56
- stdout.write(`\r${ui.brightMagenta}${f}${ui.reset} ${ui.dim}${this.msg}${ui.reset}\x1B[K`);
56
+ stdout.write(`\r${ui.bold}${ui.accent}${f}${ui.reset} ${ui.dim}${this.msg}${ui.reset}\x1B[K`);
57
57
  }
58
58
  stop() {
59
59
  if (!this.timer)
package/dist/ui/theme.js CHANGED
@@ -28,6 +28,7 @@ const DEFAULT = {
28
28
  gray: '\x1B[38;2;92;99;112m',
29
29
  brightCyan: '\x1B[38;2;86;182;194m',
30
30
  brightMagenta: '\x1B[38;2;198;120;221m',
31
+ accent: '\x1B[38;2;86;182;194m', // 与 cyan 同源(One Dark):logo/标题/输入框顶线/选中项统一承载
31
32
  userBg: '\x1B[48;2;44;49;60m', // One Dark current-line bg,深色终端上微妙可辨
32
33
  // diff 行底色:One Dark bg #282c34 上加 ~14% 亮度的对应色,够辨识但不刺眼
33
34
  addBg: '\x1B[48;2;44;62;42m', // 偏暗绿(One Dark green(152,195,121)暗化)
@@ -51,6 +52,7 @@ const THEMES = {
51
52
  gray: '\x1B[38;2;88;110;117m',
52
53
  brightCyan: '\x1B[38;2;42;161;152m',
53
54
  brightMagenta: '\x1B[38;2;108;113;196m',
55
+ accent: '\x1B[38;2;38;139;210m', // Solarized blue:浅底下更醒目的强调
54
56
  userBg: '\x1B[48;2;238;232;213m',
55
57
  // diff 行底色:Solarized Light base2(238,232,213)上贴同色族浅 tint,
56
58
  // 比直接用 base1 更柔,跟深 fg(red/green)对比充足
@@ -67,6 +69,7 @@ const THEMES = {
67
69
  gray: '\x1B[38;2;88;110;117m',
68
70
  brightCyan: '\x1B[38;2;147;161;161m',
69
71
  brightMagenta: '\x1B[38;2;108;113;196m',
72
+ accent: '\x1B[38;2;42;161;152m', // Solarized cyan(深底版):暗底上跳出
70
73
  userBg: '\x1B[48;2;7;54;66m',
71
74
  // diff 行底色:Solarized Dark base03(0,43,54)上加对应色族暗 tint
72
75
  addBg: '\x1B[48;2;20;50;38m',
@@ -82,6 +85,7 @@ const THEMES = {
82
85
  gray: '\x1B[38;2;146;131;116m',
83
86
  brightCyan: '\x1B[38;2;142;192;124m',
84
87
  brightMagenta: '\x1B[38;2;211;134;155m',
88
+ accent: '\x1B[38;2;250;189;47m', // Gruvbox yellow(主题色)
85
89
  userBg: '\x1B[48;2;40;40;40m',
86
90
  // diff 行底色:Gruvbox dark bg(40,40,40)上贴暗 bg0_a 风格
87
91
  addBg: '\x1B[48;2;40;55;30m',
@@ -97,11 +101,121 @@ const THEMES = {
97
101
  gray: '\x1B[38;2;76;86;106m',
98
102
  brightCyan: '\x1B[38;2;136;192;208m',
99
103
  brightMagenta: '\x1B[38;2;180;142;173m',
104
+ accent: '\x1B[38;2;136;192;208m', // Nord 浅冰蓝(主题色)
100
105
  userBg: '\x1B[48;2;67;76;94m',
101
106
  // diff 行底色:Nord polar night(46,52,64)上贴对应色族暗 tint
102
107
  addBg: '\x1B[48;2;46;66;52m',
103
108
  delBg: '\x1B[48;2;72;46;52m',
104
109
  },
110
+ orange: {
111
+ // 秋季/南瓜橙:深暖底(#231c16 系)+ accent 主强调走橙色;yellow 槽也偏橙
112
+ // 保持调性一致(标题、菜单等关键 UI 元素走 accent);red 取暖珊瑚避免与
113
+ // 橙黄撞色;蓝/紫/青保留冷色锚点维持多色辨识度。
114
+ red: '\x1B[38;2;255;105;90m', // 暖珊瑚红
115
+ green: '\x1B[38;2;150;200;115m', // 黄绿
116
+ yellow: '\x1B[38;2;255;170;60m', // 南瓜橙(主题色)
117
+ blue: '\x1B[38;2;120;170;230m', // 冷蓝锚点
118
+ magenta: '\x1B[38;2;225;130;195m', // 暖粉紫
119
+ cyan: '\x1B[38;2;110;195;195m', // 雾青
120
+ gray: '\x1B[38;2;150;130;110m', // 暖灰
121
+ brightCyan: '\x1B[38;2;160;220;220m',
122
+ brightMagenta: '\x1B[38;2;245;165;215m',
123
+ accent: '\x1B[38;2;255;170;60m', // 南瓜橙(主题主色:logo/标题/输入框顶线/选中项)
124
+ userBg: '\x1B[48;2;55;42;32m', // 暖深棕(略亮于 #231c16 背景)
125
+ // diff 行底色:暖深棕底上贴对应色族暗 tint,跟 fg 配对柔和可辨
126
+ addBg: '\x1B[48;2;55;70;35m',
127
+ delBg: '\x1B[48;2;78;42;32m',
128
+ },
129
+ rose: {
130
+ // 玫红:深紫底 + accent 玫粉作主强调;magenta 槽与 accent 同源(主题色),
131
+ // red 偏暖玫;蓝/青保留冷色锚点维持多色辨识度。
132
+ red: '\x1B[38;2;240;100;120m', // 暖玫红
133
+ green: '\x1B[38;2;160;200;130m', // 黄绿
134
+ yellow: '\x1B[38;2;240;180;140m', // 暖杏
135
+ blue: '\x1B[38;2;130;160;230m', // 冷蓝锚点
136
+ magenta: '\x1B[38;2;230;90;170m', // 主玫红(主题色)
137
+ cyan: '\x1B[38;2;120;200;200m', // 雾青
138
+ gray: '\x1B[38;2;160;140;150m', // 暖灰紫
139
+ brightCyan: '\x1B[38;2;170;220;220m',
140
+ brightMagenta: '\x1B[38;2;250;160;200m',
141
+ accent: '\x1B[38;2;230;90;150m', // 玫粉(主题主色)
142
+ userBg: '\x1B[48;2;50;32;46m', // 深紫底
143
+ // diff 行底色:深紫底贴对应色族暗 tint,绿暗化偏橄榄、红暗化偏紫红
144
+ addBg: '\x1B[48;2;50;60;42m',
145
+ delBg: '\x1B[48;2;75;40;52m',
146
+ },
147
+ emerald: {
148
+ // 翡翠绿:深绿底 + accent 翡翠绿作主强调;green 槽与 accent 同源(主题色),
149
+ // cyan 偏青绿;蓝/紫保留冷色锚点,red/yellow 保留暖色警示色。
150
+ red: '\x1B[38;2;235;110;110m', // 暖红(警示色)
151
+ green: '\x1B[38;2;80;210;140m', // 主翡翠绿(主题色)
152
+ yellow: '\x1B[38;2;230;200;100m', // 暖黄
153
+ blue: '\x1B[38;2;100;180;210m', // 冷青蓝
154
+ magenta: '\x1B[38;2;190;140;210m', // 冷紫
155
+ cyan: '\x1B[38;2;100;210;190m', // 青绿
156
+ gray: '\x1B[38;2;110;140;130m', // 冷绿灰
157
+ brightCyan: '\x1B[38;2;140;230;210m',
158
+ brightMagenta: '\x1B[38;2;210;170;230m',
159
+ accent: '\x1B[38;2;80;210;140m', // 翡翠绿(主题主色)
160
+ userBg: '\x1B[48;2;18;42;34m', // 深绿底
161
+ // diff 行底色:深绿底贴对应色族暗 tint,绿暗化偏深绿、红暗化偏暗红
162
+ addBg: '\x1B[48;2;30;55;40m',
163
+ delBg: '\x1B[48;2;60;40;40m',
164
+ },
165
+ amber: {
166
+ // 琥珀金黄:深棕底 + accent 琥珀金黄作主强调;yellow 槽与 accent 同源(主题色),
167
+ // 跟 orange 区分(orange 南瓜橙偏橙红,amber 琥珀偏金黄);蓝/紫/青保留冷色锚点。
168
+ red: '\x1B[38;2;230;120;90m', // 暖橙红(警示色)
169
+ green: '\x1B[38;2;180;180;80m', // 黄绿
170
+ yellow: '\x1B[38;2;255;200;80m', // 主琥珀金黄(主题色)
171
+ blue: '\x1B[38;2;120;160;200m', // 冷蓝
172
+ magenta: '\x1B[38;2;220;140;180m', // 暖粉
173
+ cyan: '\x1B[38;2;130;180;170m', // 雾青绿
174
+ gray: '\x1B[38;2;150;130;100m', // 暖灰
175
+ brightCyan: '\x1B[38;2;170;210;200m',
176
+ brightMagenta: '\x1B[38;2;240;180;210m',
177
+ accent: '\x1B[38;2;255;200;80m', // 琥珀金黄(主题主色)
178
+ userBg: '\x1B[48;2;43;31;14m', // 深棕底(比 orange 略浅,避免撞色)
179
+ // diff 行底色:深棕底贴对应色族暗 tint,绿暗化偏橄榄、红暗化偏暗棕红
180
+ addBg: '\x1B[48;2;50;55;25m',
181
+ delBg: '\x1B[48;2;70;40;28m',
182
+ },
183
+ lavender: {
184
+ // 薰衣草淡紫:深紫底 + accent 淡紫作主强调;magenta 槽与 accent 同源(主题色),
185
+ // blue 偏冷蓝锚点(略带紫调);red/yellow 保留暖色警示色。
186
+ red: '\x1B[38;2;230;130;150m', // 暖粉红(警示色)
187
+ green: '\x1B[38;2;150;200;140m', // 黄绿
188
+ yellow: '\x1B[38;2;230;200;140m', // 暖杏
189
+ blue: '\x1B[38;2;140;160;230m', // 冷蓝锚点(略带紫)
190
+ magenta: '\x1B[38;2;180;150;230m', // 主薰衣草紫(主题色)
191
+ cyan: '\x1B[38;2;140;210;210m', // 雾青
192
+ gray: '\x1B[38;2;140;130;160m', // 冷灰紫
193
+ brightCyan: '\x1B[38;2;180;230;230m',
194
+ brightMagenta: '\x1B[38;2;210;180;250m',
195
+ accent: '\x1B[38;2;180;150;230m', // 薰衣草紫(主题主色)
196
+ userBg: '\x1B[48;2;31;26;46m', // 深紫底(比 rose 更冷)
197
+ // diff 行底色:深紫底贴对应色族暗 tint,绿暗化偏冷绿、红暗化偏冷紫红
198
+ addBg: '\x1B[48;2;38;46;42m',
199
+ delBg: '\x1B[48;2;60;40;55m',
200
+ },
201
+ sunset: {
202
+ // 日落珊瑚:深棕红底 + accent 珊瑚红作主强调;red 槽与 accent 同源(主题色);
203
+ // 跟 orange 区分(orange 南瓜橙偏橙黄,sunset 珊瑚偏红);蓝/青保留冷色锚点。
204
+ red: '\x1B[38;2;255;120;100m', // 主珊瑚红(主题色)
205
+ green: '\x1B[38;2;180;200;110m', // 黄绿
206
+ yellow: '\x1B[38;2;255;170;80m', // 暖橙黄
207
+ blue: '\x1B[38;2;110;170;220m', // 冷蓝锚点
208
+ magenta: '\x1B[38;2;230;120;180m', // 玫红
209
+ cyan: '\x1B[38;2;120;200;190m', // 雾青
210
+ gray: '\x1B[38;2;160;120;110m', // 暖灰
211
+ brightCyan: '\x1B[38;2;170;225;215m',
212
+ brightMagenta: '\x1B[38;2;250;170;210m',
213
+ accent: '\x1B[38;2;255;120;100m', // 珊瑚红(主题主色)
214
+ userBg: '\x1B[48;2;42;22;20m', // 深棕红底
215
+ // diff 行底色:深棕红底贴对应色族暗 tint,绿暗化偏橄榄、红暗化偏暗棕红
216
+ addBg: '\x1B[48;2;50;55;30m',
217
+ delBg: '\x1B[48;2;75;32;30m',
218
+ },
105
219
  };
106
220
  let currentName = 'default';
107
221
  let version = 0;
@@ -168,6 +282,9 @@ export const ui = {
168
282
  get brightMagenta() {
169
283
  return color('brightMagenta');
170
284
  },
285
+ get accent() {
286
+ return color('accent');
287
+ },
171
288
  get userBg() {
172
289
  return color('userBg');
173
290
  },