mocode-ai 0.4.4 → 0.4.5

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,7 +1,4 @@
1
1
  import { stdin, stdout } from 'node:process';
2
- import { appendFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { join } from 'node:path';
5
2
  import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, stripAnsi, sliceByDisplayCol, } from './render.js';
6
3
  import { ui } from './theme.js';
7
4
  import * as content from './content.js';
@@ -55,6 +52,10 @@ const WHEEL_LINES = 3; // 滚轮每格滚动行数
55
52
  // 点击输入框粘贴:当前文本输入方(prompt.ts / repl 运行态 typeahead)注册回调,接收剪贴板文本贴入。
56
53
  // 只在输入区(底栏输入行)内右键单击(未拖动)时触发——与内容区选区互不干扰(内容区左键=框选,右键=复制)。
57
54
  let pasteHandler = null;
55
+ /** 鼠标点击输入框 → prompt.ts(文本 source of truth)提供的光标应用回调;
56
+ * layout 只做"点击 → 算新位置"的映射,实际改 cl/cc 归 prompt(同 pasteHandler 套路)。
57
+ * 若未注册(非输入态/未进 prompt):仅做视觉反馈,cl/cc 不变。 */
58
+ let cursorChangeHandler = null;
58
59
  const esc = {
59
60
  altOn: '\x1B[?1049h',
60
61
  altOff: '\x1B[?1049l',
@@ -394,6 +395,7 @@ export function clearContent() {
394
395
  mdActive = false;
395
396
  mdBuf = '';
396
397
  content.reset();
398
+ notifyContentReset(); // batch 渲染器同步重置(batch 摘要行索引全部失效)
397
399
  stdout.write(esc.home);
398
400
  }
399
401
  /**
@@ -420,6 +422,69 @@ export function rewindContent(rowsToRewind) {
420
422
  // repaintViewport 会按新 buffer 重画整片,旧 frame 自然被覆盖。
421
423
  repaintViewport();
422
424
  }
425
+ /**
426
+ * 在绝对行索引 after 后插入自洽行(详情展开用)。
427
+ * 滚动回看冻结:scrollOffset>0 时 offset += delta 把窗口冻在原绝对行,
428
+ * 与 contentWrite/contentWriteMd 同模式。插入后 repaintViewport 重画 viewport。
429
+ *
430
+ * 续写位(contentRow):若原写头在插入点之后,前移 lines.length,保持相对位置;
431
+ * 若原写头 ≤ after,不变(新行在写头之后)。非 TTY 直接调 content.insertAfter。
432
+ */
433
+ export function contentInsertAfter(after, lines) {
434
+ if (!active || lines.length === 0)
435
+ return;
436
+ const g = getGeo();
437
+ const totalBefore = content.totalRows();
438
+ const scrolled = scrollOffset > 0;
439
+ content.insertAfter(after, lines);
440
+ const delta = content.totalRows() - totalBefore;
441
+ if (delta === 0)
442
+ return;
443
+ // 续写位:原写头绝对行 = viewport 起点 + (contentRow-1) [offset=0];偏移后才一致
444
+ if (scrollOffset === 0 && contentRow > after + 1) {
445
+ contentRow = Math.min(contentRow + delta, g.contentBottom);
446
+ }
447
+ // 滚动回看冻结(同 contentWrite)
448
+ if (scrolled) {
449
+ scrollOffset = Math.max(0, Math.min(scrollOffset + delta, Math.max(0, content.totalRows() - g.contentBottom)));
450
+ }
451
+ // batch 摘要行索引平移
452
+ void import('./batch.js').then((m) => m.shiftBatchesAfter(after, delta));
453
+ repaintViewport();
454
+ }
455
+ /**
456
+ * 从绝对行索引 startIdx 起删 n 行(折叠回退用)。
457
+ * 滚动回看冻结同 insertAfter:offset -= delta,钳 ≥ 0。续写位若在删区后前移 delta。
458
+ */
459
+ export function contentDeleteFrom(startIdx, n) {
460
+ if (!active || n <= 0)
461
+ return;
462
+ const g = getGeo();
463
+ const totalBefore = content.totalRows();
464
+ const scrolled = scrollOffset > 0;
465
+ content.deleteFrom(startIdx, n);
466
+ const delta = totalBefore - content.totalRows();
467
+ if (delta === 0)
468
+ return;
469
+ if (scrollOffset === 0 && contentRow > startIdx + 1) {
470
+ contentRow = Math.max(1, contentRow - delta);
471
+ }
472
+ if (scrolled) {
473
+ scrollOffset = Math.max(0, scrollOffset - delta);
474
+ }
475
+ // batch 摘要行索引平移(用 -delta 表示后段索引前移)
476
+ void import('./batch.js').then((m) => m.shiftBatchesAfter(startIdx, -delta));
477
+ repaintViewport();
478
+ }
479
+ /** 当前内容物理行总数(含当前未提交行)。供 batch 渲染器在 endBatch 时定位摘要行索引。 */
480
+ export function totalRows() {
481
+ return content.totalRows();
482
+ }
483
+ /** 清空内容区时通知 batch 渲染器重置(摘要行映射与展开态)。 */
484
+ export function notifyContentReset() {
485
+ // 动态 import 避免循环;模块级 reset() 只清映射,不动 batch 内部数据(id 与 entries 仍可重用)
486
+ void import('./batch.js').then((m) => m.reset());
487
+ }
423
488
  // ── viewport 滚动回看(Phase 2)──
424
489
  /** 是否处于滚动回看态(offset>0,内容区显历史)。prompt 据此在非滚动键时回尾。 */
425
490
  export function isScrolled() {
@@ -567,6 +632,13 @@ export function clearSelection() {
567
632
  export function setPasteHandler(fn) {
568
633
  pasteHandler = fn;
569
634
  }
635
+ /** 注册"点击输入框 → 改光标"回调:prompt.ts 等文本所有者挂此回调后,
636
+ * 鼠标左键落输入行算出的新 (line, col) 会在 prompt 下次 redraw 前喂入此回调
637
+ * (prompt 用它写自己的 cl / cc,真正改 source of truth)。
638
+ * 传 null 注销(在 cleanup / 退出输入态时调,防下个 prompt 实例被旧回调污染)。 */
639
+ export function setCursorChangeHandler(fn) {
640
+ cursorChangeHandler = fn;
641
+ }
570
642
  /** picker / 介入面板期间禁用鼠标选区与拖拽(避免 viewport 重画覆盖菜单);滚轮仍可用。面板退出后恢复。 */
571
643
  export function setMouseEnabled(v) {
572
644
  mouseEnabled = v;
@@ -612,11 +684,143 @@ function pasteIntoInput() {
612
684
  })
613
685
  .catch(() => { });
614
686
  }
687
+ /** 鼠标左键落输入行:进输入态(若是 running)→ 用统一的 display_col 单位算出"目标光标位"
688
+ * → paintInput 用新光标重画(末步 cup 把终端真光标移到点击位,默认即竖线/闪烁块),
689
+ * 并同步调 cursorChangeHandler 让 prompt.ts 改自己的 cl/cc(source of truth),下次按键按新位写字。
690
+ *
691
+ * 单位说明:
692
+ * - paintInput 的 view.cursorCol 单位 = display_col(同 paintInput 的 dispCursorCol,见 prompt.ts:186)
693
+ * - prompt 的 cc 单位 = char_idx(字符索引)
694
+ * 本函数同时给出 targetLine、targetDisplayCol(paintInput 用)、targetCharCol(prompt 用),
695
+ * 故 visitColToCharCol 那一套反推只是用于把 click 屏幕列换成字符偏移,与 paintInput 的视协议完全对齐。
696
+ *
697
+ * 点击行为:
698
+ * - 屏幕 visRow/visCol 用 charWidth 计列(中文/Emoji 全角占 2);落在宽字符中段归到字符左边界。
699
+ * - 累加 flat[0..flatIdx-1] 的 display_w + 字符长度 → targetDisplayCol/targetCharCol,与 paintInput 渲染一致。
700
+ * - 段接缝点击因 paintInput 协议限制(<= 还是 <),会落到前段末;这是 paintInput 全局行为,不归本函数。
701
+ * - 未注册 handler(非输入态)→ 仅视觉真光标可见;cl/cc 不变。 */
702
+ function setInputCursorFromClick(screenRow, screenCol) {
703
+ if (!active || !base || !lastView)
704
+ return;
705
+ // running 态:先收归输入态(enterInputMode 会 paintInput 整帧重画;之后我们再按新光标重画一次覆盖即可)
706
+ if (mode !== 'input') {
707
+ enterInputMode(statusText);
708
+ }
709
+ const g = getGeo();
710
+ const promptW = displayWidth(lastView.prompt);
711
+ const firstInputRow = g.contentBottom + 4;
712
+ const inputRowsAvail = Math.max(0, g.footerH - 5);
713
+ // 输入框可视区的 (visRow, visCol) 屏幕坐标 → 0-based。
714
+ // 点到可视区末行之下(空白区)→ 落到最末可视行(光标归最后一段);isInputRow 已挡可视区之上的点击。
715
+ const visRow = Math.max(0, Math.min(screenRow - firstInputRow, Math.max(0, inputRowsAvail - 1)));
716
+ // 屏幕列 (1-based SGR) → 显示列 (0-based),再扣 prompt 宽;尾点容许越界 clip 到 >=0。
717
+ const visCol = Math.max(0, screenCol - 1 - promptW);
718
+ // 复刻 windowInputVis 的折行 + 滚动窗(输入态, lines 全量参与)。
719
+ // lastView.lines 在 prompt 的 redraw() 中为 dispLines()(含 chip pre 行 / chip prefix 列);
720
+ // chip 模式下点击 chip 区域也走同一算法,targetCharCol 与 paintInput 的视协议(段接缝除外)一致。
721
+ const cols = Math.max(1, g.cols - promptW);
722
+ const lineVis = lastView.lines.map((l) => wrapByDisplayWidth(l, cols));
723
+ const flat = [];
724
+ for (const lv of lineVis)
725
+ for (const r of lv)
726
+ flat.push(r);
727
+ const totalVis = flat.length;
728
+ const maxInputRows = Math.max(1, Math.floor(g.rows * 0.4));
729
+ const showCount = Math.min(maxInputRows, totalVis);
730
+ // 起窗偏移:用 lastView 当前光标位置作为锚(与 paintInput 同算法)。
731
+ let curVisLine = 0;
732
+ {
733
+ const clRows = lineVis[lastView.cursorLine] ?? [''];
734
+ let acc = 0;
735
+ for (let i = 0; i < clRows.length; i++) {
736
+ const rw = displayWidth(clRows[i]);
737
+ if (lastView.cursorCol <= acc + rw) {
738
+ curVisLine = i;
739
+ break;
740
+ }
741
+ acc += rw;
742
+ curVisLine = i;
743
+ }
744
+ }
745
+ let curAbs = curVisLine;
746
+ for (let i = 0; i < lastView.cursorLine; i++)
747
+ curAbs += lineVis[i].length;
748
+ const startVis = totalVis > maxInputRows
749
+ ? Math.max(0, Math.min(curAbs - maxInputRows + 1, totalVis - maxInputRows))
750
+ : 0;
751
+ let targetLine;
752
+ let targetDisplayCol;
753
+ let dispatchFlatIdx; // 传给 prompt 的屏 tap 原坐标(flat 中段号);超尾点边界用 totalVis - 1
754
+ let dispatchInSegVis; // 段内显示列
755
+ // 点到可视区末行之下(空行)→ 等价"光标在最末可视行的字符串末尾"。
756
+ if (startVis + visRow >= totalVis) {
757
+ const lastFlatIdx = totalVis - 1;
758
+ // 把 lastFlatIdx 反推到 (line, segInLine)
759
+ let lastLine = 0;
760
+ let lastSegInLine = lastFlatIdx;
761
+ while (lastLine < lineVis.length && lastSegInLine >= lineVis[lastLine].length) {
762
+ lastSegInLine -= lineVis[lastLine].length;
763
+ lastLine++;
764
+ }
765
+ if (lastLine >= lineVis.length) {
766
+ lastLine = lineVis.length - 1;
767
+ lastSegInLine = lineVis[lastLine].length - 1;
768
+ }
769
+ // view.cursorCol 单位 = lines[lastLine] 内 display_col
770
+ let inLineDisplayCol = 0;
771
+ for (let s = 0; s <= lastSegInLine; s++)
772
+ 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);
818
+ }
615
819
  /**
616
820
  * 鼠标事件分发(mouse.setHandler 注册)。
617
821
  * - 左键(button 0):内容区按下开选区、拖动扩展(触边自动翻页)、释放只更新高亮**不复制**;
618
- * 纯点击(未拖动)清空旧选区(点别处的常见预期);落在输入行不做任何特殊处理(不选区、不粘贴——
619
- * 留给终端 / 正常打字焦点行为,避免误触发)。
822
+ * 纯点击(未拖动)清空旧选区(点别处的常见预期);落在输入行 → 自动进入输入态 + 光标定位到点击位
823
+ * (沿用 windowInputVis 的软折规则做逆解,支持多行 / 中文宽字符;右键 release 落输入行仍贴入)。
620
824
  * - 右键(button 2,单击 = press→release 未拖动):落在输入行 → 读剪贴板贴入(仿常见终端"右键粘贴");
621
825
  * 落在内容区 → 复制当前选区(若有)到剪贴板后立即清空高亮(视觉确认"已复制",不留旧选区误导),
622
826
  * 静默不弹提示;都无对应状态则 no-op。
@@ -656,8 +860,12 @@ function handleMouseEvent(e) {
656
860
  if (e.button !== 0)
657
861
  return; // 其余中/右键 press/drag 不处理(终端原生右键菜单等不受影响)
658
862
  if (e.type === 'press') {
659
- if (isInputRow(e.row))
660
- return; // 输入行左键不做选区(不干扰正常打字/焦点)
863
+ if (isInputRow(e.row)) {
864
+ // 左键落输入行:进输入态 + 光标定位到点击位 + 重画。
865
+ // 输入框内不再做选区(避免和"在该处开始打字"的直觉冲突;右键 release 仍调 pasteIntoInput)。
866
+ setInputCursorFromClick(e.row, e.col);
867
+ return;
868
+ }
661
869
  selecting = true;
662
870
  const rowInContent = Math.max(1, Math.min(e.row, g.contentBottom));
663
871
  const absLine = screenRowToAbsLine(rowInContent);
@@ -689,10 +897,31 @@ function handleMouseEvent(e) {
689
897
  if (!selection)
690
898
  return;
691
899
  if (!selection.dragged) {
692
- // 内容区纯点击(未拖动):只清选区,不复制(复制交给右键)
693
- selection = null;
694
- repaintViewport();
695
- repaint(); // 同上:把真光标带回输入框
900
+ // 内容区纯点击(未拖动):若落在工具 batch 摘要行上 → 切换展开/折叠(仿 Claude Code);
901
+ // 否则原行为:清选区。batch 反查通过 content lineAt + dynamic import(避免 layout↔batch 循环依赖)。
902
+ const absClick = selection.anchorLine; // 起止同行同列,取任一;未拖动时 line = anchor = end
903
+ void (async () => {
904
+ try {
905
+ const m = await import('./batch.js');
906
+ const id = m.findBatchByAbsLine(absClick);
907
+ if (id) {
908
+ m.toggleBatch(id, {
909
+ contentInsertAfter: (after, lines) => contentInsertAfter(after, lines),
910
+ contentDeleteFrom: (start, n) => contentDeleteFrom(start, n),
911
+ });
912
+ selection = null;
913
+ repaintViewport();
914
+ repaint();
915
+ return;
916
+ }
917
+ }
918
+ catch {
919
+ // batch 不可用(非 TTY 等)→ 走默认清选区路径
920
+ }
921
+ selection = null;
922
+ repaintViewport();
923
+ repaint();
924
+ })();
696
925
  return;
697
926
  }
698
927
  // 拖动过:保留高亮选区供右键复制(不在此处复制,复制交给右键释放分支)。
@@ -735,7 +964,7 @@ function twoColumn(leftStr, leftW, rightStr, rightW, cols) {
735
964
  * INPUT: ◆ 空闲
736
965
  * 思考中: ⠹ 思考中… 0.5s
737
966
  * 运行心跳: ♥ 0.5s
738
- * 滚动回看: ◆ 历史 ↑3 (PgDn 回底) */
967
+ * 滚动回看: ◆ 空闲 历史 ↑3 (PgDn 回底) */
739
968
  function composeSpinnerLine(status, cols) {
740
969
  const spinning = mode === 'running' && runningFrame >= 0;
741
970
  const hasSpinner = !!status.spinnerFrame;
@@ -748,9 +977,13 @@ function composeSpinnerLine(status, cols) {
748
977
  let lead;
749
978
  let leadW;
750
979
  if (scrolled) {
751
- // 滚动回看:左段仅显 ◆(或心跳帧),右段显历史指示
752
- lead = `${spinning ? ui.brightMagenta : ui.brightCyan}${spinning ? RUNNING_FRAMES[runningFrame] : '◆'}${ui.reset}`;
753
- leadW = 1;
980
+ // 滚动回看:左段 = 符号( 或 心跳帧) + 状态名(灰,无走时);右段 = 历史指示。
981
+ // 跟非回看的 INPUT/RUNNING 态保持一致——避免「◆ 留下、状态字蒸发」的视觉错觉。
982
+ const symbol = spinning
983
+ ? `${ui.brightMagenta}${RUNNING_FRAMES[runningFrame]}${ui.reset}`
984
+ : `${ui.brightCyan}◆${ui.reset}`;
985
+ lead = `${symbol} ${ui.dim}${status.status}${ui.reset}`;
986
+ leadW = 1 + 1 + displayWidth(status.status);
754
987
  }
755
988
  else if (hasSpinner) {
756
989
  // spinner 激活(思考中/执行工具…):帧 + 状态 + 走时
@@ -896,20 +1129,6 @@ function stopTurnTimer() {
896
1129
  turnTimer = null;
897
1130
  }
898
1131
  }
899
- // ── 临时诊断:spinner frame 泄漏追踪(定位 557e678 后的间歇性泄漏后删除)──
900
- // 记 paintLiveAtCursor/clearLiveAtCursor 的可疑时序:续写位漂移、clear 被守卫跳过、清错行。
901
- // 同步追加到 ~/.mocode/spinner-debug.log,全 try/catch 不抛、不阻塞、不抢屏。
902
- let _dbgSpinnerPath = '';
903
- function dbgSpinner(msg) {
904
- try {
905
- if (!_dbgSpinnerPath)
906
- _dbgSpinnerPath = join(homedir(), '.mocode', 'spinner-debug.log');
907
- appendFileSync(_dbgSpinnerPath, `[${new Date().toISOString()}] ${msg}\n`);
908
- }
909
- catch {
910
- // 诊断日志失败不影响渲染
911
- }
912
- }
913
1132
  /**
914
1133
  * 在续写位画一行瞬时活动文本(spinner 帧):不进缓冲、不推进续写位,逐行 clearLine 重画。
915
1134
  * 仅 TTY + offset=0(实时尾)+ 非打字暂停态时物理写屏;滚动态跳过(由状态行 spinner 兜底,且避免覆盖 viewport 历史行)。
@@ -951,10 +1170,6 @@ export function paintLiveAtCursor(text) {
951
1170
  if (scrollOffset === 0 && contentRow < g.contentBottom) {
952
1171
  contentRow = Math.min(total, g.contentBottom);
953
1172
  }
954
- // 临时诊断:续写位 != 上次画帧位置 = spinner 运行期间续写位漂移(泄漏根因嫌疑)
955
- if (frameRow && (frameRow !== contentRow || frameCol !== contentCol)) {
956
- dbgSpinner(`DRIFT-PAINT old=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) mode=${mode} off=${scrollOffset}`);
957
- }
958
1173
  let out = '';
959
1174
  if (frameRow && (frameRow !== contentRow || frameCol !== contentCol)) {
960
1175
  // 清旧帧同样钳到可视区(resize 后 frameRow 可能 > contentBottom)
@@ -1046,31 +1261,6 @@ function windowInputVis(lines, cursorLine, cursorCol, cols, promptW, rows) {
1046
1261
  startVis,
1047
1262
  };
1048
1263
  }
1049
- /**
1050
- * 把一行纯可见文本(无 ANSI / 零宽)在光标显示列处切成 before/cur/after:
1051
- * cur = 光标右侧那个字符(块状光标"压"在它上面);光标在行末(列 == 行宽)则 cur=''。
1052
- * 供 paintInput 画块状光标——反白 cur(行末反白一个空格),让用户看清"现在在哪输入"。
1053
- *
1054
- * 光标列恒落在字符边界:cursorCol = 显示宽度(slice 整字累加),不进字符内部,故按 acc===col 取字符即可。
1055
- * 宽字符(CJK=2)在行尾放不下时整字折下行,光标列仍在边界,acc 逐字累加 displayWidth 与终端光标一致。
1056
- */
1057
- function splitAtVisCol(line, col) {
1058
- let acc = 0;
1059
- let i = 0;
1060
- for (const ch of line) {
1061
- const cw = charWidth(ch.codePointAt(0) ?? 0);
1062
- if (cw <= 0) {
1063
- i += ch.length;
1064
- continue; // 零宽(组合符等):不计列,跳过(输入文本一般无,稳妥)
1065
- }
1066
- if (acc === col) {
1067
- return { before: line.slice(0, i), cur: ch, after: line.slice(i + ch.length) };
1068
- }
1069
- acc += cw;
1070
- i += ch.length;
1071
- }
1072
- return { before: line, cur: '', after: '' }; // 光标在行末:无字符可反白
1073
- }
1074
1264
  /**
1075
1265
  * 画输入区:擦旧菜单 → (必要时)setRegion → 画状态行 + 输入行 + 向上菜单,光标留输入框(dim 时回续写位)。
1076
1266
  * prompt.ts 每次按键调;enterInputMode / enterRunningMode 也调(空 / dim)。
@@ -1140,23 +1330,16 @@ export function paintInput(view) {
1140
1330
  const firstInputRow = g.contentBottom + 4;
1141
1331
  const inputRowsAvail = g.footerH - 5; // 去掉虚拟空/spinner行/上线/下线/model行,留输入行
1142
1332
  const indent = ' '.repeat(promptW);
1143
- const showCaret = view.caret !== false; // 默认 true;picker 等非文本输入传 false 关闭块状光标
1333
+ // 光标:不画反白块/假光标——输入框走终端真光标(WT / VSCode 终端默认竖线/闪烁块,
1334
+ // 各终端表现略不同但都贴合 IME 候选气泡且不再"挡住字符")。真光标位置由下方第 6 步 cup 写。
1335
+ // 保留 view.caret=false 路径给 picker 等非文本输入:把真光标定位到 hint 末尾。
1144
1336
  for (let i = 0; i < inputRowsAvail; i++) {
1145
1337
  const line = vis.visRows[i] ?? '';
1146
1338
  const r = firstInputRow + i;
1147
1339
  const prefix = vis.startVis === 0 && i === 0 ? view.prompt : indent;
1148
- let text;
1149
- if (view.dim) {
1150
- text = renderDimInputRow(view.prompt, line, view.placeholder ?? '', g.cols);
1151
- }
1152
- else if (showCaret && i === vis.visLine) {
1153
- // 块状光标:反白光标右侧字符(cur),行末(无字符)反白一个空格——示"现在在哪输入"
1154
- const { before, cur, after } = splitAtVisCol(line, vis.cursorVisCol);
1155
- text = `${prefix}${before}${ui.reverse}${cur || ' '}${ui.reset}${after}`;
1156
- }
1157
- else {
1158
- text = `${prefix}${line}`;
1159
- }
1340
+ const text = view.dim
1341
+ ? renderDimInputRow(view.prompt, line, view.placeholder ?? '', g.cols)
1342
+ : `${prefix}${line}`;
1160
1343
  buf += cup(r, 1) + esc.clearLine + text;
1161
1344
  }
1162
1345
  // 4b. 下线(输入框底):满屏宽细线 ─(cyan),在 model 行上一行(model 行占屏底 rows)
@@ -1195,16 +1378,17 @@ export function paintInput(view) {
1195
1378
  */
1196
1379
  function renderDimInputRow(prompt, text, placeholder, cols) {
1197
1380
  const promptW = displayWidth(prompt);
1198
- const caret = `${ui.reverse} ${ui.reset}`; // 反白块状光标(1 cell,与 INPUT 态同款)
1199
- const contentW = Math.max(0, cols - promptW - 1);
1381
+ // dim (运行中打字):不画反白块——反白块视觉占位是 INPUT 态真光标的旧版,这里真光标本
1382
+ // 就被隐起来(IME 锚定需要,见 contentMode),在 dim 文本后硬塞个反白空格会"白块闪烁"。
1383
+ // 用户读 dim 文本本身就能定位打字边界;真光标在 INPUT 态显形(竖线/闪烁块,跟终端默认一致)。
1384
+ const contentW = Math.max(0, cols - promptW);
1200
1385
  if (text.length > 0) {
1201
- // 有打字:❯ dim + 文本(dim,超长时从头部截断保留尾部——光标恒在末尾,须始终看到刚打的字,
1202
- // 而非 truncateDisplay 那样保留开头、把刚打的内容截没,显示成卡在开头不动的假象) + 反白光标(末尾)
1203
- return `${ui.dim}${prompt}${truncateDisplayHead(text, contentW)}${ui.reset}${caret}`;
1386
+ // 有打字:❯ dim + 文本(dim,超长从头部截断保留尾部——光标恒在末尾,须始终看到刚打的字)
1387
+ return `${ui.dim}${prompt}${truncateDisplayHead(text, contentW)}${ui.reset}`;
1204
1388
  }
1205
- // 空:❯ dim + 反白光标(打字起点) + dim 占位 ghost
1389
+ // 空:❯ dim + dim 占位 ghost(taking the full row)
1206
1390
  const p = placeholder ? truncateDisplay(placeholder, contentW) : '';
1207
- return `${ui.dim}${prompt}${ui.reset}${caret}${ui.dim}${p}${ui.reset}`;
1391
+ return `${ui.dim}${prompt}${p}${ui.reset}`;
1208
1392
  }
1209
1393
  /**
1210
1394
  * 运行态 typeahead 回显:定向写输入行(底栏输入框),把 dim 占位换成已打字文本 + 反白块状光标(无打字时光标在起点)。
package/dist/ui/prompt.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import readline from 'node:readline';
2
2
  import { stdin, stdout } from 'node:process';
3
3
  import { ui } from './theme.js';
4
- import { displayWidth, padEndDisplay, truncateDisplay } from './render.js';
4
+ import { displayWidth, padEndDisplay, truncateDisplay, visColToCharCol, wrapByDisplayWidth } from './render.js';
5
5
  import * as layout from './layout.js';
6
6
  import * as mouse from './mouse.js';
7
7
  // ── 粘贴检测(块级 + 时间窗)──
@@ -249,8 +249,105 @@ export async function promptWithSlashMenu(opts) {
249
249
  menu: menuLines().length ? { lines: menuLines() } : null,
250
250
  });
251
251
  }
252
+ /** 鼠标点击输入框:layout 已把"屏 tap 位置"以 (flatIdx, inSegVis) 形式传进来
253
+ * (flatIdx = flat 中的绝对段号;inSegVis = 段内显示列)。
254
+ * 本函数在 prompt 自己的 (lines + chip) 上复刻 wrap/flat 还原 (cl, cc)→ redraw。
255
+ * chip 模式自动扣 chip prefix:
256
+ * - dispLines() 把 chipPre 末行 + chipPrefix + suffix[0] 拼成一行 "mergedRow"
257
+ * - flat 中对应的"合并行段"是 mergedRow 的折行段,段内 inChar 累加从 mergedRow 起
258
+ * - 要得到 lines[cl=0] 内的字符索引,扣掉 mergedRow 起始到 lines[0] 起始的字符数
259
+ * = chipPre 末行字符数 + chipPrefix 字符数。 */
260
+ function applyExternalCursor(flatIdx, inSegVis) {
261
+ const g = layout.getGeo();
262
+ const promptW = displayWidth(opts.prompt);
263
+ const W = Math.max(1, g.cols - promptW);
264
+ const dispLs = dispLines();
265
+ const lineVis = dispLs.map((l) => wrapByDisplayWidth(l, W));
266
+ const flat = [];
267
+ for (const lv of lineVis)
268
+ for (const r of lv)
269
+ flat.push(r);
270
+ let newCl;
271
+ let newCc;
272
+ if (flat.length === 0) {
273
+ newCl = 0;
274
+ newCc = 0;
275
+ }
276
+ else {
277
+ const safeIdx = Math.max(0, Math.min(flatIdx, flat.length - 1));
278
+ const seg = flat[safeIdx];
279
+ const segW = displayWidth(seg);
280
+ const safeInSeg = Math.max(0, Math.min(inSegVis, segW));
281
+ const inChar = visColToCharCol(seg, safeInSeg);
282
+ // safeIdx → (dispLine, segInLine)
283
+ let dispLine = 0;
284
+ let segInLine = safeIdx;
285
+ while (dispLine < lineVis.length && segInLine >= lineVis[dispLine].length) {
286
+ segInLine -= lineVis[dispLine].length;
287
+ dispLine++;
288
+ }
289
+ if (dispLine >= lineVis.length) {
290
+ dispLine = lineVis.length - 1;
291
+ segInLine = lineVis[dispLine].length - 1;
292
+ }
293
+ // dispLine → cl(chip-aware)
294
+ if (chip) {
295
+ const preLen = chipPreLines().length;
296
+ if (dispLine < preLen - 1) {
297
+ // 落在 chipPre 内(非末行)→ 走出 chip 到 suffix 起点
298
+ newCl = 0;
299
+ }
300
+ else if (dispLine === preLen - 1) {
301
+ // 合并行 = suffix 第 0 行(cl=0)
302
+ newCl = 0;
303
+ }
304
+ else {
305
+ newCl = dispLine - (preLen - 1);
306
+ }
307
+ }
308
+ else {
309
+ newCl = dispLine;
310
+ }
311
+ newCl = Math.max(0, Math.min(newCl, lines.length - 1));
312
+ const lineText = lines[newCl] ?? '';
313
+ // cc 必须在 lines[newCl] 内。flat 中 seg 是 dispLs[dispLine] 的某折行段,
314
+ // seg 内 inChar 是段内字符偏移,但 lines[newCl] 的字符是按 (dispLs 前段累加) + (本段 inChar) 算的。
315
+ // ——非 chip 模式下:dispLine = newCl, segStartChars = sum(lineVis[dispLine][0..segInLine-1].length)
316
+ // ccInLine = segStartChars + inChar
317
+ // ——chip 模式下 newCl=0(合并行):dispLine 是合并行 dispLine,segStartChars 同上,
318
+ // 但 lines[0] 在合并行中"起始处"在 (mergedRow.length - lines[0].length) 字符处,
319
+ // 故 ccInLine = segStartChars + inChar - chipOverheadChars。
320
+ let segStartChars = 0;
321
+ const segs = lineVis[dispLine];
322
+ for (let s = 0; s < segInLine; s++)
323
+ segStartChars += segs[s]?.length ?? 0;
324
+ let ccInLine = segStartChars + inChar;
325
+ if (chip && newCl === 0) {
326
+ const pre = chipPreLines();
327
+ const mergedRow = pre[pre.length - 1] ?? '';
328
+ const prefix = chipPrefix();
329
+ // mergedRow 起始到 lines[0] 起始的字符数 = (chipPreLast + chipPrefix) 字符数
330
+ const chipOverheadChars = mergedRow.length + prefix.length - lineText.length;
331
+ ccInLine = ccInLine - chipOverheadChars;
332
+ }
333
+ newCc = Math.max(0, Math.min(ccInLine, lineText.length));
334
+ }
335
+ cl = newCl;
336
+ cc = newCc;
337
+ // 收起菜单/过滤态:点击输入框关闭菜单,回到纯文本编辑。
338
+ if (menuOpen) {
339
+ menuOpen = false;
340
+ filtered = [];
341
+ selected = 0;
342
+ menuTop = 0;
343
+ }
344
+ justSawCR = false;
345
+ computeFiltered();
346
+ redraw();
347
+ }
252
348
  function cleanup() {
253
349
  layout.setPasteHandler(null);
350
+ layout.setCursorChangeHandler(null);
254
351
  try {
255
352
  stdin.setRawMode(false);
256
353
  }
@@ -566,6 +663,7 @@ export async function promptWithSlashMenu(opts) {
566
663
  return;
567
664
  }
568
665
  layout.setPasteHandler(onMousePaste); // 鼠标右键单击输入框(未拖动)→ 读剪贴板贴入;cleanup 时注销
666
+ layout.setCursorChangeHandler(applyExternalCursor); // 鼠标左键单击输入框(未拖动)→ 改 cl/cc 到点击位;cleanup 时注销
569
667
  stdin.resume();
570
668
  emitter.on('keypress', onKey);
571
669
  computeFiltered();
@@ -615,7 +713,6 @@ export async function promptTurnPicker(items) {
615
713
  cursorLine: 0,
616
714
  cursorCol: displayWidth(hint),
617
715
  menu: { lines: menuLines() },
618
- caret: false, // 纯导航菜单(非文本输入):不画输入框块状光标,聚焦由菜单 ▸ 标记
619
716
  });
620
717
  }
621
718
  function cleanup() {
@@ -756,7 +853,6 @@ export async function promptSessionPicker(items, recentCap = 10) {
756
853
  cursorLine: 0,
757
854
  cursorCol: displayWidth(h),
758
855
  menu: { lines: menuLines() },
759
- caret: false, // 纯导航菜单(非文本输入):不画输入框块状光标,聚焦由菜单 ▸ 标记
760
856
  });
761
857
  }
762
858
  function cleanup() {
@@ -894,7 +990,6 @@ export async function promptThemePicker(items) {
894
990
  cursorLine: 0,
895
991
  cursorCol: displayWidth(hint),
896
992
  menu: { lines: menuLines() },
897
- caret: false, // 纯导航菜单:不画输入框块状光标,聚焦由菜单 ▸ 标记
898
993
  });
899
994
  }
900
995
  function cleanup() {
@@ -1008,7 +1103,6 @@ export async function promptRevertChoice(fileCount) {
1008
1103
  cursorLine: 0,
1009
1104
  cursorCol: displayWidth(hint),
1010
1105
  menu: { lines: menuLines() },
1011
- caret: false, // 纯导航菜单:不画输入框块状光标,聚焦由菜单 ▸ 标记
1012
1106
  });
1013
1107
  }
