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.
@@ -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
+ }
@@ -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
+ }
@@ -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
- // xterm alternate scroll mode(DECSET 1007):alt 屏内滚轮转发 ↑/↓ 键序列(\x1B[A/\x1B[B),
53
- // 不抓取任何鼠标点击/拖拽事件——终端原生框选与复制全程可用,不需要按 Shift 逃生。
54
- // 主流终端(Windows Terminal ≥1.14、xterm、VTE 系 GNOME Terminal、Konsole、iTerm2)均支持;
55
- // 不支持的终端会静默忽略此转义序列,退化为"滚轮不滚动,但选区复制始终能用"——不会更差。
56
- // 滚轮转发出的 ↑/↓ onKey/onRunningKey 现有的 plain ↑/↓ 分支接住(见下方 scrollBy 调用点)
57
- altScrollOn: '\x1B[?1007h',
58
- altScrollOff: '\x1B[?1007l',
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
- const line = slice[r - 1] ?? '';
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.altScrollOn); // alt 屏滚轮转发 ↑/↓(不抓鼠标点击/拖拽,原生选区复制不受影响)
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.altScrollOff); // alt 屏滚轮转发
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[?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
  }