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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { stdout, platform } from 'node:process';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
/**
|
|
4
|
+
* 系统剪贴板读写(叶子模块,无 UI 依赖)。
|
|
5
|
+
*
|
|
6
|
+
* 写入(copyToClipboard):选区复制(layout 鼠标选择)与 /copy 等命令共用,双通道:
|
|
7
|
+
* 1. OSC 52(`\x1B]52;c;<base64>\x07`):请求终端把文本写入系统剪贴板。经终端转发,SSH / 远程也生效;
|
|
8
|
+
* Windows Terminal ≥1.16、iTerm2、kitty、WezTerm 等支持(部分终端默认关,需开"允许应用访问剪贴板")。
|
|
9
|
+
* 2. 本地原生工具(best-effort,失败静默):win32=clip.exe(UTF-16LE)、darwin=pbcopy、
|
|
10
|
+
* linux=wl-copy / xclip / xsel。覆盖 OSC52 被禁用的场景(如部分 VS Code 集成终端配置)。
|
|
11
|
+
* 两条都发:哪条生效由环境决定,重复写同一内容无副作用。
|
|
12
|
+
*
|
|
13
|
+
* 读取(readClipboard):OSC 52 是单向的(终端不会把剪贴板内容回传给应用,即便发 `\x1B]52;c;?\x07`
|
|
14
|
+
* 请求读取,多数终端出于安全考虑不响应),故读只能靠本地原生工具:win32=PowerShell Get-Clipboard、
|
|
15
|
+
* darwin=pbpaste、linux=wl-paste / xclip -o / xsel -o。供鼠标点击输入框时"贴入"用。
|
|
16
|
+
*/
|
|
17
|
+
/** OSC 52:base64(UTF-8) 写系统剪贴板(c=clipboard 选区)。 */
|
|
18
|
+
function osc52(text) {
|
|
19
|
+
const b64 = Buffer.from(text, 'utf8').toString('base64');
|
|
20
|
+
// 长度保护:部分终端对 OSC52 载荷有上限(常见 ~74KB / 100KB),超限直接跳过 OSC52
|
|
21
|
+
// (仍走原生通道),避免半截 base64 污染终端。
|
|
22
|
+
if (b64.length > 100000)
|
|
23
|
+
return;
|
|
24
|
+
stdout.write(`\x1B]52;c;${b64}\x07`);
|
|
25
|
+
}
|
|
26
|
+
/** spawn 一个吃 stdin 的剪贴板工具,把 buf 写入其 stdin;所有错误静默(best-effort)。 */
|
|
27
|
+
function pipeTo(cmd, args, buf) {
|
|
28
|
+
try {
|
|
29
|
+
const p = spawn(cmd, args, {
|
|
30
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
});
|
|
33
|
+
p.on('error', () => { }); // 命令不存在等:静默(OSC52 兜底)
|
|
34
|
+
p.stdin.on('error', () => { });
|
|
35
|
+
p.stdin.end(buf);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// 忽略
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** 本地原生剪贴板(best-effort)。 */
|
|
42
|
+
function nativeCopy(text) {
|
|
43
|
+
if (platform === 'win32') {
|
|
44
|
+
// clip.exe 在 Win10+ 接受 UTF-16LE 字节(无 BOM),可正确处理中文/emoji;
|
|
45
|
+
// 直接管 UTF-8 会按控制台代码页解码致乱码,故用 utf16le。
|
|
46
|
+
pipeTo('clip', [], Buffer.from(text, 'utf16le'));
|
|
47
|
+
}
|
|
48
|
+
else if (platform === 'darwin') {
|
|
49
|
+
pipeTo('pbcopy', [], Buffer.from(text, 'utf8'));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// linux:Wayland 优先 wl-copy,X11 退 xclip / xsel(装了哪个用哪个;spawn error 静默跳过)
|
|
53
|
+
const b = Buffer.from(text, 'utf8');
|
|
54
|
+
pipeTo('wl-copy', [], b);
|
|
55
|
+
pipeTo('xclip', ['-selection', 'clipboard'], b);
|
|
56
|
+
pipeTo('xsel', ['--clipboard', '--input'], b);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** 写入系统剪贴板(OSC52 + 本地原生双通道)。 */
|
|
60
|
+
export function copyToClipboard(text) {
|
|
61
|
+
if (!text)
|
|
62
|
+
return;
|
|
63
|
+
osc52(text);
|
|
64
|
+
nativeCopy(text);
|
|
65
|
+
}
|
|
66
|
+
/** spawn 一个不吃 stdin、把 stdout 收集成字符串的命令;失败返回 null(不抛)。 */
|
|
67
|
+
function captureOutput(cmd, args, encoding) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
try {
|
|
70
|
+
const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
|
|
71
|
+
const chunks = [];
|
|
72
|
+
let settled = false;
|
|
73
|
+
const finish = (v) => {
|
|
74
|
+
if (settled)
|
|
75
|
+
return;
|
|
76
|
+
settled = true;
|
|
77
|
+
resolve(v);
|
|
78
|
+
};
|
|
79
|
+
p.stdout.on('data', (c) => chunks.push(c));
|
|
80
|
+
p.stdout.on('error', () => finish(null));
|
|
81
|
+
p.on('error', () => finish(null));
|
|
82
|
+
p.on('close', (code) => {
|
|
83
|
+
if (code !== 0)
|
|
84
|
+
return finish(null);
|
|
85
|
+
finish(Buffer.concat(chunks).toString(encoding));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
resolve(null);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 读取系统剪贴板(本地原生工具,OSC52 无法读取——终端不回传)。
|
|
95
|
+
* win32 用 PowerShell -NoProfile Get-Clipboard(比 clip.exe 只写无读更可靠,系统自带无需安装);
|
|
96
|
+
* darwin=pbpaste;linux 依次尝试 wl-paste / xclip -o / xsel -o,装了哪个用哪个。
|
|
97
|
+
* 都失败返回空串(不抛),调用方按"无内容可贴"处理。
|
|
98
|
+
*/
|
|
99
|
+
export async function readClipboard() {
|
|
100
|
+
if (platform === 'win32') {
|
|
101
|
+
// Get-Clipboard 默认按控制台代码页输出;显式转 UTF8 避免中文乱码。
|
|
102
|
+
const out = await captureOutput('powershell', ['-NoProfile', '-NonInteractive', '-Command',
|
|
103
|
+
'[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; Get-Clipboard -Raw'], 'utf8');
|
|
104
|
+
return out?.replace(/\r\n/g, '\n').replace(/\r$/, '') ?? '';
|
|
105
|
+
}
|
|
106
|
+
if (platform === 'darwin') {
|
|
107
|
+
return (await captureOutput('pbpaste', [], 'utf8')) ?? '';
|
|
108
|
+
}
|
|
109
|
+
// linux:按顺序试,第一个成功(非 null)即用
|
|
110
|
+
const wl = await captureOutput('wl-paste', ['--no-newline'], 'utf8');
|
|
111
|
+
if (wl != null)
|
|
112
|
+
return wl;
|
|
113
|
+
const xc = await captureOutput('xclip', ['-selection', 'clipboard', '-o'], 'utf8');
|
|
114
|
+
if (xc != null)
|
|
115
|
+
return xc;
|
|
116
|
+
const xs = await captureOutput('xsel', ['--clipboard', '--output'], 'utf8');
|
|
117
|
+
return xs ?? '';
|
|
118
|
+
}
|
package/dist/ui/content.js
CHANGED
|
@@ -104,3 +104,8 @@ export function sliceFromEnd(offset, count) {
|
|
|
104
104
|
export function totalRows() {
|
|
105
105
|
return rows.length + (hasCurrent ? 1 : 0);
|
|
106
106
|
}
|
|
107
|
+
/** 取绝对行索引(0-based,含当前行)的原始自洽行;越界返 null。供鼠标选区文本提取。 */
|
|
108
|
+
export function lineAt(abs) {
|
|
109
|
+
const all = snapshot();
|
|
110
|
+
return abs >= 0 && abs < all.length ? all[abs] : null;
|
|
111
|
+
}
|
package/dist/ui/intervention.js
CHANGED
|
@@ -3,6 +3,7 @@ import { stdin, stderr } from 'node:process';
|
|
|
3
3
|
import { ui } from './theme.js';
|
|
4
4
|
import { displayWidth, truncateDisplay } from './render.js';
|
|
5
5
|
import * as layout from './layout.js';
|
|
6
|
+
import * as mouse from './mouse.js';
|
|
6
7
|
import { Spinner } from './spinner.js';
|
|
7
8
|
const emitter = stdin;
|
|
8
9
|
/** choice 末尾的"自定义输入"项标签(纯 ASCII,宽度安全)。选中后切到 input 子态。 */
|
|
@@ -123,6 +124,7 @@ export async function promptIntervention(req) {
|
|
|
123
124
|
}
|
|
124
125
|
/** 退出:摘自己的监听 + 恢复快照监听 + 擦菜单恢复内容区。不 setRawMode(false)/pause stdin(运行态由 repl 接管)。 */
|
|
125
126
|
function cleanup() {
|
|
127
|
+
layout.setMouseEnabled(true); // 恢复鼠标框选(面板期间禁,防拖拽覆盖菜单)
|
|
126
128
|
emitter.removeListener('keypress', onKey);
|
|
127
129
|
for (const l of savedListeners)
|
|
128
130
|
emitter.on('keypress', l);
|
|
@@ -147,6 +149,10 @@ export async function promptIntervention(req) {
|
|
|
147
149
|
function onKey(_str, key) {
|
|
148
150
|
if (resolved || !key)
|
|
149
151
|
return;
|
|
152
|
+
// 鼠标 fragment:滚轮走 handleMouseEvent(mouseEnabled=true 时仍可滚动查看内容,与之前行为一致);
|
|
153
|
+
// 框选/拖拽在面板期间被 layout.setMouseEnabled(false) 挡掉(防 viewport 重画覆盖菜单)。
|
|
154
|
+
if (mouse.swallow(key.sequence ?? ''))
|
|
155
|
+
return;
|
|
150
156
|
// Ctrl+C → 取消(不 reject SIGINT——否则经 executeTool 的 try/catch 变成 tool 错误串)
|
|
151
157
|
if (key.ctrl && key.name === 'c') {
|
|
152
158
|
finish({ action: 'cancelled' });
|
|
@@ -265,8 +271,9 @@ export async function promptIntervention(req) {
|
|
|
265
271
|
return new Promise((res, rej) => {
|
|
266
272
|
resolve = res;
|
|
267
273
|
try {
|
|
268
|
-
// 进入面板:停 spinner(避免 onFrame 覆盖)+ 回尾(若用户正滚动回看)
|
|
274
|
+
// 进入面板:停 spinner(避免 onFrame 覆盖)+ 禁鼠标框选(防拖拽 viewport 重画覆盖菜单)+ 回尾(若用户正滚动回看)
|
|
269
275
|
Spinner.pauseCurrent();
|
|
276
|
+
layout.setMouseEnabled(false);
|
|
270
277
|
layout.resetScroll();
|
|
271
278
|
// 快照现有 keypress 监听(运行态的 onRunningKey)并摘掉,挂自己的 onKey
|
|
272
279
|
savedListeners = emitter.listeners('keypress').slice();
|
|
@@ -286,6 +293,7 @@ export async function promptIntervention(req) {
|
|
|
286
293
|
}
|
|
287
294
|
catch (e) {
|
|
288
295
|
// 进入失败:必须恢复运行态监听,否则 onRunningKey 残留摘除 → 本 turns 的 Ctrl+C/滚动/typeahead 全废
|
|
296
|
+
layout.setMouseEnabled(true);
|
|
289
297
|
try {
|
|
290
298
|
emitter.removeListener('keypress', onKey);
|
|
291
299
|
}
|
package/dist/ui/layout.js
CHANGED
|
@@ -2,9 +2,11 @@ import { stdin, stdout } from 'node:process';
|
|
|
2
2
|
import { appendFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, } from './render.js';
|
|
5
|
+
import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, stripAnsi, sliceByDisplayCol, } from './render.js';
|
|
6
6
|
import { ui } from './theme.js';
|
|
7
7
|
import * as content from './content.js';
|
|
8
|
+
import * as mouse from './mouse.js';
|
|
9
|
+
import { copyToClipboard, readClipboard } from './clipboard.js';
|
|
8
10
|
import { renderMarkdown } from './markdown.js';
|
|
9
11
|
// ── 内部状态 ──
|
|
10
12
|
let active = false;
|
|
@@ -46,16 +48,26 @@ let sigwinchHandler = null;
|
|
|
46
48
|
// 非 md 写(contentWrite)先 commitMd 收尾(清 segMark,后续写不再被 setLines 截断)。
|
|
47
49
|
let mdActive = false;
|
|
48
50
|
let mdBuf = '';
|
|
51
|
+
let selection = null;
|
|
52
|
+
let selecting = false; // 左键按下中(press→release 之间)
|
|
53
|
+
let mouseEnabled = true; // 导航菜单(picker)期间置 false:只吞报表不做选区/滚动,防菜单被 viewport 重画覆盖
|
|
54
|
+
const WHEEL_LINES = 3; // 滚轮每格滚动行数
|
|
55
|
+
// 点击输入框粘贴:当前文本输入方(prompt.ts / repl 运行态 typeahead)注册回调,接收剪贴板文本贴入。
|
|
56
|
+
// 只在输入区(底栏输入行)内右键单击(未拖动)时触发——与内容区选区互不干扰(内容区左键=框选,右键=复制)。
|
|
57
|
+
let pasteHandler = null;
|
|
49
58
|
const esc = {
|
|
50
59
|
altOn: '\x1B[?1049h',
|
|
51
60
|
altOff: '\x1B[?1049l',
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
// 完整鼠标追踪(仿 Claude Code 全屏渲染):1000=按键(按下/释放)+ 1002=拖动 motion + 1006=SGR 编码。
|
|
62
|
+
// 拿到按下/拖动/释放的坐标后,由 layout 在应用层维护选区(mouse.ts 重组报表 → handleMouseEvent):
|
|
63
|
+
// - 左键:内容区按下开选区、拖动扩展(触边自动翻页跨屏)、释放只留高亮,不自动复制;输入框不响应
|
|
64
|
+
// (不干扰正常打字/焦点)。
|
|
65
|
+
// - 右键(单击,press→release 未拖动):落在输入框 → 读剪贴板贴入(setPasteHandler 回调);
|
|
66
|
+
// 落在内容区 → 复制当前选区(若有)到剪贴板(clipboard.ts),静默不弹提示。
|
|
67
|
+
// - 滚轮报表(button&64)转 scrollBy。
|
|
68
|
+
// 代价:终端原生框选被鼠标捕获接管——想用终端原生选区可按住 Shift(多数终端放行)。
|
|
69
|
+
mouseOn: '\x1B[?1000h\x1B[?1002h\x1B[?1006h',
|
|
70
|
+
mouseOff: '\x1B[?1006l\x1B[?1002l\x1B[?1000l', // 关:反序
|
|
59
71
|
cursorShow: '\x1B[?25h',
|
|
60
72
|
cursorHide: '\x1B[?25l',
|
|
61
73
|
clearLine: '\x1B[2K',
|
|
@@ -389,9 +401,63 @@ export function clearContent() {
|
|
|
389
401
|
export function isScrolled() {
|
|
390
402
|
return scrollOffset > 0;
|
|
391
403
|
}
|
|
404
|
+
/** viewport 窗口起点绝对行索引(0-based,对齐 content.sliceFromEnd 的 start)。 */
|
|
405
|
+
function viewportAbsStart() {
|
|
406
|
+
const g = getGeo();
|
|
407
|
+
const total = content.totalRows();
|
|
408
|
+
const end = Math.max(0, total - scrollOffset);
|
|
409
|
+
return Math.max(0, end - g.contentBottom);
|
|
410
|
+
}
|
|
411
|
+
/** 屏行(1-based,内容区内)→ 绝对缓冲行索引(0-based)。 */
|
|
412
|
+
function screenRowToAbsLine(row) {
|
|
413
|
+
return viewportAbsStart() + (row - 1);
|
|
414
|
+
}
|
|
415
|
+
/** 选区归一化(anchor/end 按阅读顺序排序为 start/end)。无选区返 null。 */
|
|
416
|
+
function normalizeSelection() {
|
|
417
|
+
if (!selection)
|
|
418
|
+
return null;
|
|
419
|
+
const a = { line: selection.anchorLine, col: selection.anchorCol };
|
|
420
|
+
const b = { line: selection.endLine, col: selection.endCol };
|
|
421
|
+
const aFirst = a.line < b.line || (a.line === b.line && a.col <= b.col);
|
|
422
|
+
return aFirst
|
|
423
|
+
? { startLine: a.line, startCol: a.col, endLine: b.line, endCol: b.col }
|
|
424
|
+
: { startLine: b.line, startCol: b.col, endLine: a.line, endCol: a.col };
|
|
425
|
+
}
|
|
426
|
+
/** 给自洽带色行的显示列区间 [colStart,colEnd) 套反白(\x1B[7m...\x1B[27m),SGR 码原样穿过不受影响。 */
|
|
427
|
+
function highlightRange(line, colStart, colEnd) {
|
|
428
|
+
if (colEnd <= colStart)
|
|
429
|
+
return line;
|
|
430
|
+
const parts = line.split(/(\x1b\[[0-9;]*m)/);
|
|
431
|
+
let w = 0;
|
|
432
|
+
let out = '';
|
|
433
|
+
let opened = false;
|
|
434
|
+
for (let i = 0; i < parts.length; i++) {
|
|
435
|
+
if (i % 2 === 1) {
|
|
436
|
+
out += parts[i]; // SGR 码原样穿过
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
for (const ch of parts[i]) {
|
|
440
|
+
const cw = charWidth(ch.codePointAt(0) ?? 0);
|
|
441
|
+
if (!opened && w >= colStart && w < colEnd) {
|
|
442
|
+
out += '\x1B[7m';
|
|
443
|
+
opened = true;
|
|
444
|
+
}
|
|
445
|
+
if (opened && w >= colEnd) {
|
|
446
|
+
out += '\x1B[27m';
|
|
447
|
+
opened = false;
|
|
448
|
+
}
|
|
449
|
+
out += ch;
|
|
450
|
+
w += cw;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (opened)
|
|
454
|
+
out += '\x1B[27m';
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
392
457
|
/**
|
|
393
458
|
* 重画内容区 viewport:按 scrollOffset 取缓冲尾窗,逐行 cup+clearline+rowtext 映射到屏 1..contentBottom。
|
|
394
459
|
* offset=0 即尾窗(== 实时屏,resize / 回尾时用)。清行含 contentBottom——顺带擦 WT 边距漏影(状态行重复)。
|
|
460
|
+
* 有活跃选区(鼠标拖拽中)时,对选中范围套反白——纯视觉,不影响缓冲内容。
|
|
395
461
|
*/
|
|
396
462
|
export function repaintViewport() {
|
|
397
463
|
if (!active)
|
|
@@ -399,9 +465,20 @@ export function repaintViewport() {
|
|
|
399
465
|
const g = getGeo();
|
|
400
466
|
const h = g.contentBottom;
|
|
401
467
|
const slice = content.sliceFromEnd(scrollOffset, h);
|
|
468
|
+
const sel = normalizeSelection();
|
|
469
|
+
const absStart = sel ? viewportAbsStart() : 0;
|
|
402
470
|
let p = '';
|
|
403
471
|
for (let r = 1; r <= h; r++) {
|
|
404
|
-
|
|
472
|
+
let line = slice[r - 1] ?? '';
|
|
473
|
+
if (sel) {
|
|
474
|
+
const absLine = absStart + r - 1;
|
|
475
|
+
if (absLine >= sel.startLine && absLine <= sel.endLine) {
|
|
476
|
+
const lineW = ansiDisplayWidth(line);
|
|
477
|
+
const colStart = absLine === sel.startLine ? sel.startCol : 0;
|
|
478
|
+
const colEnd = absLine === sel.endLine ? sel.endCol : lineW;
|
|
479
|
+
line = highlightRange(line, colStart, colEnd);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
405
482
|
p += cup(r, 1) + esc.clearLine + line;
|
|
406
483
|
}
|
|
407
484
|
// 光标:合并进同一 write(若拆成两次 stdout.write,行写完光标会暂留 contentRow/contentBottom,
|
|
@@ -438,6 +515,148 @@ export function scrollBy(delta) {
|
|
|
438
515
|
repaintViewport();
|
|
439
516
|
repaint();
|
|
440
517
|
}
|
|
518
|
+
/** 清活跃选区(不复制),若原有选区则重画去掉反白。供 ESC / 点击别处 / 退出滚动态等场景调。 */
|
|
519
|
+
export function clearSelection() {
|
|
520
|
+
if (!selection)
|
|
521
|
+
return;
|
|
522
|
+
selection = null;
|
|
523
|
+
if (scrollOffset >= 0)
|
|
524
|
+
repaintViewport();
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* 注册"点击输入框粘贴"回调:当前持有文本输入的模块(prompt.ts 的 promptWithSlashMenu / repl 运行态
|
|
528
|
+
* typeahead)在挂键盘监听时调,退出时传 null 注销。点击输入行(未拖动)时 layout 读剪贴板后回调此函数,
|
|
529
|
+
* 由回调方完成插入(各输入状态的行/光标结构不同,layout 不掺和文本编辑逻辑,只管"贴入"这个动作触发)。
|
|
530
|
+
*/
|
|
531
|
+
export function setPasteHandler(fn) {
|
|
532
|
+
pasteHandler = fn;
|
|
533
|
+
}
|
|
534
|
+
/** picker / 介入面板期间禁用鼠标选区与滚轮(避免 viewport 重画覆盖菜单);面板退出后恢复。 */
|
|
535
|
+
export function setMouseEnabled(v) {
|
|
536
|
+
mouseEnabled = v;
|
|
537
|
+
if (!v) {
|
|
538
|
+
selecting = false;
|
|
539
|
+
if (selection) {
|
|
540
|
+
selection = null;
|
|
541
|
+
repaintViewport();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/** 从归一化选区抠出纯文本(去 ANSI,按显示列裁切,行间 \n 拼接)。越界行跳过(缓冲被 trim 等边界情况)。 */
|
|
546
|
+
function extractSelectionText(sel) {
|
|
547
|
+
const out = [];
|
|
548
|
+
for (let abs = sel.startLine; abs <= sel.endLine; abs++) {
|
|
549
|
+
const raw = content.lineAt(abs);
|
|
550
|
+
if (raw == null)
|
|
551
|
+
continue;
|
|
552
|
+
const plain = stripAnsi(raw);
|
|
553
|
+
const lineW = displayWidth(plain);
|
|
554
|
+
const colStart = abs === sel.startLine ? sel.startCol : 0;
|
|
555
|
+
const colEnd = abs === sel.endLine ? sel.endCol : lineW;
|
|
556
|
+
out.push(sliceByDisplayCol(plain, colStart, colEnd));
|
|
557
|
+
}
|
|
558
|
+
return out.join('\n');
|
|
559
|
+
}
|
|
560
|
+
/** 屏行是否落在底栏输入行范围内(paintInput 的 firstInputRow..firstInputRow+inputRowsAvail-1)。 */
|
|
561
|
+
function isInputRow(row) {
|
|
562
|
+
const g = getGeo();
|
|
563
|
+
const firstInputRow = g.contentBottom + 4;
|
|
564
|
+
const inputRowsAvail = Math.max(0, g.footerH - 5);
|
|
565
|
+
return row >= firstInputRow && row < firstInputRow + inputRowsAvail;
|
|
566
|
+
}
|
|
567
|
+
/** 右键单击输入行(未拖动的 press→release):读剪贴板 + 回调 pasteHandler 贴入。异步但不阻塞其他事件。 */
|
|
568
|
+
function pasteIntoInput() {
|
|
569
|
+
if (!pasteHandler)
|
|
570
|
+
return;
|
|
571
|
+
const handler = pasteHandler;
|
|
572
|
+
readClipboard()
|
|
573
|
+
.then((text) => {
|
|
574
|
+
if (text && active)
|
|
575
|
+
handler(text);
|
|
576
|
+
})
|
|
577
|
+
.catch(() => { });
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* 鼠标事件分发(mouse.setHandler 注册)。
|
|
581
|
+
* - 左键(button 0):内容区按下开选区、拖动扩展(触边自动翻页)、释放只更新高亮**不复制**;
|
|
582
|
+
* 纯点击(未拖动)清空旧选区(点别处的常见预期);落在输入行不做任何特殊处理(不选区、不粘贴——
|
|
583
|
+
* 留给终端 / 正常打字焦点行为,避免误触发)。
|
|
584
|
+
* - 右键(button 2,单击 = press→release 未拖动):落在输入行 → 读剪贴板贴入(仿常见终端"右键粘贴");
|
|
585
|
+
* 落在内容区 → 复制当前选区(若有)到剪贴板后立即清空高亮(视觉确认"已复制",不留旧选区误导),
|
|
586
|
+
* 静默不弹提示;都无对应状态则 no-op。
|
|
587
|
+
* - 滚轮(button&64,由 mouse.ts 解析为 wheel 事件):照常 scrollBy。
|
|
588
|
+
* 选区坐标存绝对缓冲行(viewportAbsStart + 屏行),故翻页 / 追加新内容期间选区锚点仍指向同段文字。
|
|
589
|
+
*/
|
|
590
|
+
function handleMouseEvent(e) {
|
|
591
|
+
if (!active)
|
|
592
|
+
return;
|
|
593
|
+
if (e.type === 'wheel') {
|
|
594
|
+
if (mouseEnabled)
|
|
595
|
+
scrollBy(e.dir * WHEEL_LINES);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (!mouseEnabled)
|
|
599
|
+
return;
|
|
600
|
+
const g = getGeo();
|
|
601
|
+
const col = Math.max(0, e.col - 1); // SGR 报表列 1-based → 显示列 0-based
|
|
602
|
+
// 右键释放:落输入行 → 贴入;落内容区 → 复制当前选区后清空(去掉高亮),不可重复右键复制同一选区。
|
|
603
|
+
if (e.type === 'release' && e.button === 2) {
|
|
604
|
+
if (isInputRow(e.row)) {
|
|
605
|
+
pasteIntoInput();
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const sel = normalizeSelection();
|
|
609
|
+
if (!sel)
|
|
610
|
+
return;
|
|
611
|
+
const text = extractSelectionText(sel);
|
|
612
|
+
selection = null;
|
|
613
|
+
repaintViewport(); // 复制后清高亮:视觉反馈"已复制",不留旧选区误导
|
|
614
|
+
if (!text)
|
|
615
|
+
return;
|
|
616
|
+
copyToClipboard(text);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (e.button !== 0)
|
|
620
|
+
return; // 其余中/右键 press/drag 不处理(终端原生右键菜单等不受影响)
|
|
621
|
+
if (e.type === 'press') {
|
|
622
|
+
if (isInputRow(e.row))
|
|
623
|
+
return; // 输入行左键不做选区(不干扰正常打字/焦点)
|
|
624
|
+
selecting = true;
|
|
625
|
+
const rowInContent = Math.max(1, Math.min(e.row, g.contentBottom));
|
|
626
|
+
const absLine = screenRowToAbsLine(rowInContent);
|
|
627
|
+
selection = { anchorLine: absLine, anchorCol: col, endLine: absLine, endCol: col, dragged: false };
|
|
628
|
+
repaintViewport();
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (e.type === 'drag') {
|
|
632
|
+
if (!selecting || !selection)
|
|
633
|
+
return;
|
|
634
|
+
// 触边自动翻页:motion 事件持续到达时靠此逐步滚动,把选区扩展到滚出去的历史行。
|
|
635
|
+
if (e.row <= 1)
|
|
636
|
+
scrollBy(WHEEL_LINES);
|
|
637
|
+
else if (e.row >= g.contentBottom)
|
|
638
|
+
scrollBy(-WHEEL_LINES);
|
|
639
|
+
const rowInContent = Math.max(1, Math.min(e.row, g.contentBottom));
|
|
640
|
+
const absLine = screenRowToAbsLine(rowInContent);
|
|
641
|
+
if (absLine !== selection.endLine || col !== selection.endCol)
|
|
642
|
+
selection.dragged = true;
|
|
643
|
+
selection.endLine = absLine;
|
|
644
|
+
selection.endCol = col;
|
|
645
|
+
repaintViewport();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
// release(左键)
|
|
649
|
+
selecting = false;
|
|
650
|
+
if (!selection)
|
|
651
|
+
return;
|
|
652
|
+
if (!selection.dragged) {
|
|
653
|
+
// 内容区纯点击(未拖动):只清选区,不复制(复制交给右键)。
|
|
654
|
+
selection = null;
|
|
655
|
+
repaintViewport();
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
// 拖动过:保留高亮选区供右键复制(不在此处复制,复制交给右键释放分支)。
|
|
659
|
+
}
|
|
441
660
|
/** 回尾(offset=0);仅当原本滚动过才重画(避免每轮 enterRunningMode 闪烁)。 */
|
|
442
661
|
export function resetScroll() {
|
|
443
662
|
if (scrollOffset === 0)
|
|
@@ -814,7 +1033,7 @@ export function paintInput(view) {
|
|
|
814
1033
|
const line = slice[g.contentBottom - 1] ?? '';
|
|
815
1034
|
buf += cup(g.contentBottom, 1) + esc.clearLine + line;
|
|
816
1035
|
}
|
|
817
|
-
// 2c. 虚拟空行(内容区与状态栏之间的视觉间隔,属底栏非内容)
|
|
1036
|
+
// 2c. 虚拟空行(内容区与状态栏之间的视觉间隔,属底栏非内容):恒清空,防底栏撑高时旧内容残留该行。
|
|
818
1037
|
buf += cup(g.contentBottom + 1, 1) + esc.clearLine;
|
|
819
1038
|
// 3. 状态行:spinner 行 + model 行(两行式底栏)
|
|
820
1039
|
const spinnerRow = g.contentBottom + 2; // +1 虚拟空行,+2 spinner 行
|
|
@@ -998,7 +1217,8 @@ export function enterAltScreen() {
|
|
|
998
1217
|
return;
|
|
999
1218
|
active = true;
|
|
1000
1219
|
stdout.write(esc.altOn);
|
|
1001
|
-
stdout.write(esc.
|
|
1220
|
+
stdout.write(esc.mouseOn); // 完整鼠标追踪(按下/拖动/释放/滚轮)→ mouse.swallow 重组 → handleMouseEvent
|
|
1221
|
+
mouse.setHandler(handleMouseEvent);
|
|
1002
1222
|
setRegion(6); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行(两行式底栏)
|
|
1003
1223
|
contentRow = 1;
|
|
1004
1224
|
contentCol = 1;
|
|
@@ -1007,6 +1227,8 @@ export function enterAltScreen() {
|
|
|
1007
1227
|
scrollLockUntil = 0;
|
|
1008
1228
|
mdActive = false;
|
|
1009
1229
|
mdBuf = '';
|
|
1230
|
+
selection = null;
|
|
1231
|
+
selecting = false;
|
|
1010
1232
|
content.reset();
|
|
1011
1233
|
exitHandler = () => exitAltScreen();
|
|
1012
1234
|
process.on('exit', exitHandler);
|
|
@@ -1055,6 +1277,10 @@ export function exitAltScreen() {
|
|
|
1055
1277
|
scrollLockUntil = 0; // 清轮首滚动锁(防状态泄漏到下次进 alt 屏)
|
|
1056
1278
|
frameRow = 0; // 清 spinner 帧位置(防状态泄漏到下次进 alt 屏)
|
|
1057
1279
|
frameCol = 0;
|
|
1280
|
+
mouse.setHandler(null);
|
|
1281
|
+
mouse.resetMouse();
|
|
1282
|
+
selection = null;
|
|
1283
|
+
selecting = false;
|
|
1058
1284
|
// raw 还原独立 try:非 TTY / 不支持时 setRawMode 抛错,不应阻断 stdout 恢复(alt 退屏必须执行)。
|
|
1059
1285
|
try {
|
|
1060
1286
|
stdin.setRawMode(false); // 还原 raw(RUNNING 态常驻 raw,退出时必须还原,否则终端残留 raw 模式)
|
|
@@ -1065,7 +1291,7 @@ export function exitAltScreen() {
|
|
|
1065
1291
|
try {
|
|
1066
1292
|
stdout.write('\x1B[r'); // 复位 DECSTBM margins
|
|
1067
1293
|
stdout.write(esc.cursorShow);
|
|
1068
|
-
stdout.write(esc.
|
|
1294
|
+
stdout.write(esc.mouseOff); // 关鼠标追踪(反序:先 1006l 再 1002l 再 1000l)
|
|
1069
1295
|
stdout.write(esc.altOff); // 退 alt(恢复主屏 + 光标)
|
|
1070
1296
|
}
|
|
1071
1297
|
catch {
|
package/dist/ui/mouse.js
CHANGED
|
@@ -1,93 +1,90 @@
|
|
|
1
|
-
// SGR 鼠标报表重组器(
|
|
1
|
+
// SGR 鼠标报表重组器 + 事件分发(叶子:仅正则 + 回调,无 UI 依赖)。
|
|
2
2
|
//
|
|
3
|
-
// 背景:layout 进 alt 屏时发 \x1B[?1000h + \x1B[?
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
function
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* - suppress=false:非鼠标,放行正常处理。
|
|
61
|
+
* 喂入一个 keypress 的 key.sequence。
|
|
62
|
+
* 返回 true=本 keypress 是鼠标 fragment(或完整报表),调用方须 return 吞掉;
|
|
63
|
+
* 返回 false=非鼠标,放行正常处理。完整报表拼齐时同步派发 MouseEvent 给 handler。
|
|
65
64
|
*/
|
|
66
|
-
export function
|
|
65
|
+
export function swallow(seq) {
|
|
66
|
+
if (!seq)
|
|
67
|
+
return false;
|
|
67
68
|
if (!collecting) {
|
|
68
69
|
if (!seq.startsWith(START))
|
|
69
|
-
return
|
|
70
|
-
//
|
|
71
|
-
if (REPORT_RE.test(seq))
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
return { wheel: 0, suppress: true };
|
|
78
|
+
return true;
|
|
78
79
|
}
|
|
79
|
-
// 收集中:追加。
|
|
80
80
|
buf += seq;
|
|
81
81
|
if (REPORT_RE.test(buf)) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
if (PARTIAL_RE.test(buf))
|
|
87
|
+
return true; // 合法未完成形状,继续等
|
|
88
|
+
resetMouse(); // 偏离合法形状:畸形垃圾,丢半截,吞掉不打扰
|
|
89
|
+
return true;
|
|
93
90
|
}
|