1014
1108
  function cleanup() {
package/dist/ui/render.js CHANGED
@@ -99,6 +99,28 @@ export function sliceByDisplayCol(str, start, end) {
99
99
  }
100
100
  return out;
101
101
  }
102
+ /** 把可视列(0-based)反推为 str 内的字符偏移(JS 字符串下标,UTF-16 code unit)。
103
+ * 点击落在宽字符的"列中段"时归到该字符之前的偏移(停在它左侧边界)——与终端光标行为一致。
104
+ * visCol 大于串的显示宽度时返回 str.length(点行末的"右边")。
105
+ * 输入框点击定位专用;与 sliceByDisplayCol 互为逆,但语义只关心单点、不关心区间。 */
106
+ export function visColToCharCol(str, visCol) {
107
+ if (visCol <= 0)
108
+ return 0;
109
+ let w = 0;
110
+ let i = 0;
111
+ for (const ch of str) {
112
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
113
+ if (cw <= 0) {
114
+ i += ch.length;
115
+ continue;
116
+ } // 零宽(组合符):不占列
117
+ if (w + cw > visCol)
118
+ break; // 落在该字符显示区间内 → 停在其左侧
119
+ w += cw;
120
+ i += ch.length;
121
+ }
122
+ return i;
123
+ }
102
124
  /** 带色串的可见显示宽度(先去 ANSI 再按 displayWidth 度量)。 */
103
125
  export function ansiDisplayWidth(s) {
104
126
  return displayWidth(stripAnsi(s));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {