mocode-ai 0.1.9 → 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/mouse.js CHANGED
@@ -1,93 +1,90 @@
1
- // SGR 鼠标报表重组器(叶子,仅正则,无依赖)。
1
+ // SGR 鼠标报表重组器 + 事件分发(叶子:仅正则 + 回调,无 UI 依赖)。
2
2
  //
3
- // 背景:layout 进 alt 屏时发 \x1B[?1000h + \x1B[?1006h SGR 鼠标追踪,终端把滚轮/点击发为
4
- // \x1B[<button;col;rowM(按下/滚轮/拖动)或 m(释放)。但 Node readline 的 emitKeypressEvents
5
- // 不认 `<` CSI 参数字节,把一条报表拆成 `\x1b[<` + 逐字符 共 9 个 keypress(数字/`;`/`M` 有
6
- // 可打印 name,会被当文本砸进输入框)。本模块在 keypress 层重组:每个 keypress key.sequence
7
- // 喂入 consumeMouse,内部状态机把 fragment 拼回完整报表,期间所有 fragment 返回 suppress=true
8
- // 让调用方吞掉,完整报表解出滚轮方向后返回 wheel(±1)由调用方调 layout.scrollBy(wheel*5)。
3
+ // 背景:layout 进 alt 屏时发 \x1B[?1000h + \x1B[?1002h + \x1B[?1006h,启用:
4
+ // 1000 = 按键事件追踪(按下 / 释放),1002 = 拖动追踪(按住键移动时上报 motion),
5
+ // 1006 = SGR 编码 \x1B[<btn;col;rowM(按下 / 拖动)或 \x1B[<btn;col;rowm(释放)。
6
+ // 有了 1002 的拖动上报,才能实现"按住左键拖过多屏 应用层维护选区 松开复制"(仿 Claude Code)。
9
7
  //
10
- // 触发可靠:首 fragment `\x1b[<` SGR 鼠标独有——真 Esc key.name==='escape'、sequence==='\x1b';
11
- // 人按 Esc 再按 < 得 `\x1b<`(meta-<),不进 CSI 分支。故 `\x1b[<` keypress 只能是 SGR 报表开头。
12
- // 后续 fragment 同步背靠背到达(终端一次发整条报表,readline 在同一 onData chunk 内同步抛完)。
13
- /** 单条完整报表(非 g:用于 test,不推进 lastIndex);捕获 button。 */
14
- const REPORT_RE = /\x1b\[<(\d+);\d+;\d+[Mm]/;
15
- /** 全局报表(用于 matchAll 取所有完整报表,取末条)。 */
16
- const REPORT_RE_G = /\x1b\[<(\d+);\d+;\d+[Mm]/g;
8
+ // Node readline emitKeypressEvents 不认 `<` CSI 参数字节,把一条报表拆成 `\x1b[<` +
9
+ // 逐字符共 ≥9 keypress(数字 / `;` / `M` 有可打印 name,会被当文本砸进输入框)。故本模块在
10
+ // keypress 层重组:每个 keypress key.sequence 喂入 swallow,内部状态机把 fragment 拼回完整报表,
11
+ // 期间所有 fragment 返回 true(调用方须 return 吞掉,防砸进输入框);报表拼齐后解析成结构化
12
+ // MouseEvent 派发给已注册的 handler(layout 注册,做滚动 / 选区 / 复制)
13
+ //
14
+ // 触发可靠:首 fragment `\x1b[<` 是 SGR 鼠标独有——真 Esc 的 sequence==='\x1b';人按 Esc 再按 <
15
+ // 得 `\x1b<`(meta-<),不进 CSI 分支。故 `\x1b[<` 开头只能是 SGR 报表。
16
+ /** 单条完整报表(捕获 button / col / row / 终止符 M|m)。 */
17
+ const REPORT_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/;
18
+ /** 全局版:一个 chunk 里可能背靠背多条(拖动 motion 连发),matchAll 取全部。 */
19
+ const REPORT_RE_G = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
17
20
  /** 收集中 buf 允许的形状:\x1b[< 后跟 [0-9;] 任意个,末尾可选一个 M/m(未完成或刚完成)。 */
18
21
  const PARTIAL_RE = /^\x1b\[<[0-9;]*[Mm]?$/;
19
22
  const START = '\x1b[<';
20
- /** button & 64 为滚轮:64=上(+1)、65=下(-1);其余(点击/拖动/移动/释放)→ 0。 */
21
- function wheelDir(button) {
22
- if (!(button & 64))
23
- return 0;
24
- return button & 1 ? -1 : +1;
25
- }
26
- /**
27
- * 纯函数:从可能含 ≥1 条完整报表的串里取末条滚轮方向(+1 上 / -1 下 / 0 无或非滚轮)。
28
- * 供"整体抛出整条报表"的前向兼容分支与测试用(当前 Node 拆碎,正常路径走 consumeMouse)。
29
- */
30
- export function wheelFromReport(seq) {
31
- let last = 0;
32
- for (const m of seq.matchAll(REPORT_RE_G)) {
33
- last = wheelDir(Number(m[1]));
34
- }
35
- return last;
36
- }
23
+ let handler = null;
37
24
  let collecting = false;
38
25
  let buf = '';
39
- let stallTimer = null;
40
- function resetState() {
41
- collecting = false;
42
- buf = '';
43
- if (stallTimer) {
44
- clearTimeout(stallTimer);
45
- stallTimer = null;
46
- }
26
+ /** 注册事件回调(layout alt 屏时注册)。 */
27
+ export function setHandler(fn) {
28
+ handler = fn;
47
29
  }
48
30
  /** 复位内部收集状态(退出 alt 屏 / 测试间清污染)。 */
49
31
  export function resetMouse() {
50
- resetState();
32
+ collecting = false;
33
+ buf = '';
34
+ }
35
+ /** button 位:64=滚轮(&1 区分上下),32=拖动 motion,低 2 位=键(0=左,1=中,2=右)。 */
36
+ function emit(button, col, row, term) {
37
+ if (!handler)
38
+ return;
39
+ if (button & 64) {
40
+ handler({ type: 'wheel', dir: button & 1 ? -1 : +1 });
41
+ return;
42
+ }
43
+ const btn = button & 3;
44
+ if (term === 'm') {
45
+ handler({ type: 'release', col, row, button: btn });
46
+ return;
47
+ }
48
+ if (button & 32) {
49
+ handler({ type: 'drag', col, row, button: btn });
50
+ return;
51
+ }
52
+ handler({ type: 'press', col, row, button: btn });
51
53
  }
52
- function armStall() {
53
- if (stallTimer)
54
- clearTimeout(stallTimer);
55
- // 100ms 内未收齐视为畸形/丢失,丢半截(终端报表原子发出,跨 chunk >100ms 罕见;有界安全)
56
- const t = setTimeout(resetState, 100);
57
- t.unref();
58
- stallTimer = t;
54
+ /** 解出串中所有完整报表并按序派发(拖动 motion 一个 chunk 多条时全处理) */
55
+ function dispatchAll(s) {
56
+ for (const m of s.matchAll(REPORT_RE_G)) {
57
+ emit(Number(m[1]), Number(m[2]), Number(m[3]), m[4]);
58
+ }
59
59
  }
60
60
  /**
61
- * 喂入一个 keypress 的 key.sequence,返回 { suppress, wheel }
62
- * - suppress=true:本 keypress 是鼠标 fragment(或完整报表),调用方须 return 吞掉。
63
- * - wheel≠0:刚重组出一条完整滚轮报表,调用方调 layout.scrollBy(wheel*5)
64
- * - suppress=false:非鼠标,放行正常处理。
61
+ * 喂入一个 keypress 的 key.sequence。
62
+ * 返回 true=本 keypress 是鼠标 fragment(或完整报表),调用方须 return 吞掉;
63
+ * 返回 false=非鼠标,放行正常处理。完整报表拼齐时同步派发 MouseEvent 给 handler
65
64
  */
66
- export function consumeMouse(seq) {
65
+ export function swallow(seq) {
66
+ if (!seq)
67
+ return false;
67
68
  if (!collecting) {
68
69
  if (!seq.startsWith(START))
69
- return { wheel: 0, suppress: false };
70
- // 前向兼容:若未来 Node 整体抛整条报表(含完整 \x1B[<…M),直接解。
71
- if (REPORT_RE.test(seq))
72
- return { wheel: wheelFromReport(seq), suppress: true };
73
- // 开收:首 fragment(典型 readline 的 `\x1b[<`,或 `\x1b[<64;5;` 半截)。
70
+ return false;
71
+ // keypress 已含完整报表(未来 Node 可能整体抛):直接解。
72
+ if (REPORT_RE.test(seq)) {
73
+ dispatchAll(seq);
74
+ return true;
75
+ }
74
76
  collecting = true;
75
77
  buf = seq;
76
- armStall();
77
- return { wheel: 0, suppress: true };
78
+ return true;
78
79
  }
79
- // 收集中:追加。
80
80
  buf += seq;
81
81
  if (REPORT_RE.test(buf)) {
82
- const wheel = wheelFromReport(buf);
83
- resetState();
84
- return { wheel, suppress: true };
85
- }
86
- if (PARTIAL_RE.test(buf)) {
87
- // 合法未完成形状(无 M/m 终止)—— 继续等下一 fragment。
88
- return { wheel: 0, suppress: true };
82
+ dispatchAll(buf);
83
+ resetMouse();
84
+ return true;
89
85
  }
90
- // 偏离合法形状:畸形垃圾(报表跨 chunk 超时/编码异常)。丢半截,吞掉不打扰。
91
- resetState();
92
- return { wheel: 0, suppress: true };
86
+ if (PARTIAL_RE.test(buf))
87
+ return true; // 合法未完成形状,继续等
88
+ resetMouse(); // 偏离合法形状:畸形垃圾,丢半截,吞掉不打扰
89
+ return true;
93
90
  }
package/dist/ui/prompt.js CHANGED
@@ -20,8 +20,8 @@ function ensurePasteDetector() {
20
20
  pasteDetectorInstalled = true;
21
21
  stdin.on('data', (chunk) => {
22
22
  const raw = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
23
- // 剔除 SGR 鼠标报告(\x1B[<…M/m,连半截也匹配):防快速滚轮/触控板动量在一个 chunk 里拼 ≥2 条
24
- // 报表(22 字符 > 16 阈值)误判粘贴 50ms、把后续真按键泄进 pasteParts。
23
+ // 剔除 SGR 鼠标报告(\x1B[<…M/m,连半截也匹配):拖拽选区时终端连续发 motion 报表,
24
+ // 一个 chunk 里可能拼多条(22+ 字符 > 16 阈值)误判粘贴 50ms、把后续真按键泄进 pasteParts。
25
25
  const text = raw.replace(/\x1b\[<[0-9;]*[Mm]/g, '');
26
26
  const hasNL = text.indexOf('\r') >= 0 || text.indexOf('\n') >= 0;
27
27
  // 按字符数(码点)判粘贴,非字节:CJK 汉字占 3 UTF-8 字节,旧阈值(len>8 字节)会把 IME 提交的
@@ -80,6 +80,7 @@ export async function promptWithSlashMenu(opts) {
80
80
  let cc = lines[cl].length; // 光标在该行的字符索引 = 末行末尾
81
81
  let justSawCR = false; // \r\n 合一:粘贴的 \r 折行后,紧跟的 \n 吞掉(避免折两行)
82
82
  let chip = null; // 原子粘贴块:整段封进 [预览…],不可编辑;提交时拼回全文
83
+ let chipPre = ''; // chip 之前已存在的文本(粘贴发生时光标前的原文,原样多行保留,不截断);随 chip 一起提交/删除
83
84
  let menuOpen = false;
84
85
  let selected = 0;
85
86
  let filtered = [];
@@ -111,16 +112,30 @@ export async function promptWithSlashMenu(opts) {
111
112
  function chipPrefix() {
112
113
  return chip ? `[${truncateDisplay(chip.split('\n').join(' '), 20)}] ` : '';
113
114
  }
114
- /** 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)。 */
115
121
  function dispLines() {
116
122
  if (!chip)
117
123
  return lines;
118
- const pre = chipPrefix();
119
- 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;
120
132
  }
121
- /** 供 layout 定位光标列:chip 在第 0 行时偏移 chipPrefix 宽度(suffix 光标始终在 chip 之后)。 */
133
+ /** 供 layout 定位光标列:chip 在合并行(cl===0)时偏移 chipPre 末行宽度 + chipPrefix 宽度。 */
122
134
  function dispCursorCol() {
123
- 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();
124
139
  }
125
140
  /** 在光标处插入文本(含换行则拆行)。供短粘贴 finalize 落为可编辑文本。 */
126
141
  function insertText(text) {
@@ -137,21 +152,27 @@ export async function promptWithSlashMenu(opts) {
137
152
  // 旧值 parts[...].length 漏算 before → 光标落到插入文本内部(行中间)→ 后续打字插到中间(bug)。
138
153
  cc = before.length + parts[parts.length - 1].length;
139
154
  }
140
- /** 粘贴结束:长粘贴(>8 行或 >400 字符)落/并进 chip(原子,整段封预览),短粘贴落为可编辑文本。 */
141
- function finalizePaste() {
142
- if (resolved) {
143
- pasteParts = [];
144
- return;
145
- }
146
- const buf = pasteParts.join('');
147
- pasteParts = [];
148
- justSawCR = false;
155
+ /** 落一段粘贴文本(长/短判定与 chip 逻辑,finalizePaste 与鼠标点击贴入共用)
156
+ * 长粘贴落 chip 时不再吞掉用户已打的字:光标前的文本并入 chipPre(原样保留,与 chip 一起原子化),
157
+ * 光标后的文本留在 suffix(lines)——即"前段文字 [长文本…] 后段文字"而非清空覆盖。 */
158
+ function applyPastedText(buf) {
149
159
  if (!buf)
150
160
  return;
151
161
  const isLong = buf.split('\n').length > 8 || buf.length > 400;
152
162
  if (isLong) {
153
- chip = chip == null ? buf : chip + '\n' + buf; // 多块粘贴:并进同一 chip
154
- 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)];
155
176
  cl = 0;
156
177
  cc = 0;
157
178
  }
@@ -161,6 +182,23 @@ export async function promptWithSlashMenu(opts) {
161
182
  computeFiltered();
162
183
  redraw();
163
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
+ }
164
202
  function computeFiltered() {
165
203
  if (cl === 0 && lines[0].startsWith('/')) {
166
204
  filtered = opts.commands.filter((c) => c.name.startsWith(lines[0]));
@@ -177,12 +215,13 @@ export async function promptWithSlashMenu(opts) {
177
215
  layout.paintInput({
178
216
  prompt: opts.prompt,
179
217
  lines: dispLines(),
180
- cursorLine: cl,
218
+ cursorLine: dispCursorLine(),
181
219
  cursorCol: dispCursorCol(),
182
220
  menu: menuLines().length ? { lines: menuLines() } : null,
183
221
  });
184
222
  }
185
223
  function cleanup() {
224
+ layout.setPasteHandler(null);
186
225
  try {
187
226
  stdin.setRawMode(false);
188
227
  }
@@ -206,7 +245,7 @@ export async function promptWithSlashMenu(opts) {
206
245
  cleanup();
207
246
  resolve(value);
208
247
  }
209
- /** 提交:菜单打开时先补全选中项到第 0 行。chip suffix 拼回全文(chip 在前,换行接 suffix)。 */
248
+ /** 提交:菜单打开时先补全选中项到第 0 行。chipPre + chip + suffix 依次拼回全文(保留粘贴前后原有文字)。 */
210
249
  function submit() {
211
250
  if (menuOpen && filtered[selected]) {
212
251
  lines = [filtered[selected].name];
@@ -214,7 +253,12 @@ export async function promptWithSlashMenu(opts) {
214
253
  cc = lines[0].length;
215
254
  }
216
255
  const suffix = lines.join('\n');
217
- 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
+ }
218
262
  finish(content === '' ? [''] : content.split('\n'));
219
263
  }
220
264
  /** 插换行:在光标处断行。 */
@@ -235,6 +279,7 @@ export async function promptWithSlashMenu(opts) {
235
279
  cl = 0;
236
280
  cc = 0;
237
281
  chip = null;
282
+ chipPre = '';
238
283
  menuOpen = false;
239
284
  filtered = [];
240
285
  selected = 0;
@@ -265,14 +310,10 @@ export async function promptWithSlashMenu(opts) {
265
310
  function onKey(_str, key) {
266
311
  if (resolved || !key)
267
312
  return;
268
- // SGR 鼠标滚轮:readline \x1B[<btn;col;rowM 拆成多 fragment,经 mouse.consumeMouse 重组;
269
- // 滚轮 → scrollBy(±5)(只重画内容区,底栏不动)。置于 pasting 之前——鼠标 fragment 永不进 pasteParts
270
- const _m = mouse.consumeMouse(key.sequence ?? '');
271
- if (_m.suppress) {
272
- if (_m.wheel)
273
- layout.scrollBy(_m.wheel * 5);
313
+ // 鼠标 fragment(SGR 报表被 readline 拆碎):mouse.swallow 重组并派发 MouseEvent
314
+ // layout.handleMouseEvent(滚轮/框选/复制全在那处理);此处只需吞掉 fragment 不进 pasteParts/输入框。
315
+ if (mouse.swallow(key.sequence ?? ''))
274
316
  return;
275
- }
276
317
  // Shift+Tab:循环切换 agent 模式(auto ↔ plan)。不插字符、不提交、不影响输入文本;
277
318
  // 回调由 repl 注入(翻 agentMode + 重写 history[0] + 设状态行 modeTag),再 redraw() 经
278
319
  // paintInput 重画底栏(状态行 chip 即时刷新 + 光标留输入框)。置于 case 'tab' 之前,故不触发菜单补全。
@@ -306,7 +347,7 @@ export async function promptWithSlashMenu(opts) {
306
347
  }
307
348
  // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑↓ 与 plain ↑/↓ 每次 5 行。
308
349
  // plain ↑/↓ 仅在单行输入且菜单关闭时作滚动(多行编辑留给光标移动,菜单打开留给选项);
309
- // 鼠标滚轮已由 onKey 顶部 SGR 重组守卫处理(不经此分支);此分支仅键盘 plain ↑/↓,放大到 5 行/格。
350
+ // 兼鼠标滚轮——alt 屏内(经 \x1B[?1007h)滚轮转发 ↑/↓,1 行/格太慢故放大到 5
310
351
  const plainArrowScroll = (key.name === 'up' || key.name === 'down') &&
311
352
  !key.ctrl &&
312
353
  !key.meta &&
@@ -382,8 +423,14 @@ export async function promptWithSlashMenu(opts) {
382
423
  redraw();
383
424
  }
384
425
  else if (chip) {
385
- // 光标在 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;
386
432
  chip = null;
433
+ chipPre = '';
387
434
  computeFiltered();
388
435
  redraw();
389
436
  }
@@ -478,10 +525,11 @@ export async function promptWithSlashMenu(opts) {
478
525
  rawOk = false;
479
526
  }
480
527
  if (!rawOk) {
481
- // isTTY 但 setRawMode 失败(罕见):退化为 readline
528
+ // isTTY 但 setRawMode 失败(罕见):退化为 readline,不注册 setPasteHandler(不走 cleanup,防泄漏)
482
529
  questionFallback(opts.prompt).then((a) => res([a]), (e) => rej(e));
483
530
  return;
484
531
  }
532
+ layout.setPasteHandler(onMousePaste); // 鼠标右键单击输入框(未拖动)→ 读剪贴板贴入;cleanup 时注销
485
533
  stdin.resume();
486
534
  emitter.on('keypress', onKey);
487
535
  computeFiltered();
@@ -535,6 +583,7 @@ export async function promptTurnPicker(items) {
535
583
  });
536
584
  }
537
585
  function cleanup() {
586
+ layout.setMouseEnabled(true); // 恢复鼠标框选/滚轮(面板期间被禁,防拖拽覆盖菜单)
538
587
  try {
539
588
  stdin.setRawMode(false);
540
589
  }
@@ -554,6 +603,8 @@ export async function promptTurnPicker(items) {
554
603
  function onKey(_str, key) {
555
604
  if (resolved || !key)
556
605
  return;
606
+ if (mouse.swallow(key.sequence ?? ''))
607
+ return; // 鼠标 fragment 吞掉(框选已禁,滚轮 handleMouseEvent no-op)
557
608
  if (key.ctrl && key.name === 'c') {
558
609
  cleanup();
559
610
  reject(new Error('SIGINT'));
@@ -584,6 +635,7 @@ export async function promptTurnPicker(items) {
584
635
  return new Promise((res, rej) => {
585
636
  resolve = res;
586
637
  reject = rej;
638
+ layout.setMouseEnabled(false); // 面板期间禁鼠标框选/滚轮(防拖拽 viewport 重画覆盖菜单)
587
639
  ensurePasteDetector();
588
640
  readline.emitKeypressEvents(stdin);
589
641
  let rawOk = true;
@@ -672,6 +724,7 @@ export async function promptSessionPicker(items, recentCap = 10) {
672
724
  });
673
725
  }
674
726
  function cleanup() {
727
+ layout.setMouseEnabled(true);
675
728
  try {
676
729
  stdin.setRawMode(false);
677
730
  }
@@ -691,6 +744,8 @@ export async function promptSessionPicker(items, recentCap = 10) {
691
744
  function onKey(_str, key) {
692
745
  if (resolved || !key)
693
746
  return;
747
+ if (mouse.swallow(key.sequence ?? ''))
748
+ return;
694
749
  if (key.ctrl && key.name === 'c') {
695
750
  cleanup();
696
751
  reject(new Error('SIGINT'));
@@ -731,6 +786,7 @@ export async function promptSessionPicker(items, recentCap = 10) {
731
786
  return new Promise((res, rej) => {
732
787
  resolve = res;
733
788
  reject = rej;
789
+ layout.setMouseEnabled(false);
734
790
  ensurePasteDetector();
735
791
  readline.emitKeypressEvents(stdin);
736
792
  let rawOk = true;
@@ -806,6 +862,7 @@ export async function promptThemePicker(items) {
806
862
  });
807
863
  }
808
864
  function cleanup() {
865
+ layout.setMouseEnabled(true);
809
866
  try {
810
867
  stdin.setRawMode(false);
811
868
  }
@@ -825,6 +882,8 @@ export async function promptThemePicker(items) {
825
882
  function onKey(_str, key) {
826
883
  if (resolved || !key)
827
884
  return;
885
+ if (mouse.swallow(key.sequence ?? ''))
886
+ return;
828
887
  if (key.ctrl && key.name === 'c') {
829
888
  cleanup();
830
889
  reject(new Error('SIGINT'));
@@ -856,6 +915,7 @@ export async function promptThemePicker(items) {
856
915
  return new Promise((res, rej) => {
857
916
  resolve = res;
858
917
  reject = rej;
918
+ layout.setMouseEnabled(false);
859
919
  ensurePasteDetector();
860
920
  readline.emitKeypressEvents(stdin);
861
921
  let rawOk = true;
@@ -916,6 +976,7 @@ export async function promptRevertChoice(fileCount) {
916
976
  });
917
977
  }
918
978
  function cleanup() {
979
+ layout.setMouseEnabled(true);
919
980
  try {
920
981
  stdin.setRawMode(false);
921
982
  }
@@ -935,6 +996,8 @@ export async function promptRevertChoice(fileCount) {
935
996
  function onKey(_str, key) {
936
997
  if (resolved || !key)
937
998
  return;
999
+ if (mouse.swallow(key.sequence ?? ''))
1000
+ return;
938
1001
  if (key.ctrl && key.name === 'c') {
939
1002
  cleanup();
940
1003
  reject(new Error('SIGINT'));
@@ -965,6 +1028,7 @@ export async function promptRevertChoice(fileCount) {
965
1028
  return new Promise((res, rej) => {
966
1029
  resolve = res;
967
1030
  reject = rej;
1031
+ layout.setMouseEnabled(false);
968
1032
  ensurePasteDetector();
969
1033
  readline.emitKeypressEvents(stdin);
970
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,40 +1,40 @@
1
- {
2
- "name": "mocode-ai",
3
- "version": "0.1.9",
4
- "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
- "type": "module",
6
- "bin": {
7
- "mocode": "bin/mocode.js"
8
- },
9
- "files": [
10
- "dist",
11
- "bin",
12
- "README.md"
13
- ],
14
- "engines": {
15
- "node": ">=18"
16
- },
17
- "scripts": {
18
- "start": "tsx src/index.ts",
19
- "build": "tsc -p tsconfig.build.json",
20
- "typecheck": "tsc --noEmit",
21
- "prepare": "npm run build"
22
- },
23
- "dependencies": {
24
- "cli-highlight": "^2.1.11",
25
- "dotenv": "^16.0.0",
26
- "fast-glob": "^3.0.0",
27
- "openai": "^4.0.0"
28
- },
29
- "devDependencies": {
30
- "@types/node": "^22.0.0",
31
- "tsx": "^4.0.0",
32
- "typescript": "^5.0.0"
33
- },
34
- "repository": {
35
- "type": "git",
36
- "url": "git+https://github.com/wanxunyang/mocode.git"
37
- },
38
- "author": "wxy",
39
- "license": "MIT"
40
- }
1
+ {
2
+ "name": "mocode-ai",
3
+ "version": "0.2.0",
4
+ "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
+ "type": "module",
6
+ "bin": {
7
+ "mocode": "bin/mocode.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "bin",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "scripts": {
18
+ "start": "tsx src/index.ts",
19
+ "build": "tsc -p tsconfig.build.json",
20
+ "typecheck": "tsc --noEmit",
21
+ "prepare": "npm run build"
22
+ },
23
+ "dependencies": {
24
+ "cli-highlight": "^2.1.11",
25
+ "dotenv": "^16.0.0",
26
+ "fast-glob": "^3.0.0",
27
+ "openai": "^4.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.0.0",
31
+ "tsx": "^4.0.0",
32
+ "typescript": "^5.0.0"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/wanxunyang/mocode.git"
37
+ },
38
+ "author": "wxy",
39
+ "license": "MIT"
40
+ }