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/README.md +139 -137
- package/README.zh-CN.md +202 -0
- package/dist/repl/index.js +12 -2
- package/dist/ui/clipboard.js +118 -0
- package/dist/ui/content.js +5 -0
- package/dist/ui/intervention.js +9 -1
- package/dist/ui/layout.js +238 -12
- package/dist/ui/mouse.js +67 -70
- package/dist/ui/prompt.js +98 -22
- package/dist/ui/render.js +17 -0
- package/package.json +1 -1
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
|
|
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
|
-
/**
|
|
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 =
|
|
115
|
-
|
|
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
|
|
133
|
+
/** 供 layout 定位光标列:chip 在合并行(cl===0)时偏移 chipPre 末行宽度 + chipPrefix 宽度。 */
|
|
118
134
|
function dispCursorCol() {
|
|
119
|
-
|
|
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
|
-
/**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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));
|