mocode-ai 0.4.5 → 0.4.7

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
@@ -49,6 +49,9 @@ let selection = null;
49
49
  let selecting = false; // 左键按下中(press→release 之间)
50
50
  let mouseEnabled = true; // 导航菜单(picker)期间置 false:只吞报表不做选区/滚动,防菜单被 viewport 重画覆盖
51
51
  const WHEEL_LINES = 3; // 滚轮每格滚动行数
52
+ let inputSelection = null;
53
+ /** 上次画过输入框的 lastView 签名(lines 数 + 每行 display_w 累加);变了说明 prompt.ts 改了文本 → 选区锚点失效,清掉。 */
54
+ let lastInputSig = '';
52
55
  // 点击输入框粘贴:当前文本输入方(prompt.ts / repl 运行态 typeahead)注册回调,接收剪贴板文本贴入。
53
56
  // 只在输入区(底栏输入行)内右键单击(未拖动)时触发——与内容区选区互不干扰(内容区左键=框选,右键=复制)。
54
57
  let pasteHandler = null;
@@ -618,9 +621,15 @@ export function scrollBy(delta) {
618
621
  }
619
622
  /** 清活跃选区(不复制),若原有选区则重画去掉反白。供 ESC / 点击别处 / 退出滚动态等场景调。 */
620
623
  export function clearSelection() {
621
- if (!selection)
624
+ if (!selection && !inputSelection)
622
625
  return;
623
626
  selection = null;
627
+ if (inputSelection) {
628
+ inputSelection = null;
629
+ if (active && base && lastView)
630
+ paintInput(lastView);
631
+ return;
632
+ }
624
633
  if (scrollOffset >= 0)
625
634
  repaintViewport();
626
635
  }
@@ -648,6 +657,11 @@ export function setMouseEnabled(v) {
648
657
  selection = null;
649
658
  repaintViewport();
650
659
  }
660
+ if (inputSelection) {
661
+ inputSelection = null;
662
+ if (active && base && lastView)
663
+ paintInput(lastView);
664
+ }
651
665
  }
652
666
  }
653
667
  /** 从归一化选区抠出纯文本(去 ANSI,按显示列裁切,行间 \n 拼接)。越界行跳过(缓冲被 trim 等边界情况)。 */
@@ -665,6 +679,47 @@ function extractSelectionText(sel) {
665
679
  }
666
680
  return out.join('\n');
667
681
  }
682
+ /** 输入框选区归一化:按 (line, col) 字典序排成 start/end。无选区返 null。 */
683
+ function normalizeInputSelection() {
684
+ if (!inputSelection)
685
+ return null;
686
+ const a = inputSelection.anchor;
687
+ const b = inputSelection.end;
688
+ const aFirst = a.line < b.line || (a.line === b.line && a.col <= b.col);
689
+ return aFirst
690
+ ? { startLine: a.line, startCol: a.col, endLine: b.line, endCol: b.col }
691
+ : { startLine: b.line, startCol: b.col, endLine: a.line, endCol: a.col };
692
+ }
693
+ /** 从归一化输入框选区抠出纯文本(lines 上不带 ANSI,直接按 display_col 切)。越界行跳到该行末尾。 */
694
+ function extractInputSelectionText(sel) {
695
+ if (!lastView)
696
+ return '';
697
+ const out = [];
698
+ for (let l = sel.startLine; l <= sel.endLine; l++) {
699
+ const raw = lastView.lines[l];
700
+ if (raw == null)
701
+ continue;
702
+ const lineW = displayWidth(raw);
703
+ const colStart = l === sel.startLine ? sel.startCol : 0;
704
+ const colEnd = l === sel.endLine ? sel.endCol : lineW;
705
+ out.push(sliceByDisplayCol(raw, colStart, colEnd));
706
+ }
707
+ return out.join('\n');
708
+ }
709
+ /** 输入框 lastView 签名:lines 数 + 每行 display_w 累加;任何键改动 lines 即变化,用作 inputSelection 失效判据。 */
710
+ function inputViewSig(view) {
711
+ if (!view)
712
+ return '';
713
+ return view.lines.length + '|' + view.lines.map((l) => displayWidth(l).toString()).join(',');
714
+ }
715
+ /** 清输入框反白选区并重画(若 active)。不复制,仅清。供 clearSelection / setMouseEnabled 复用。 */
716
+ function clearInputSelection() {
717
+ if (!inputSelection)
718
+ return;
719
+ inputSelection = null;
720
+ if (active && base && lastView)
721
+ paintInput(lastView);
722
+ }
668
723
  /** 屏行是否落在底栏输入行范围内(paintInput 的 firstInputRow..firstInputRow+inputRowsAvail-1)。 */
