mocode-ai 0.1.10 → 0.2.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/prompt.js CHANGED
@@ -3,6 +3,7 @@ import { stdin, stdout } from 'node:process';
3
3
  import { ui } from './theme.js';
4
4
  import { displayWidth, padEndDisplay, truncateDisplay } from './render.js';
5
5
  import * as layout from './layout.js';
6
+ import * as mouse from './mouse.js';
6
7
  // ── 粘贴检测(块级 + 时间窗)──
7
8
  // 块级:多字节大块(len>8)或含 CR/LF 的小块 = 粘贴(键盘单键 1 字节,Enter=\r 单字节)。
8
9
  // 时间窗:每块重置 50ms 计时器,静默 50ms 即"粘贴结束"→ onPasteEnd。跨多块的大粘贴(块间 <50ms)累积进
@@ -18,7 +19,10 @@ function ensurePasteDetector() {
18
19
  return;
19
20
  pasteDetectorInstalled = true;
20
21
  stdin.on('data', (chunk) => {
21
- const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
22
+ const raw = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
23
+ // 剔除 SGR 鼠标报告(\x1B[<…M/m,连半截也匹配):拖拽选区时终端连续发 motion 报表,
24
+ // 一个 chunk 里可能拼多条(22+ 字符 > 16 阈值)误判粘贴 50ms、把后续真按键泄进 pasteParts。
25
+ const text = raw.replace(/\x1b\[<[0-9;]*[Mm]/g, '');
22
26
  const hasNL = text.indexOf('\r') >= 0 || text.indexOf('\n') >= 0;
23
27
  // 按字符数(码点)判粘贴,非字节:CJK 汉字占 3 UTF-8 字节,旧阈值(len>8 字节)会把 IME 提交的
24
28
  // 3-10 个汉字误判为粘贴 → 进 50ms 缓冲 → finalizePaste→insertText 落字(且旧 insertText 把光标
@@ -76,6 +80,7 @@ export async function promptWithSlashMenu(opts) {
76
80
  let cc = lines[cl].length; // 光标在该行的字符索引 = 末行末尾
77
81
  let justSawCR = false; // \r\n 合一:粘贴的 \r 折行后,紧跟的 \n 吞掉(避免折两行)
78
82
  let chip = null; // 原子粘贴块:整段封进 [预览…],不可编辑;提交时拼回全文
83
+ let chipPre = ''; // chip 之前已存在的文本(粘贴发生时光标前的原文,原样多行保留,不截断);随 chip 一起提交/删除
79
84
  let menuOpen = false;
80
85
  let selected = 0;
81
86
  let filtered = [];
@@ -107,16 +112,30 @@ export async function promptWithSlashMenu(opts) {
107
112
  function chipPrefix() {
108
113
  return chip ? `[${truncateDisplay(chip.split('\n').join(' '), 20)}] ` : '';
109
114
  }
110
- /** layout 画的行:chip 存在时把前缀拼到第 0 行前(chip 原子显示,不可编辑;光标活在 suffix)。 */
115
+ /** chipPre 按行拆分(粘贴发生前光标之前已有的文本,可能多行,原样保留在 chip 之前)。 */
116
+ function chipPreLines() {
117
+ return chip ? chipPre.split('\n') : [];
118
+ }
119
+ /** 供 layout 画的行:chipPre 的行原样排在前面,最后一行接上 chip 前缀 + suffix 第 0 行
120
+ * (chip 原子显示,不可编辑;chipPre 同样不可直接编辑,光标只活在 suffix)。 */
111
121
  function dispLines() {
112
122
  if (!chip)
113
123
  return lines;
114
- const pre = chipPrefix();
115
- return lines.length > 0 ? [pre + lines[0], ...lines.slice(1)] : [pre];
124
+ const pre = chipPreLines();
125
+ const lastPre = pre[pre.length - 1];
126
+ const mergedRow = lastPre + chipPrefix() + lines[0];
127
+ return [...pre.slice(0, -1), mergedRow, ...lines.slice(1)];
128
+ }
129
+ /** 供 layout 定位光标行:chipPre 多出的行数计入偏移(suffix 的 cl=0 对应 chipPre 最后一行所在的合并行)。 */
130
+ function dispCursorLine() {
131
+ return chip ? chipPreLines().length - 1 + cl : cl;
116
132
  }
117
- /** 供 layout 定位光标列:chip 在第 0 行时偏移 chipPrefix 宽度(suffix 光标始终在 chip 之后)。 */
133
+ /** 供 layout 定位光标列:chip 在合并行(cl===0)时偏移 chipPre 末行宽度 + chipPrefix 宽度。 */
118
134
  function dispCursorCol() {
119
- return chip && cl === 0 ? displayWidth(chipPrefix()) + cursorCol() : cursorCol();
135
+ if (!chip || cl !== 0)
136
+ return cursorCol();
137
+ const pre = chipPreLines();
138
+ return displayWidth(pre[pre.length - 1]) + displayWidth(chipPrefix()) + cursorCol();
120
139
  }
121
140
  /** 在光标处插入文本(含换行则拆行)。供短粘贴 finalize 落为可编辑文本。 */
122
141
  function insertText(text) {
@@ -133,21 +152,27 @@ export async function promptWithSlashMenu(opts) {
133
152
  // 旧值 parts[...].length 漏算 before → 光标落到插入文本内部(行中间)→ 后续打字插到中间(bug)。
134
153
  cc = before.length + parts[parts.length - 1].length;
135
154
  }
136
- /** 粘贴结束:长粘贴(>8 行或 >400 字符)落/并进 chip(原子,整段封预览),短粘贴落为可编辑文本。 */
137
- function finalizePaste() {
138
- if (resolved) {
139
- pasteParts = [];
140
- return;
141
- }
142
- const buf = pasteParts.join('');
143
- pasteParts = [];
144
- justSawCR = false;
155
+ /** 落一段粘贴文本(长/短判定与 chip 逻辑,finalizePaste 与鼠标点击贴入共用)
156
+ * 长粘贴落 chip 时不再吞掉用户已打的字:光标前的文本并入 chipPre(原样保留,与 chip 一起原子化),
157
+ * 光标后的文本留在 suffix(lines)——即"前段文字 [长文本…] 后段文字"而非清空覆盖。 */
158
+ function applyPastedText(buf) {
145
159
  if (!buf)
146
160
  return;
147
161
  const isLong = buf.split('\n').length > 8 || buf.length > 400;
148
162
  if (isLong) {
149
- chip = chip == null ? buf : chip + '\n' + buf; // 多块粘贴:并进同一 chip
150
- lines = [''];
163
+ const before = lines[cl].slice(0, cc);
164
+ const after = lines[cl].slice(cc);
165
+ if (chip == null) {
166
+ // 首次起 chip:光标前的行(含之前的整行)转入 chipPre,光标后的部分留作 suffix 首行。
167
+ chipPre = [...lines.slice(0, cl), before].join('\n');
168
+ chip = buf;
169
+ }
170
+ else {
171
+ // 已有 chip:光标前若已打了字(chip 之后、本次粘贴之前),并入 chip 尾部保留(不丢字),
172
+ // 光标后的部分仍留作 suffix 首行。
173
+ chip = before ? chip + '\n' + before + '\n' + buf : chip + '\n' + buf;
174
+ }
175
+ lines = [after, ...lines.slice(cl + 1)];
151
176
  cl = 0;
152
177
  cc = 0;
153
178
  }
@@ -157,6 +182,23 @@ export async function promptWithSlashMenu(opts) {
157
182
  computeFiltered();
158
183
  redraw();
159
184
  }
185
+ /** 粘贴结束:长粘贴(>8 行或 >400 字符)落/并进 chip(原子,整段封预览),短粘贴落为可编辑文本。 */
186
+ function finalizePaste() {
187
+ if (resolved) {
188
+ pasteParts = [];
189
+ return;
190
+ }
191
+ const buf = pasteParts.join('');
192
+ pasteParts = [];
193
+ justSawCR = false;
194
+ applyPastedText(buf);
195
+ }
196
+ /** 鼠标右键单击输入框(未拖动)时 layout 读剪贴板后回调:与键盘粘贴走同一落地逻辑(长/短判定)。 */
197
+ function onMousePaste(text) {
198
+ if (resolved)
199
+ return;
200
+ applyPastedText(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
201
+ }
160
202
  function computeFiltered() {
161
203
  if (cl === 0 && lines[0].startsWith('/')) {
162
204
  filtered = opts.commands.filter((c) => c.name.startsWith(lines[0]));
@@ -173,12 +215,13 @@ export async function promptWithSlashMenu(opts) {
173
215
  layout.paintInput({
174
216
  prompt: opts.prompt,
175
217
  lines: dispLines(),
176
- cursorLine: cl,
218
+ cursorLine: dispCursorLine(),
177
219
  cursorCol: dispCursorCol(),
178
220
  menu: menuLines().length ? { lines: menuLines() } : null,
179
221
  });
180
222
  }
181
223
  function cleanup() {
224
+ layout.setPasteHandler(null);
182
225
  try {
183
226
  stdin.setRawMode(false);
184
227
  }
@@ -202,7 +245,7 @@ export async function promptWithSlashMenu(opts) {
202
245
  cleanup();
203
246
  resolve(value);
204
247
  }
205
- /** 提交:菜单打开时先补全选中项到第 0 行。chip suffix 拼回全文(chip 在前,换行接 suffix)。 */
248
+ /** 提交:菜单打开时先补全选中项到第 0 行。chipPre + chip + suffix 依次拼回全文(保留粘贴前后原有文字)。 */
206
249
  function submit() {
207
250
  if (menuOpen && filtered[selected]) {
208
251
  lines = [filtered[selected].name];
@@ -210,7 +253,12 @@ export async function promptWithSlashMenu(opts) {
210
253
  cc = lines[0].length;
211
254
  }
212
255
  const suffix = lines.join('\n');
213
- const content = chip ? (suffix.length > 0 ? chip + '\n' + suffix : chip) : suffix;
256
+ let content = suffix;
257
+ if (chip) {
258
+ content = suffix.length > 0 ? chip + '\n' + suffix : chip;
259
+ if (chipPre)
260
+ content = chipPre + '\n' + content;
261
+ }
214
262
  finish(content === '' ? [''] : content.split('\n'));
215
263
  }
216
264
  /** 插换行:在光标处断行。 */
@@ -231,6 +279,7 @@ export async function promptWithSlashMenu(opts) {
231
279
  cl = 0;
232
280
  cc = 0;
233
281
  chip = null;
282
+ chipPre = '';
234
283
  menuOpen = false;
235
284
  filtered = [];
236
285
  selected = 0;
@@ -261,6 +310,10 @@ export async function promptWithSlashMenu(opts) {
261
310
  function onKey(_str, key) {
262
311
  if (resolved || !key)
263
312
  return;
313
+ // 鼠标 fragment(SGR 报表被 readline 拆碎):mouse.swallow 重组并派发 MouseEvent 给
314
+ // layout.handleMouseEvent(滚轮/框选/复制全在那处理);此处只需吞掉 fragment 不进 pasteParts/输入框。
315
+ if (mouse.swallow(key.sequence ?? ''))
316
+ return;
264
317
  // Shift+Tab:循环切换 agent 模式(auto ↔ plan)。不插字符、不提交、不影响输入文本;
265
318
  // 回调由 repl 注入(翻 agentMode + 重写 history[0] + 设状态行 modeTag),再 redraw() 经
266
319
  // paintInput 重画底栏(状态行 chip 即时刷新 + 光标留输入框)。置于 case 'tab' 之前,故不触发菜单补全。
@@ -370,8 +423,14 @@ export async function promptWithSlashMenu(opts) {
370
423
  redraw();
371
424
  }
372
425
  else if (chip) {
373
- // 光标在 suffix 开头(紧贴 ] 后):退格删整个 chip(原子)
426
+ // 光标在 suffix 开头(紧贴 ] 后):退格删整个 chip(原子),chipPre 还原为可编辑行,
427
+ // 光标落在 chipPre 末尾(紧接原来打的字继续编辑)。
428
+ const preLines = chipPre ? chipPre.split('\n') : [''];
429
+ lines = [...preLines.slice(0, -1), preLines[preLines.length - 1] + lines[0], ...lines.slice(1)];
430
+ cl = preLines.length - 1;
431
+ cc = preLines[preLines.length - 1].length;
374
432
  chip = null;
433
+ chipPre = '';
375
434
  computeFiltered();
376
435
  redraw();
377
436
  }
@@ -466,10 +525,11 @@ export async function promptWithSlashMenu(opts) {
466
525
  rawOk = false;
467
526
  }
468
527
  if (!rawOk) {
469
- // isTTY 但 setRawMode 失败(罕见):退化为 readline
528
+ // isTTY 但 setRawMode 失败(罕见):退化为 readline,不注册 setPasteHandler(不走 cleanup,防泄漏)
470
529
  questionFallback(opts.prompt).then((a) => res([a]), (e) => rej(e));
471
530
  return;
472
531
  }
532
+ layout.setPasteHandler(onMousePaste); // 鼠标右键单击输入框(未拖动)→ 读剪贴板贴入;cleanup 时注销
473
533
  stdin.resume();
474
534
  emitter.on('keypress', onKey);
475
535
  computeFiltered();
@@ -523,6 +583,7 @@ export async function promptTurnPicker(items) {
523
583
  });
524
584
  }
525
585
  function cleanup() {
586
+ layout.setMouseEnabled(true); // 恢复鼠标框选/滚轮(面板期间被禁,防拖拽覆盖菜单)
526
587
  try {
527
588
  stdin.setRawMode(false);
528
589
  }
@@ -542,6 +603,8 @@ export async function promptTurnPicker(items) {
542
603
  function onKey(_str, key) {
543
604
  if (resolved || !key)
544
605
  return;
606
+ if (mouse.swallow(key.sequence ?? ''))
607
+ return; // 鼠标 fragment 吞掉(框选已禁,滚轮 handleMouseEvent no-op)
545
608
  if (key.ctrl && key.name === 'c') {
546
609
  cleanup();
547
610
  reject(new Error('SIGINT'));
@@ -572,6 +635,7 @@ export async function promptTurnPicker(items) {
572
635
  return new Promise((res, rej) => {
573
636
  resolve = res;
574
637
  reject = rej;
638
+ layout.setMouseEnabled(false); // 面板期间禁鼠标框选/滚轮(防拖拽 viewport 重画覆盖菜单)
575
639
  ensurePasteDetector();
576
640
  readline.emitKeypressEvents(stdin);
577
641
  let rawOk = true;
@@ -660,6 +724,7 @@ export async function promptSessionPicker(items, recentCap = 10) {
660
724
  });
661
725
  }
662
726
  function cleanup() {
727
+ layout.setMouseEnabled(true);
663
728
  try {
664
729
  stdin.setRawMode(false);
665
730
  }
@@ -679,6 +744,8 @@ export async function promptSessionPicker(items, recentCap = 10) {
679
744
  function onKey(_str, key) {
680
745
  if (resolved || !key)
681
746
  return;
747
+ if (mouse.swallow(key.sequence ?? ''))
748
+ return;
682
749
  if (key.ctrl && key.name === 'c') {
683
750
  cleanup();
684
751
  reject(new Error('SIGINT'));
@@ -719,6 +786,7 @@ export async function promptSessionPicker(items, recentCap = 10) {
719
786
  return new Promise((res, rej) => {
720
787
  resolve = res;
721
788
  reject = rej;
789
+ layout.setMouseEnabled(false);
722
790
  ensurePasteDetector();
723
791
  readline.emitKeypressEvents(stdin);
724
792
  let rawOk = true;
@@ -794,6 +862,7 @@ export async function promptThemePicker(items) {
794
862
  });
795
863
  }
796
864
  function cleanup() {
865
+ layout.setMouseEnabled(true);
797
866
  try {
798
867
  stdin.setRawMode(false);
799
868
  }
@@ -813,6 +882,8 @@ export async function promptThemePicker(items) {
813
882
  function onKey(_str, key) {
814
883
  if (resolved || !key)
815
884
  return;
885
+ if (mouse.swallow(key.sequence ?? ''))
886
+ return;
816
887
  if (key.ctrl && key.name === 'c') {
817
888
  cleanup();
818
889
  reject(new Error('SIGINT'));
@@ -844,6 +915,7 @@ export async function promptThemePicker(items) {
844
915
  return new Promise((res, rej) => {
845
916
  resolve = res;
846
917
  reject = rej;
918
+ layout.setMouseEnabled(false);
847
919
  ensurePasteDetector();
848
920
  readline.emitKeypressEvents(stdin);
849
921
  let rawOk = true;
@@ -904,6 +976,7 @@ export async function promptRevertChoice(fileCount) {
904
976
  });
905
977
  }
906
978
  function cleanup() {
979
+ layout.setMouseEnabled(true);
907
980
  try {
908
981
  stdin.setRawMode(false);
909
982
  }
@@ -923,6 +996,8 @@ export async function promptRevertChoice(fileCount) {
923
996
  function onKey(_str, key) {
924
997
  if (resolved || !key)
925
998
  return;
999
+ if (mouse.swallow(key.sequence ?? ''))
1000
+ return;
926
1001
  if (key.ctrl && key.name === 'c') {
927
1002
  cleanup();
928
1003
  reject(new Error('SIGINT'));
@@ -953,6 +1028,7 @@ export async function promptRevertChoice(fileCount) {
953
1028
  return new Promise((res, rej) => {
954
1029
  resolve = res;
955
1030
  reject = rej;
1031
+ layout.setMouseEnabled(false);
956
1032
  ensurePasteDetector();
957
1033
  readline.emitKeypressEvents(stdin);
958
1034
  let rawOk = true;
package/dist/ui/render.js CHANGED
@@ -82,6 +82,23 @@ export function fmtElapsed(ms) {
82
82
  export function stripAnsi(s) {
83
83
  return s.replace(/\x1b\[[0-9;]*m/g, '');
84
84
  }
85
+ /** 按显示列范围 [start, end) 截取纯文本(不含 ANSI,按 charWidth 计位,CJK/宽字符占 2)。
86
+ * 跨宽字符边界时整字符归入(w+cw>start 即含入),供鼠标选区文本提取用。 */
87
+ export function sliceByDisplayCol(str, start, end) {
88
+ if (start >= end)
89
+ return '';
90
+ let w = 0;
91
+ let out = '';
92
+ for (const ch of str) {
93
+ if (w >= end)
94
+ break;
95
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
96
+ if (w + cw > start)
97
+ out += ch;
98
+ w += cw;
99
+ }
100
+ return out;
101
+ }
85
102
  /** 带色串的可见显示宽度(先去 ANSI 再按 displayWidth 度量)。 */
86
103
  export function ansiDisplayWidth(s) {
87
104
  return displayWidth(stripAnsi(s));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.1.10",
3
+ "version": "0.2.0",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {