mocode-ai 0.1.10 → 0.2.1

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,152 @@ 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
+ repaint(); // 补一次输入区重画:INPUT 态 repaintViewport 把真光标留在内容区续写位,须靠 repaint 把它带回输入框(否则点内容区会现假闪烁光标,见 issue)
630
+ return;
631
+ }
632
+ if (e.type === 'drag') {
633
+ if (!selecting || !selection)
634
+ return;
635
+ // 触边自动翻页:motion 事件持续到达时靠此逐步滚动,把选区扩展到滚出去的历史行。
636
+ if (e.row <= 1)
637
+ scrollBy(WHEEL_LINES);
638
+ else if (e.row >= g.contentBottom)
639
+ scrollBy(-WHEEL_LINES);
640
+ const rowInContent = Math.max(1, Math.min(e.row, g.contentBottom));
641
+ const absLine = screenRowToAbsLine(rowInContent);
642
+ if (absLine !== selection.endLine || col !== selection.endCol)
643
+ selection.dragged = true;
644
+ selection.endLine = absLine;
645
+ selection.endCol = col;
646
+ repaintViewport();
647
+ repaint(); // 同上:把真光标带回输入框,防拖动选区期间光标停留内容区闪烁
648
+ return;
649
+ }
650
+ // release(左键)
651
+ selecting = false;
652
+ if (!selection)
653
+ return;
654
+ if (!selection.dragged) {
655
+ // 内容区纯点击(未拖动):只清选区,不复制(复制交给右键)。
656
+ selection = null;
657
+ repaintViewport();
658
+ repaint(); // 同上:把真光标带回输入框
659
+ return;
660
+ }
661
+ // 拖动过:保留高亮选区供右键复制(不在此处复制,复制交给右键释放分支)。
662
+ repaint(); // 同上:把真光标带回输入框
663
+ }
441
664
  /** 回尾(offset=0);仅当原本滚动过才重画(避免每轮 enterRunningMode 闪烁)。 */
442
665
  export function resetScroll() {
443
666
  if (scrollOffset === 0)
@@ -814,7 +1037,7 @@ export function paintInput(view) {
814
1037
  const line = slice[g.contentBottom - 1] ?? '';
815
1038
  buf += cup(g.contentBottom, 1) + esc.clearLine + line;
816
1039
  }
817
- // 2c. 虚拟空行(内容区与状态栏之间的视觉间隔,属底栏非内容):恒清空,防底栏撑高时旧内容残留该行
1040
+ // 2c. 虚拟空行(内容区与状态栏之间的视觉间隔,属底栏非内容):恒清空,防底栏撑高时旧内容残留该行。
818
1041
  buf += cup(g.contentBottom + 1, 1) + esc.clearLine;
819
1042
  // 3. 状态行:spinner 行 + model 行(两行式底栏)
820
1043
  const spinnerRow = g.contentBottom + 2; // +1 虚拟空行,+2 spinner 行
@@ -998,7 +1221,8 @@ export function enterAltScreen() {
998
1221
  return;
999
1222
  active = true;
1000
1223
  stdout.write(esc.altOn);
1001
- stdout.write(esc.altScrollOn); // alt 屏滚轮转发 ↑/↓(不抓鼠标点击/拖拽,原生选区复制不受影响)
1224
+ stdout.write(esc.mouseOn); // 完整鼠标追踪(按下/拖动/释放/滚轮)→ mouse.swallow 重组 → handleMouseEvent
1225
+ mouse.setHandler(handleMouseEvent);
1002
1226
  setRegion(6); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行(两行式底栏)
1003
1227
  contentRow = 1;
1004
1228
  contentCol = 1;
@@ -1007,6 +1231,8 @@ export function enterAltScreen() {
1007
1231
  scrollLockUntil = 0;
1008
1232
  mdActive = false;
1009
1233
  mdBuf = '';
1234
+ selection = null;
1235
+ selecting = false;
1010
1236
  content.reset();
1011
1237
  exitHandler = () => exitAltScreen();
1012
1238
  process.on('exit', exitHandler);
@@ -1055,6 +1281,10 @@ export function exitAltScreen() {
1055
1281
  scrollLockUntil = 0; // 清轮首滚动锁(防状态泄漏到下次进 alt 屏)
1056
1282
  frameRow = 0; // 清 spinner 帧位置(防状态泄漏到下次进 alt 屏)
1057
1283
  frameCol = 0;
1284
+ mouse.setHandler(null);
1285
+ mouse.resetMouse();
1286
+ selection = null;
1287
+ selecting = false;
1058
1288
  // raw 还原独立 try:非 TTY / 不支持时 setRawMode 抛错,不应阻断 stdout 恢复(alt 退屏必须执行)。
1059
1289
  try {
1060
1290
  stdin.setRawMode(false); // 还原 raw(RUNNING 态常驻 raw,退出时必须还原,否则终端残留 raw 模式)
@@ -1065,7 +1295,7 @@ export function exitAltScreen() {
1065
1295
  try {
1066
1296
  stdout.write('\x1B[r'); // 复位 DECSTBM margins
1067
1297
  stdout.write(esc.cursorShow);
1068
- stdout.write(esc.altScrollOff); // alt 屏滚轮转发
1298
+ stdout.write(esc.mouseOff); // 关鼠标追踪(反序:先 1006l 再 1002l 再 1000l)
1069
1299
  stdout.write(esc.altOff); // 退 alt(恢复主屏 + 光标)
1070
1300
  }
1071
1301
  catch {