669
724
  function isInputRow(row) {
670
725
  const g = getGeo();
@@ -706,6 +761,33 @@ function setInputCursorFromClick(screenRow, screenCol) {
706
761
  if (mode !== 'input') {
707
762
  enterInputMode(statusText);
708
763
  }
764
+ const pos = inputScreenToInputPos(screenRow, screenCol);
765
+ if (!pos) {
766
+ paintInput(lastView);
767
+ return;
768
+ }
769
+ // 三步原子:
770
+ // 1) lastView.cursorLine = targetLine — view.cursorLine 单位 = lines 行号 in dispLines(供 paintInput)
771
+ // 2) lastView.cursorCol = targetDisplayCol — view.cursorCol 单位 = display_col(供 paintInput,
772
+ // paintInput 内部 wrap/flat 后用 cursorCol 算 visLine + cursorVisCol)
773
+ // 3) paintInput 重画——末步 cup 把真光标移到点击位的可视位置
774
+ // 4) cursorChangeHandler(dispatchFlatIdx, dispatchInSegVis) — 给 prompt 的"屏 tap 原坐标",
775
+ // prompt 自己重做 flat 算 cl/cc(扣 chip prefix,与 layout 端的 stamp 时 dispLines 一致即可)。
776
+ // 未注册 handler(非输入态)→ 仅步骤 3 可见真光标移动;步骤 4 不动 prompt 的 cl/cc。
777
+ lastView = { ...lastView, cursorLine: pos.line, cursorCol: pos.displayCol };
778
+ paintInput(lastView);
779
+ if (cursorChangeHandler)
780
+ cursorChangeHandler(pos.flatIdx, pos.inSegVis);
781
+ }
782
+ /**
783
+ * 把屏(行,列)反推到 (lines 行号, 行内 display_col, flatIdx, 段内显示列),
784
+ * 算法与 setInputCursorFromClick 抽出前等价——基于 lastView 的"折行 + 滚动窗"几何,
785
+ * 与 paintInput 的 windowInputVis 同源(startVis 同锚),保证 press/drag 落点 = 画上高亮位置。
786
+ * 越界点(空行)→ 落末行末段末。
787
+ */
788
+ function inputScreenToInputPos(screenRow, screenCol) {
789
+ if (!lastView)
790
+ return null;
709
791
  const g = getGeo();
710
792
  const promptW = displayWidth(lastView.prompt);
711
793
  const firstInputRow = g.contentBottom + 4;
@@ -717,7 +799,7 @@ function setInputCursorFromClick(screenRow, screenCol) {
717
799
  const visCol = Math.max(0, screenCol - 1 - promptW);
718
800
  // 复刻 windowInputVis 的折行 + 滚动窗(输入态, lines 全量参与)。
719
801
  // lastView.lines 在 prompt 的 redraw() 中为 dispLines()(含 chip pre 行 / chip prefix 列);
720
- // chip 模式下点击 chip 区域也走同一算法,targetCharCol paintInput 的视协议(段接缝除外)一致。
802
+ // chip 模式下点击 chip 区域也走同一算法,与 paintInput 的视协议(段接缝除外)一致。
721
803
  const cols = Math.max(1, g.cols - promptW);
722
804
  const lineVis = lastView.lines.map((l) => wrapByDisplayWidth(l, cols));
723
805
  const flat = [];
@@ -726,7 +808,6 @@ function setInputCursorFromClick(screenRow, screenCol) {
726
808
  flat.push(r);
727
809
  const totalVis = flat.length;
728
810
  const maxInputRows = Math.max(1, Math.floor(g.rows * 0.4));
729
- const showCount = Math.min(maxInputRows, totalVis);
730
811
  // 起窗偏移:用 lastView 当前光标位置作为锚(与 paintInput 同算法)。
731
812
  let curVisLine = 0;
732
813
  {
@@ -748,10 +829,6 @@ function setInputCursorFromClick(screenRow, screenCol) {
748
829
  const startVis = totalVis > maxInputRows
749
830
  ? Math.max(0, Math.min(curAbs - maxInputRows + 1, totalVis - maxInputRows))
750
831
  : 0;
751
- let targetLine;
752
- let targetDisplayCol;
753
- let dispatchFlatIdx; // 传给 prompt 的屏 tap 原坐标(flat 中段号);超尾点边界用 totalVis - 1
754
- let dispatchInSegVis; // 段内显示列
755
832
  // 点到可视区末行之下(空行)→ 等价"光标在最末可视行的字符串末尾"。
756
833
  if (startVis + visRow >= totalVis) {
757
834
  const lastFlatIdx = totalVis - 1;
@@ -766,55 +843,44 @@ function setInputCursorFromClick(screenRow, screenCol) {
766
843
  lastLine = lineVis.length - 1;
767
844
  lastSegInLine = lineVis[lastLine].length - 1;
768
845
  }
769
- // view.cursorCol 单位 = lines[lastLine] 内 display_col
846
+ // lines[lastLine] 内 display_col = 前 segInLine 段 display_w + 末段 display_w
770
847
  let inLineDisplayCol = 0;
771
848
  for (let s = 0; s <= lastSegInLine; s++)
772
849
  inLineDisplayCol += displayWidth(lineVis[lastLine][s] ?? '');
773
- targetLine = lastLine;
774
- targetDisplayCol = inLineDisplayCol; // 含末段段末 = 整 lines[lastLine] 的 display_w
775
- dispatchFlatIdx = lastFlatIdx;
776
- dispatchInSegVis = displayWidth(flat[lastFlatIdx]); // 段末
777
- }
778
- else {
779
- // flatIdx = 点击可视段在 flat 中的绝对索引(startVis + visRow)
780
- const flatIdx = startVis + visRow;
781
- // flatIdx 反推到 (line, segInLine) — line 给 paintInput 用(同 lines[cursorLine] 行号),
782
- // segInLine 给 prompt cl/cc 反推用。
783
- let line = 0;
784
- let segInLine = flatIdx;
785
- while (line < lineVis.length && segInLine >= lineVis[line].length) {
786
- segInLine -= lineVis[line].length;
787
- line++;
788
- }
789
- if (line >= lineVis.length) {
790
- line = lineVis.length - 1;
791
- segInLine = lineVis[line].length - 1;
792
- }
793
- // view.cursorCol 单位 = lines[line] 内 display_col(windowInputVis 只在该行累加,跨行不算)。
794
- // 即 lineVis[line] 前 segInLine 段 display_w 累加 + 段内显示列。
795
- let inLineDisplayCol = 0;
796
- for (let s = 0; s < segInLine; s++)
797
- inLineDisplayCol += displayWidth(lineVis[line][s] ?? '');
798
- const seg = flat[flatIdx];
799
- const segW = displayWidth(seg);
800
- const inSeg = Math.min(visCol, segW); // 段尾点击容许越界 clip 到段末
801
- targetLine = line;
802
- targetDisplayCol = inLineDisplayCol + inSeg;
803
- dispatchFlatIdx = flatIdx;
804
- dispatchInSegVis = inSeg;
805
- }
806
- // 三步原子:
807
- // 1) lastView.cursorLine = targetLine — view.cursorLine 单位 = lines 行号 in dispLines(供 paintInput)
808
- // 2) lastView.cursorCol = targetDisplayCol — view.cursorCol 单位 = display_col(供 paintInput,
809
- // paintInput 内部 wrap/flat 后用 cursorCol 算 visLine + cursorVisCol)
810
- // 3) paintInput 重画——末步 cup 把真光标移到点击位的可视位置
811
- // 4) cursorChangeHandler(dispatchFlatIdx, dispatchInSegVis) — 给 prompt 的"屏 tap 原坐标",
812
- // prompt 自己重做 flat 算 cl/cc(扣 chip prefix,与 layout 端的 stamp 时 dispLines 一致即可)。
813
- // 未注册 handler(非输入态)→ 仅步骤 3 可见真光标移动;步骤 4 不动 prompt 的 cl/cc。
814
- lastView = { ...lastView, cursorLine: targetLine, cursorCol: targetDisplayCol };
815
- paintInput(lastView);
816
- if (cursorChangeHandler)
817
- cursorChangeHandler(dispatchFlatIdx, dispatchInSegVis);
850
+ return {
851
+ line: lastLine,
852
+ displayCol: inLineDisplayCol,
853
+ flatIdx: lastFlatIdx,
854
+ inSegVis: displayWidth(flat[lastFlatIdx]), // 段末
855
+ };
856
+ }
857
+ // flatIdx = 点击可视段在 flat 中的绝对索引(startVis + visRow)
858
+ const flatIdx = startVis + visRow;
859
+ // 把 flatIdx 反推到 (line, segInLine) — line paintInput 用(同 lines[cursorLine] 行号),
860
+ // segInLine prompt 做 cl/cc 反推用。
861
+ let line = 0;
862
+ let segInLine = flatIdx;
863
+ while (line < lineVis.length && segInLine >= lineVis[line].length) {
864
+ segInLine -= lineVis[line].length;
865
+ line++;
866
+ }
867
+ if (line >= lineVis.length) {
868
+ line = lineVis.length - 1;
869
+ segInLine = lineVis[line].length - 1;
870
+ }
871
+ // lines[line] 内 display_col = 前 segInLine 段 display_w 累加 + 段内显示列。
872
+ let inLineDisplayCol = 0;
873
+ for (let s = 0; s < segInLine; s++)
874
+ inLineDisplayCol += displayWidth(lineVis[line][s] ?? '');
875
+ const seg = flat[flatIdx];
876
+ const segW = displayWidth(seg);
877
+ const inSeg = Math.min(visCol, segW); // 段尾点击容许越界 clip 到段末
878
+ return {
879
+ line,
880
+ displayCol: inLineDisplayCol + inSeg,
881
+ flatIdx,
882
+ inSegVis: inSeg,
883
+ };
818
884
  }
819
885
  /**
820
886
  * 鼠标事件分发(mouse.setHandler 注册)。
@@ -840,9 +906,20 @@ function handleMouseEvent(e) {
840
906
  return;
841
907
  const g = getGeo();
842
908
  const col = Math.max(0, e.col - 1); // SGR 报表列 1-based → 显示列 0-based
843
- // 右键释放:落输入行 → 贴入;落内容区复制当前选区后清空(去掉高亮),不可重复右键复制同一选区。
909
+ // 右键释放:落输入行 → 优先复制当前输入框选区(若有),退化为贴入;落内容区复制选区后清高亮。
844
910
  if (e.type === 'release' && e.button === 2) {
845
911
  if (isInputRow(e.row)) {
912
+ // 输入框选区存在且真拖动过:复制(不动 text 光标,不改 cl/cc),与内容区"copy 后清高亮"对齐
913
+ const inpSel = normalizeInputSelection();
914
+ if (inpSel) {
915
+ const text = extractInputSelectionText(inpSel);
916
+ inputSelection = null;
917
+ if (lastView)
918
+ paintInput(lastView); // 立刻擦反白,视觉反馈
919
+ if (text)
920
+ copyToClipboard(text);
921
+ return;
922
+ }
846
923
  pasteIntoInput();
847
924
  return;
848
925
  }
@@ -861,20 +938,48 @@ function handleMouseEvent(e) {
861
938
  return; // 其余中/右键 press/drag 不处理(终端原生右键菜单等不受影响)
862
939
  if (e.type === 'press') {
863
940
  if (isInputRow(e.row)) {
864
- // 左键落输入行:进输入态 + 光标定位到点击位 + 重画。
865
- // 输入框内不再做选区(避免和"在该处开始打字"的直觉冲突;右键 release 仍调 pasteIntoInput)。
941
+ // 左键落输入行:进输入态 + 光标定位到点击位 + 开输入框反白选区(起点 = 终点 = 点击位)。
942
+ // 不再 return——后续 drag/release 分支也要知道选区在输入框。点击未拖动时 release 端会清掉选区,
943
+ // 与"点一下就定位光标、起手打字"的习惯一致。
866
944
  setInputCursorFromClick(e.row, e.col);
945
+ const pos = inputScreenToInputPos(e.row, e.col);
946
+ if (pos) {
947
+ // 清掉内容区旧选区(若有):按一下输入框不应同时保留内容区反白,视觉混淆。
948
+ if (selection) {
949
+ selection = null;
950
+ }
951
+ inputSelection = {
952
+ anchor: { line: pos.line, col: pos.displayCol },
953
+ end: { line: pos.line, col: pos.displayCol },
954
+ dragged: false,
955
+ };
956
+ paintInput(lastView);
957
+ }
867
958
  return;
868
959
  }
869
960
  selecting = true;
870
961
  const rowInContent = Math.max(1, Math.min(e.row, g.contentBottom));
871
962
  const absLine = screenRowToAbsLine(rowInContent);
872
963
  selection = { anchorLine: absLine, anchorCol: col, endLine: absLine, endCol: col, dragged: false };
964
+ // 切内容区选区时清掉输入框旧选区(若有)。
965
+ inputSelection = null;
873
966
  repaintViewport();
874
967
  repaint(); // 补一次输入区重画:INPUT 态 repaintViewport 把真光标留在内容区续写位,须靠 repaint 把它带回输入框(否则点内容区会现假闪烁光标,见 issue)
875
968
  return;
876
969
  }
877
970
  if (e.type === 'drag') {
971
+ if (inputSelection) {
972
+ // 输入框拖动选区:更新 end,触边不动 scroll(输入框行数有限,无外滚概念);重画底栏。
973
+ const pos = inputScreenToInputPos(e.row, e.col);
974
+ if (pos) {
975
+ if (pos.line !== inputSelection.end.line || pos.displayCol !== inputSelection.end.col) {
976
+ inputSelection.dragged = true;
977
+ inputSelection.end = { line: pos.line, col: pos.displayCol };
978
+ }
979
+ paintInput(lastView);
980
+ }
981
+ return;
982
+ }
878
983
  if (!selecting || !selection)
879
984
  return;
880
985
  // 触边自动翻页:motion 事件持续到达时靠此逐步滚动,把选区扩展到滚出去的历史行。
@@ -894,6 +999,16 @@ function handleMouseEvent(e) {
894
999
  }
895
1000
  // release(左键)
896
1001
  selecting = false;
1002
+ // 输入框 release 优先:未拖动 → 清选区(光标已定好);拖动过 → 留高亮等右键复制
1003
+ if (inputSelection) {
1004
+ if (!inputSelection.dragged) {
1005
+ inputSelection = null;
1006
+ if (lastView)
1007
+ paintInput(lastView);
1008
+ }
1009
+ // 拖动过:不操作,留高亮
1010
+ return;
1011
+ }
897
1012
  if (!selection)
898
1013
  return;
899
1014
  if (!selection.dragged) {
@@ -1261,6 +1376,38 @@ function windowInputVis(lines, cursorLine, cursorCol, cols, promptW, rows) {
1261
1376
  startVis,
1262
1377
  };
1263
1378
  }
1379
+ /** 把 lines 按 W 做软折,返回 lineVis;供 paintInput 高亮预算用。与 windowInputVis 复用 wrap 算法。 */
1380
+ function visInputLines(lines, W) {
1381
+ return lines.map((l) => wrapByDisplayWidth(l, W));
1382
+ }
1383
+ /**
1384
+ * 给纯文本(无 ANSI,来自 wrap 后的可视段)做"显示列区间"高亮,返回插入 SEL_OPEN/SEL_OFF 后的字符串。
1385
+ * 输入字段 vis 段宽 = displayWidth(seg),clipStart/clipEnd 单位 = display_col。
1386
+ * 与 highlightRange 等价语义但省去 ANSI split(纯文本无 SGR),更省。
1387
+ */
1388
+ function highlightWithinRow(seg, clipStart, clipEnd) {
1389
+ if (clipEnd <= clipStart)
1390
+ return seg;
1391
+ let out = '';
1392
+ let w = 0;
1393
+ let opened = false;
1394
+ for (const ch of seg) {
1395
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
1396
+ if (!opened && w >= clipStart && w < clipEnd) {
1397
+ out += SEL_OPEN;
1398
+ opened = true;
1399
+ }
1400
+ if (opened && w >= clipEnd) {
1401
+ out += SEL_OFF;
1402
+ opened = false;
1403
+ }
1404
+ out += ch;
1405
+ w += cw;
1406
+ }
1407
+ if (opened)
1408
+ out += SEL_OFF;
1409
+ return out;
1410
+ }
1264
1411
  /**
1265
1412
  * 画输入区:擦旧菜单 → (必要时)setRegion → 画状态行 + 输入行 + 向上菜单,光标留输入框(dim 时回续写位)。
1266
1413
  * prompt.ts 每次按键调;enterInputMode / enterRunningMode 也调(空 / dim)。
@@ -1269,6 +1416,18 @@ export function paintInput(view) {
1269
1416
  if (!active || !base)
1270
1417
  return;
1271
1418
  lastView = view;
1419
+ // 失效检测:lastView 文本若有变化(键输入/AI 续写)→ 选区锚点不再有效,清掉避免「错位反白」。
1420
+ // 内容区选区用 content 自己的锚,与 lastView 无关,不动。
1421
+ if (inputSelection) {
1422
+ const sig = inputViewSig(view);
1423
+ if (sig !== lastInputSig) {
1424
+ inputSelection = null;
1425
+ }
1426
+ lastInputSig = sig;
1427
+ }
1428
+ else {
1429
+ lastInputSig = inputViewSig(view);
1430
+ }
1272
1431
  const preGeo = getGeo();
1273
1432
  // 累积本帧所有 cup/clearLine/文本,末尾一次写出——避免「擦旧菜单」与「重画」分多次 write 时,
1274
1433
  // 终端把中间空白渲染出来 → 菜单/面板切换时闪烁(↑↓ 导航每次 redraw 都全帧擦后重画)。
@@ -1333,13 +1492,53 @@ export function paintInput(view) {
1333
1492
  // 光标:不画反白块/假光标——输入框走终端真光标(WT / VSCode 终端默认竖线/闪烁块,
1334
1493
  // 各终端表现略不同但都贴合 IME 候选气泡且不再"挡住字符")。真光标位置由下方第 6 步 cup 写。
1335
1494
  // 保留 view.caret=false 路径给 picker 等非文本输入:把真光标定位到 hint 末尾。
1495
+ const inpSel = normalizeInputSelection(); // 已签名检测失效,这里看到的要么有效要么 null
1496
+ // 预算 (visRow -> line) 用于高亮:与 inputScreenToInputPos 的反推同源(flat → line + segInLine)
1497
+ const colW = Math.max(1, preGeo.cols - promptW);
1498
+ const lineVisArr = visInputLines(view.lines, colW);
1499
+ const flatInput = [];
1500
+ for (const lv of lineVisArr)
1501
+ for (const r of lv)
1502
+ flatInput.push(r);
1503
+ let cumLine = 0;
1504
+ const flatToLine = [];
1505
+ for (let li = 0; li < lineVisArr.length; li++) {
1506
+ const segs = lineVisArr[li];
1507
+ let acc = 0;
1508
+ for (let s = 0; s < segs.length; s++) {
1509
+ flatToLine.push({ line: li, segInLine: s, inLineDisplayCol: acc });
1510
+ acc += displayWidth(segs[s]);
1511
+ }
1512
+ if (segs.length === 0)
1513
+ flatToLine.push({ line: li, segInLine: 0, inLineDisplayCol: 0 });
1514
+ }
1336
1515
  for (let i = 0; i < inputRowsAvail; i++) {
1337
1516
  const line = vis.visRows[i] ?? '';
1338
1517
  const r = firstInputRow + i;
1339
1518
  const prefix = vis.startVis === 0 && i === 0 ? view.prompt : indent;
1340
- const text = view.dim
1341
- ? renderDimInputRow(view.prompt, line, view.placeholder ?? '', g.cols)
1342
- : `${prefix}${line}`;
1519
+ let text;
1520
+ if (view.dim) {
1521
+ text = renderDimInputRow(view.prompt, line, view.placeholder ?? '', g.cols);
1522
+ }
1523
+ else {
1524
+ text = `${prefix}${line}`;
1525
+ // 输入框反白叠层:仅当 paintInput 不是 dim(运行态 typeahead 不参与选区,避免干扰 IME 气泡),
1526
+ // 且该可视段所属的逻辑行落在 inpSel 区间内 → 高亮行内 [colStart,colEnd) 段。
1527
+ if (inpSel) {
1528
+ const flatIdx = vis.startVis + i;
1529
+ const m = flatIdx < flatToLine.length ? flatToLine[flatIdx] : flatToLine[flatToLine.length - 1];
1530
+ if (m.line >= inpSel.startLine && m.line <= inpSel.endLine) {
1531
+ const seg = flatInput[flatIdx] ?? '';
1532
+ const segW = displayWidth(seg);
1533
+ const colStart = m.line === inpSel.startLine ? inpSel.startCol - m.inLineDisplayCol : 0;
1534
+ const colEnd = m.line === inpSel.endLine ? inpSel.endCol - m.inLineDisplayCol : segW;
1535
+ if (colStart < segW && colEnd > colStart) {
1536
+ // 对 seg(纯文本无 ANSI)做行内高亮:在 [clipStart, clipEnd) 之间插 SEL_OPEN/SEL_OFF。
1537
+ text = `${prefix}${highlightWithinRow(seg, Math.max(0, colStart), Math.min(segW, colEnd))}`;
1538
+ }
1539
+ }
1540
+ }
1541
+ }
1343
1542
  buf += cup(r, 1) + esc.clearLine + text;
1344
1543
  }
1345
1544
  // 4b. 下线(输入框底):满屏宽细线 ─(cyan),在 model 行上一行(model 行占屏底 rows)
package/dist/ui/theme.js CHANGED
@@ -29,6 +29,9 @@ const DEFAULT = {
29
29
  brightCyan: '\x1B[38;2;86;182;194m',
30
30
  brightMagenta: '\x1B[38;2;198;120;221m',
31
31
  userBg: '\x1B[48;2;44;49;60m', // One Dark current-line bg,深色终端上微妙可辨
32
+ // diff 行底色:One Dark bg #282c34 上加 ~14% 亮度的对应色,够辨识但不刺眼
33
+ addBg: '\x1B[48;2;44;62;42m', // 偏暗绿(One Dark green(152,195,121)暗化)
34
+ delBg: '\x1B[48;2;62;38;42m', // 偏暗红(One Dark red(224,108,117)暗化)
32
35
  };
33
36
  /**
34
37
  * 内建主题表。default=One Dark、light=Solarized Light、solarized=Solarized Dark、
@@ -49,6 +52,10 @@ const THEMES = {
49
52
  brightCyan: '\x1B[38;2;42;161;152m',
50
53
  brightMagenta: '\x1B[38;2;108;113;196m',
51
54
  userBg: '\x1B[48;2;238;232;213m',
55
+ // diff 行底色:Solarized Light base2(238,232,213)上贴同色族浅 tint,
56
+ // 比直接用 base1 更柔,跟深 fg(red/green)对比充足
57
+ addBg: '\x1B[48;2;220;235;205m',
58
+ delBg: '\x1B[48;2;245;218;215m',
52
59
  },
53
60
  solarized: {
54
61
  red: '\x1B[38;2;220;50;47m',
@@ -61,6 +68,9 @@ const THEMES = {
61
68
  brightCyan: '\x1B[38;2;147;161;161m',
62
69
  brightMagenta: '\x1B[38;2;108;113;196m',
63
70
  userBg: '\x1B[48;2;7;54;66m',
71
+ // diff 行底色:Solarized Dark base03(0,43,54)上加对应色族暗 tint
72
+ addBg: '\x1B[48;2;20;50;38m',
73
+ delBg: '\x1B[48;2;55;30;30m',
64
74
  },
65
75
  gruvbox: {
66
76
  red: '\x1B[38;2;251;73;52m',
@@ -73,6 +83,9 @@ const THEMES = {
73
83
  brightCyan: '\x1B[38;2;142;192;124m',
74
84
  brightMagenta: '\x1B[38;2;211;134;155m',
75
85
  userBg: '\x1B[48;2;40;40;40m',
86
+ // diff 行底色:Gruvbox dark bg(40,40,40)上贴暗 bg0_a 风格
87
+ addBg: '\x1B[48;2;40;55;30m',
88
+ delBg: '\x1B[48;2;70;35;30m',
76
89
  },
77
90
  nord: {
78
91
  red: '\x1B[38;2;191;97;106m',
@@ -85,6 +98,9 @@ const THEMES = {
85
98
  brightCyan: '\x1B[38;2;136;192;208m',
86
99
  brightMagenta: '\x1B[38;2;180;142;173m',
87
100
  userBg: '\x1B[48;2;67;76;94m',
101
+ // diff 行底色:Nord polar night(46,52,64)上贴对应色族暗 tint
102
+ addBg: '\x1B[48;2;46;66;52m',
103
+ delBg: '\x1B[48;2;72;46;52m',
88
104
  },
89
105
  };
90
106
  let currentName = 'default';
@@ -155,4 +171,10 @@ export const ui = {
155
171
  get userBg() {
156
172
  return color('userBg');
157
173
  },
174
+ get addBg() {
175
+ return color('addBg');
176
+ },
177
+ get delBg() {
178
+ return color('delBg');
179
+ },
158
180
  };
@@ -126,13 +126,15 @@ export function compareSemver(a, b) {
126
126
  }
127
127
  return 0;
128
128
  }
129
- /** 纯决策:据当前版本 / 缓存 / 时间,算是否提示 / spawn / 后台刷新。无副作用,可离线测。 */
129
+ /** 纯决策:据当前版本 / 缓存 / 时间,算是否提示 / spawn / 后台刷新。无副作用,可离线测。
130
+ * notice 同时携带当前版本(`current`)与最新版本(`latest`),让 REPL 提示能向用户
131
+ * 清晰展示"你正在用的 mocode 是 X,最新是 Y",不只是单调的"有新版本"。 */
130
132
  export function evaluateUpdate(current, cache, now) {
131
133
  const refresh = !cache.latest || now - (cache.lastCheck ?? 0) > CHECK_INTERVAL_MS;
132
134
  let notice = null;
133
135
  let spawn = false;
134
136
  if (cache.latest && compareSemver(cache.latest, current) > 0) {
135
- notice = `检测到新版本 ${cache.latest},后台更新中,下次启动生效。`;
137
+ notice = `当前 mocode v${current} 已是较旧版本,最新为 v${cache.latest},后台更新中,下次启动生效。`;
136
138
  spawn = now - (cache.lastSpawn ?? 0) > SPAWN_INTERVAL_MS;
137
139
  }
138
140
  return { notice, spawn, refresh };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {