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/layout.js CHANGED
@@ -2,15 +2,16 @@ 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
8
  import * as mouse from './mouse.js';
9
+ import { copyToClipboard, readClipboard } from './clipboard.js';
9
10
  import { renderMarkdown } from './markdown.js';
10
11
  // ── 内部状态 ──
11
12
  let active = false;
12
13
  let mode = 'input';
13
- let footerH = 5; // 1 虚拟空行 + 1 状态行 + 1 上线 + 输入行数 + 1 下线(虚拟空行隔开内容与状态栏,上下线框住输入区)
14
+ let footerH = 6; // 1 虚拟空行 + 1 spinner行 + 1 上线 + 输入行数 + 1 下线 + 1 model行(两行式底栏)
14
15
  let contentRow = 1; // 续写位行(1-based,屏坐标,[1,contentBottom])
15
16
  let contentCol = 1; // 续写位列(1-based)
16
17
  // 上一次 paintLiveAtCursor 实际画帧的屏坐标(0=未画)。clearLiveAtCursor 清"这行"而非当前续写位——
@@ -47,11 +48,26 @@ let sigwinchHandler = null;
47
48
  // 非 md 写(contentWrite)先 commitMd 收尾(清 segMark,后续写不再被 setLines 截断)。
48
49
  let mdActive = false;
49
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;
50
58
  const esc = {
51
59
  altOn: '\x1B[?1049h',
52
60
  altOff: '\x1B[?1049l',
53
- mouseOn: '\x1B[?1000h\x1B[?1006h', // SGR 鼠标:按键事件追踪(1000)+SGR 编码(1006)→ \x1B[<btn;col;rowM,经 mouse.consumeMouse 重组后滚轮滚动。副作用:TUI 内鼠标选区复制失效,按 Shift 用鼠标可让终端放行做原生选区
54
- mouseOff: '\x1B[?1006l\x1B[?1000l', // 关:反序(先关 SGR 编码再关追踪)
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', // 关:反序
55
71
  cursorShow: '\x1B[?25h',
56
72
  cursorHide: '\x1B[?25l',
57
73
  clearLine: '\x1B[2K',
@@ -164,11 +180,19 @@ export function contentWrite(s) {
164
180
  commitMd(); // 非 md 写接续:先收尾 md 段(清 segMark,否则 setLines 会截到旧段)
165
181
  // 滚动回看时(scrollOffset>0)只喂缓冲 + 推进续写位,不物理写——否则新流式内容覆盖 viewport 历史行。
166
182
  // 回尾(scrollOffset===0)才物理写;写起点 = 当前续写位(loop 会推进续写位,故先捕获)。
167
- const startRow = contentRow;
168
- const startCol = contentCol;
169
183
  const g = getGeo();
170
184
  const cols = g.cols;
171
185
  const bottom = g.contentBottom;
186
+ // resize 后 contentRow 可能过时(拖终端框):
187
+ // - 缩小:contentRow > 新 bottom → 钳到新 bottom
188
+ // - 放大:contentRow < 新 bottom 且回尾 → 推进到 min(total, bottom)
189
+ if (contentRow > bottom)
190
+ contentRow = bottom;
191
+ else if (scrollOffset === 0 && contentRow < bottom) {
192
+ contentRow = Math.min(content.totalRows(), bottom);
193
+ }
194
+ const startRow = contentRow;
195
+ const startCol = contentCol;
172
196
  const advanceRow = (r) => (r >= bottom ? bottom : r + 1); // 到底则滚动,续写位留底
173
197
  // 滚动回看冻结(仿 ink store.pushBlock 的 offset+=newLines):scrollOffset>0 时新内容只入缓冲,
174
198
  // 但缓冲增长后若 offset 不变,下次 scrollBy→repaintViewport 会取漂移窗口、把新内容画进历史视图
@@ -377,9 +401,63 @@ export function clearContent() {
377
401
  export function isScrolled() {
378
402
  return scrollOffset > 0;
379
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
+ }
380
457
  /**
381
458
  * 重画内容区 viewport:按 scrollOffset 取缓冲尾窗,逐行 cup+clearline+rowtext 映射到屏 1..contentBottom。
382
459
  * offset=0 即尾窗(== 实时屏,resize / 回尾时用)。清行含 contentBottom——顺带擦 WT 边距漏影(状态行重复)。
460
+ * 有活跃选区(鼠标拖拽中)时,对选中范围套反白——纯视觉,不影响缓冲内容。
383
461
  */
384
462
  export function repaintViewport() {
385
463
  if (!active)
@@ -387,9 +465,20 @@ export function repaintViewport() {
387
465
  const g = getGeo();
388
466
  const h = g.contentBottom;
389
467
  const slice = content.sliceFromEnd(scrollOffset, h);
468
+ const sel = normalizeSelection();
469
+ const absStart = sel ? viewportAbsStart() : 0;
390
470
  let p = '';
391
471
  for (let r = 1; r <= h; r++) {
392
- 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
+ }
393
482
  p += cup(r, 1) + esc.clearLine + line;
394
483
  }
395
484
  // 光标:合并进同一 write(若拆成两次 stdout.write,行写完光标会暂留 contentRow/contentBottom,
@@ -416,10 +505,158 @@ export function scrollBy(delta) {
416
505
  const off = Math.max(0, Math.min(scrollOffset + delta, maxOff));
417
506
  if (off === scrollOffset)
418
507
  return;
508
+ // 进入滚动态前清掉 spinner 内联帧:滚动态由状态行心跳兜底,内联帧若残留会卡在历史视图某行。
509
+ if (scrollOffset === 0 && off > 0 && frameRow) {
510
+ stdout.write(cup(Math.min(frameRow, g.contentBottom), frameCol) + esc.clearLine);
511
+ frameRow = 0;
512
+ frameCol = 0;
513
+ }
419
514
  scrollOffset = off;
420
515
  repaintViewport();
421
516
  repaint();
422
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
+ }
423
660
  /** 回尾(offset=0);仅当原本滚动过才重画(避免每轮 enterRunningMode 闪烁)。 */
424
661
  export function resetScroll() {
425
662
  if (scrollOffset === 0)
@@ -443,66 +680,96 @@ export function unlockScroll() {
443
680
  export function isScrollLocked() {
444
681
  return Date.now() < scrollLockUntil;
445
682
  }
446
- // ── 状态行 ──
447
- /** 组合状态行可见串(带色),按 cols 截断防溢出(不折到输入行)。 */
448
- function composeStatus(status, cols) {
449
- const sep = ' ';
450
- const sepW = sep.length;
451
- // 运行态(含滚动回看)用心跳帧(♥/♡)替换静态 ◆:agent 跑起来时状态行前导符始终跳动,
452
- // 滑到上面看历史也能感知 agent 在跑。帧宽=1(与 ◆ 同),fixed 不变,布局不乱。INPUT 态仍显 ◆。
683
+ // ── 状态行(两行式:spinner 行 + model 行)──
684
+ const STATUS_SEP = ' ';
685
+ const STATUS_SEP_W = STATUS_SEP.length;
686
+ /** 通用两段式排版:左段左端对齐 + 空格填充 + 右段右端对齐。pad 至少 1 空格防粘连。 */
687
+ function twoColumn(leftStr, leftW, rightStr, rightW, cols) {
688
+ const pad = Math.max(1, cols - leftW - rightW);
689
+ return `${leftStr}${' '.repeat(pad)}${rightStr}`;
690
+ }
691
+ /** 上线之上那行(spinner 行):左段 = spinner 帧 + 状态文字 + 走时(全部左对齐,紧跟不分离)。
692
+ * 右段仅在滚动回看时显历史指示(右端对齐)。
693
+ * 示例:
694
+ * INPUT: ◆ 空闲
695
+ * 思考中: ⠹ 思考中… 0.5s
696
+ * 运行心跳: ♥ 0.5s
697
+ * 滚动回看: ◆ 历史 ↑3 (PgDn 回底) */
698
+ function composeSpinnerLine(status, cols) {
453
699
  const spinning = mode === 'running' && runningFrame >= 0;
454
- const lead = spinning ? `${RUNNING_FRAMES[runningFrame]} ` : `◆ `;
455
- // 模式 chip:lead 之后、model 之前。auto 显 dim 'auto'(常态),plan 显亮黄 'plan'(切换时颜色+文字都变,明显)。
456
- const modeChip = status.modeTag
457
- ? ` ${status.modeTag === 'plan' ? ui.yellow : ui.dim}${status.modeTag}${ui.reset} `
458
- : '';
459
- const modeChipW = status.modeTag ? 2 + displayWidth(status.modeTag) : 0; // 2 = 前导+尾随空格
460
- const model = truncateDisplay(status.model, 22);
461
- const ctx = status.contextBar; // 已带色
462
- const ctxW = ansiDisplayWidth(ctx);
463
- // 滚动回看时状态段改显历史指示(无 spinner——滚动只在 INPUT 态)
700
+ const hasSpinner = !!status.spinnerFrame;
464
701
  const scrolled = scrollOffset > 0;
465
- let st;
702
+ // 走时(仅运行态)
703
+ const elapsed = (mode === 'running' && turnStart != null)
704
+ ? fmtElapsed(Date.now() - turnStart)
705
+ : '';
706
+ // ── 左段:帧 + 状态 + 走时,全部紧跟 ──
707
+ let lead;
708
+ let leadW;
466
709
  if (scrolled) {
467
- st = `历史 ↑${scrollOffset} (PgDn 回底)`; // 滚动回看显历史指示,不显走时
710
+ // 滚动回看:左段仅显 (或心跳帧),右段显历史指示
711
+ lead = `${spinning ? ui.brightMagenta : ui.brightCyan}${spinning ? RUNNING_FRAMES[runningFrame] : '◆'}${ui.reset}`;
712
+ leadW = 1;
713
+ }
714
+ else if (hasSpinner) {
715
+ // spinner 激活(思考中/执行工具…):帧 + 状态 + 走时
716
+ const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
717
+ lead = `${ui.brightMagenta}${status.spinnerFrame}${ui.reset} ${ui.dim}${status.status}${ui.reset}${ePart}`;
718
+ leadW = 1 + 1 + displayWidth(status.status) + (elapsed ? 1 + displayWidth(elapsed) : 0);
719
+ }
720
+ else if (spinning) {
721
+ // 运行态心跳帧(流式输出中):帧 + 走时
722
+ const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
723
+ lead = `${ui.brightMagenta}${RUNNING_FRAMES[runningFrame]}${ui.reset}${ePart}`;
724
+ leadW = 1 + (elapsed ? 1 + displayWidth(elapsed) : 0);
468
725
  }
469
726
  else {
470
- // head = spinner 帧 + 状态文字。常态 agent 轮二者皆空:agent spinner 不调 setStatus,
471
- // 其文字在内联 paintLiveAtCursor 显,状态行不重复——只显走时。命令态(如 /compact 的「压缩中」)
472
- // setStatus,在此显帧 + 文字(无内联,不重复)
473
- const head = (status.spinnerFrame ? status.spinnerFrame + ' ' : '') + status.status;
474
- if (mode === 'running' && turnStart != null) {
475
- // RUNNING 态追加走时:整轮从 enterRunningMode 起计时,200ms 计时器续刷使其连续递增。
476
- st = head ? `${head} · ${fmtElapsed(Date.now() - turnStart)}` : fmtElapsed(Date.now() - turnStart);
477
- }
478
- else {
479
- st = head; // INPUT 等态(如"空闲")
480
- }
727
+ // INPUT 态:◆ + 状态文字(无走时)
728
+ lead = `${ui.brightCyan}◆${ui.reset} ${ui.dim}${status.status}${ui.reset}`;
729
+ leadW = 1 + 1 + displayWidth(status.status);
730
+ }
731
+ // ── 右段:仅滚动回看时显历史指示 ──
732
+ let tail = '';
733
+ let tailW = 0;
734
+ if (scrolled) {
735
+ tail = `历史 ↑${scrollOffset} (PgDn 回底)`;
736
+ tailW = displayWidth(tail);
481
737
  }
482
- const stW = displayWidth(st);
483
- const fixed = displayWidth(lead) + modeChipW + displayWidth(model) + sepW * 3 + ctxW + stW;
484
- const cwdBudget = cols - fixed - 1;
738
+ const rightStr = tail ? `${ui.yellow}${tail}${ui.reset}` : '';
739
+ return twoColumn(lead, leadW, rightStr, tailW, cols);
740
+ }
741
+ /** 下线之下那行(model 行):左 = 模式标识(auto 跟随主题色 / plan 显亮黄);右 = context + cwd,右端对齐。 */
742
+ function composeModelLine(status, cols) {
743
+ const ctx = status.contextBar; // 已带色
744
+ const ctxW = ansiDisplayWidth(ctx);
745
+ // 左段:模式标识(auto 显 brightCyan 随主题 / plan 显亮黄)
746
+ const modeTag = status.modeTag ?? '';
747
+ const leftStr = modeTag
748
+ ? `${modeTag === 'plan' ? ui.yellow : ui.brightCyan}${modeTag}${ui.reset}`
749
+ : '';
750
+ const leftW = modeTag ? displayWidth(modeTag) : 0;
751
+ // 右段:ctx + sep + cwd,右端对齐。cwd 按预算截断,极窄(<6)隐藏。
752
+ const minGap = 2;
753
+ const cwdBudget = cols - leftW - minGap - ctxW - STATUS_SEP_W - 1;
485
754
  const cwd = cwdBudget >= 6 ? truncateDisplay(status.cwd, cwdBudget) : '';
486
- return [
487
- `${spinning ? ui.brightMagenta : ui.brightCyan}${lead}${ui.reset}${modeChip}${ui.bold}${model}${ui.reset}`,
488
- sep,
489
- ctx,
490
- sep,
491
- `${ui.dim}${cwd}${ui.reset}`,
492
- sep,
493
- `${scrolled ? ui.yellow : status.spinnerFrame ? ui.brightMagenta : ui.dim}${st}${ui.reset}`,
494
- ].join('');
495
- }
496
- /** 画状态行(光标回续写位)。RUNNING 态 spinner 频繁调。 */
755
+ const cwdW = displayWidth(cwd);
756
+ const rightStr = `${ctx}${STATUS_SEP}${ui.dim}${cwd}${ui.reset}`;
757
+ const rightW = ctxW + STATUS_SEP_W + cwdW;
758
+ return twoColumn(leftStr, leftW, rightStr, rightW, cols);
759
+ }
760
+ /** 画状态行(spinner 行 + model 行,两行)。RUNNING 态 spinner 频繁调。
761
+ * 行号(footerH=6):spinner 行=contentBottom+2,model 行=rows(屏底)。
762
+ * 上线 contentBottom+3 / 输入 contentBottom+4 / 下线 contentBottom+5 由 paintInput 画,此函数只刷两行信息。 */
497
763
  export function drawStatusBar(status) {
498
764
  if (!active || !base)
499
765
  return;
500
766
  const s = status ?? { ...base, status: statusText, spinnerFrame };
501
767
  const g = getGeo();
502
- const statusRow = g.contentBottom + 2; // +1 虚拟空行(内容与状态栏间隔),+2 状态行
503
- // 一次写入:cup 状态行 + clearLine + 状态 + cup 回(运行态→输入框供 IME 锚定;否则续写位/内容区底)。
504
- // 打字中不再跳过——单次 write 结尾 cup runningCaretPos,IME 锚输入框不动;旧 isStreamingPaused 门致状态行 / 心跳打字时冻住。
505
- let out = cup(statusRow, 1) + esc.clearLine + composeStatus(s, g.cols);
768
+ const spinnerRow = g.contentBottom + 2; // +1 虚拟空行,+2 spinner 行
769
+ const modelRow = g.rows; // 屏底:model
770
+ // 一次写入:cup spinner + clearLine + spinner 行内容 + cup model + clearLine + model 行内容 + cup 回。
771
+ let out = cup(spinnerRow, 1) + esc.clearLine + composeSpinnerLine(s, g.cols) +
772
+ cup(modelRow, 1) + esc.clearLine + composeModelLine(s, g.cols);
506
773
  if (mode === 'running') {
507
774
  // 运行态(回尾 / 滚动回看均):cup 回输入框光标位(供 IME 锚定)。
508
775
  const p = runningCaretPos();
@@ -568,18 +835,44 @@ function dbgSpinner(msg) {
568
835
  */
569
836
  export function paintLiveAtCursor(text) {
570
837
  if (!active || !ui.isTTY || scrollOffset !== 0 || isStreamingPaused()) {
571
- // 临时诊断:画帧被守卫跳过,但此前画过帧(frameRow)→ 该帧不会被这次 paint 覆盖,泄漏嫌疑
572
- if (frameRow && scrollOffset !== 0)
573
- dbgSpinner(`PAINT-SKIPPED frame=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) off=${scrollOffset} paused=${isStreamingPaused()}`);
838
+ // 滚动态 / 暂停态:不画新帧。但若有旧帧残留(frameRow),必须清掉——
839
+ // 否则旧 spinner 帧停在历史视图某行,用户看到「思考中」卡在消息堆里(根因)
840
+ // 滚动态由状态行心跳兜底显示运行状态,不需内联帧;清完后 frameRow 归零,回尾时下一帧自然重画。
841
+ if (frameRow) {
842
+ // resize 后 contentBottom 可能缩小:清旧帧时钳 frameRow 到当前可视区,避免 cup 到屏外行
843
+ const g = getGeo();
844
+ const fr = Math.min(frameRow, g.contentBottom);
845
+ let out = cup(fr, frameCol) + esc.clearLine;
846
+ if (mode === 'running') {
847
+ const p = runningCaretPos();
848
+ out += cup(p.row, p.col);
849
+ }
850
+ stdout.write(out);
851
+ frameRow = 0;
852
+ frameCol = 0;
853
+ }
574
854
  return;
575
855
  }
856
+ // resize 后 contentRow 可能过时(拖终端框):
857
+ // - 缩小:contentRow > 新 bottom → 钳到新 bottom
858
+ // - 放大:contentRow < 新 bottom 且回尾 → 推进到 min(total, bottom)(内容末尾或新区底)
859
+ // 内容够填满:推进到 bottom(最底部);内容不够:推进到 total(紧跟内容尾)
860
+ // 否则 cup 到旧行号→帧画在屏幕中间(旧 bottom 位置),而非内容末尾/最底部。
861
+ const g = getGeo();
862
+ const total = content.totalRows();
863
+ if (contentRow > g.contentBottom)
864
+ contentRow = g.contentBottom;
865
+ if (scrollOffset === 0 && contentRow < g.contentBottom) {
866
+ contentRow = Math.min(total, g.contentBottom);
867
+ }
576
868
  // 临时诊断:续写位 != 上次画帧位置 = spinner 运行期间续写位漂移(泄漏根因嫌疑)
577
869
  if (frameRow && (frameRow !== contentRow || frameCol !== contentCol)) {
578
870
  dbgSpinner(`DRIFT-PAINT old=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) mode=${mode} off=${scrollOffset}`);
579
871
  }
580
872
  let out = '';
581
873
  if (frameRow && (frameRow !== contentRow || frameCol !== contentCol)) {
582
- out += cup(frameRow, frameCol) + esc.clearLine; // 续写位漂移:先清旧帧行,否则残留
874
+ // 清旧帧同样钳到可视区(resize frameRow 可能 > contentBottom)
875
+ out += cup(Math.min(frameRow, g.contentBottom), frameCol) + esc.clearLine; // 续写位漂移:先清旧帧行,否则残留
583
876
  }
584
877
  out += cup(contentRow, contentCol) + esc.clearLine + text;
585
878
  if (mode === 'running') {
@@ -591,21 +884,17 @@ export function paintLiveAtCursor(text) {
591
884
  frameCol = contentCol;
592
885
  }
593
886
  /** 清掉 paintLiveAtCursor 画过的那行瞬时活动文本。清"实际画过的位置"(frameRow),非当前续写位。
594
- * 不加 isStreamingPaused 守卫——stop 时必须无条件清(只写一次、结尾 cup 回输入框,不扰 IME);否则打字中 stop 会跳过清帧、制造泄漏。 */
887
+ * 不加 isStreamingPaused 守卫——stop 时必须无条件清(只写一次、结尾 cup 回输入框,不扰 IME);否则打字中 stop 会跳过清帧、制造泄漏。
888
+ * 滚动态(scrollOffset≠0)也必须清残留帧——否则旧 spinner 帧卡在历史视图某行(「思考中在消息堆里」根因)。 */
595
889
  export function clearLiveAtCursor() {
596
- // 临时诊断:clear 被守卫跳过,但此前画过帧(frameRow)→ 帧残留(泄漏嫌疑)
597
- if (frameRow && (!active || !ui.isTTY || scrollOffset !== 0)) {
598
- dbgSpinner(`CLEAR-SKIPPED frame=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) off=${scrollOffset} active=${active}`);
599
- }
600
- if (!active || !ui.isTTY || scrollOffset !== 0)
890
+ if (!active || !ui.isTTY)
601
891
  return;
602
892
  if (!frameRow)
603
893
  return; // 没画过就不清(避免误清当前续写位内容)
604
- // 临时诊断:画帧位置 != 当前续写位 → 旧设计(清续写位)会清错行
605
- if (frameRow !== contentRow || frameCol !== contentCol) {
606
- dbgSpinner(`CLEAR-MISMATCH frame=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) mode=${mode}`);
607
- }
608
- let out = cup(frameRow, frameCol) + esc.clearLine; // 清"画过的行",非"当前续写位"
894
+ // 滚动态也清:旧帧不该残留。清"画过的行"(frameRow),回尾后该行由 repaintViewport 重画历史内容。
895
+ // resize contentBottom 可能缩小:frameRow 钳到可视区,避免 cup 到屏外行清错位置。
896
+ const g = getGeo();
897
+ let out = cup(Math.min(frameRow, g.contentBottom), frameCol) + esc.clearLine;
609
898
  if (mode === 'running') {
610
899
  const p = runningCaretPos();
611
900
  out += cup(p.row, p.col);
@@ -726,7 +1015,7 @@ export function paintInput(view) {
726
1015
  startVis: 0,
727
1016
  }
728
1017
  : windowInputVis(view.lines, view.cursorLine, view.cursorCol, preGeo.cols, promptW, preGeo.rows);
729
- const needFooterH = 4 + vis.inputRows; // 1 虚拟空 + 1 状态 + 1 上线 + 输入行 + 1 下线
1018
+ const needFooterH = 5 + vis.inputRows; // 1 虚拟空 + 1 spinner + 1 上线 + 输入行 + 1 下线 + 1 model 行
730
1019
  let g = preGeo;
731
1020
  if (needFooterH !== footerH) {
732
1021
  // setRegion 自己 write(DECSTBM + 清行 + 归位):先把已累积的擦除 flush 出去保序(擦除用的是旧几何的
@@ -744,17 +1033,19 @@ export function paintInput(view) {
744
1033
  const line = slice[g.contentBottom - 1] ?? '';
745
1034
  buf += cup(g.contentBottom, 1) + esc.clearLine + line;
746
1035
  }
747
- // 2c. 虚拟空行(内容区与状态栏之间的视觉间隔,属底栏非内容):恒清空,防底栏撑高时旧内容残留该行
1036
+ // 2c. 虚拟空行(内容区与状态栏之间的视觉间隔,属底栏非内容):恒清空,防底栏撑高时旧内容残留该行。
748
1037
  buf += cup(g.contentBottom + 1, 1) + esc.clearLine;
749
- // 3. 状态行(footerH 变或始终重画——便宜且避免旧状态行残留)
750
- const statusRow = g.contentBottom + 2; // +1 虚拟空行,+2 状态行
1038
+ // 3. 状态行:spinner 行 + model 行(两行式底栏)
1039
+ const spinnerRow = g.contentBottom + 2; // +1 虚拟空行,+2 spinner 行
1040
+ const modelRow = g.rows; // 屏底:model 行
751
1041
  const status = { ...base, status: statusText, spinnerFrame };
752
- buf += cup(statusRow, 1) + esc.clearLine + composeStatus(status, g.cols);
1042
+ buf += cup(spinnerRow, 1) + esc.clearLine + composeSpinnerLine(status, g.cols);
1043
+ buf += cup(modelRow, 1) + esc.clearLine + composeModelLine(status, g.cols);
753
1044
  // 3b. 上线(输入框顶):满屏宽细线 ─(cyan),框住输入区上边界
754
1045
  buf += cup(g.contentBottom + 3, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
755
1046
  // 4. 输入行(g.contentBottom+4 .. rows-1)——按可视行画,首行带 prompt、其余缩进 promptW
756
1047
  const firstInputRow = g.contentBottom + 4;
757
- const inputRowsAvail = g.footerH - 4; // 去掉虚拟空/状态/上线/下线,留输入行
1048
+ const inputRowsAvail = g.footerH - 5; // 去掉虚拟空/spinner行/上线/下线/model行,留输入行
758
1049
  const indent = ' '.repeat(promptW);
759
1050
  const showCaret = view.caret !== false; // 默认 true;picker 等非文本输入传 false 关闭块状光标
760
1051
  for (let i = 0; i < inputRowsAvail; i++) {
@@ -775,8 +1066,8 @@ export function paintInput(view) {
775
1066
  }
776
1067
  buf += cup(r, 1) + esc.clearLine + text;
777
1068
  }
778
- // 4b. 下线(输入框底):满屏宽细线 ─(cyan),固定屏底 rows
779
- buf += cup(g.rows, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
1069
+ // 4b. 下线(输入框底):满屏宽细线 ─(cyan),在 model 行上一行(model 行占屏底 rows)
1070
+ buf += cup(g.rows - 1, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
780
1071
  // 5. 向上菜单(画在内容区底,底栏正上方)
781
1072
  if (view.menu && view.menu.lines.length > 0) {
782
1073
  const menuRows = Math.min(view.menu.lines.length, g.contentBottom);
@@ -883,7 +1174,7 @@ export function enterInputMode(status = '空闲') {
883
1174
  repaintViewport();
884
1175
  }
885
1176
  if (active && base) {
886
- setRegion(5); // 1 虚拟空 + 1 状态 + 1 上线 + 1 输入 + 1 下线
1177
+ setRegion(6); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行(两行式底栏)
887
1178
  paintInput({
888
1179
  prompt: '❯ ',
889
1180
  lines: [''],
@@ -894,7 +1185,7 @@ export function enterInputMode(status = '空闲') {
894
1185
  stdout.write(esc.cursorShow); // 回 INPUT 态:显真光标(运行态藏了)
895
1186
  }
896
1187
  }
897
- /** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒 5(虚拟空+状态+上线+输入+下线)。新轮回尾(确保新内容可见)。 */
1188
+ /** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒 6(虚拟空+spinner行+上线+输入+下线+model行)。新轮回尾(确保新内容可见)。 */
898
1189
  export function enterRunningMode(status, placeholder) {
899
1190
  mode = 'running';
900
1191
  statusText = status;
@@ -903,7 +1194,7 @@ export function enterRunningMode(status, placeholder) {
903
1194
  resetScroll(); // 若上轮 INPUT 滚动过(未打字回底),新轮回尾
904
1195
  lockScrollToBottom(); // 轮首短时锁:吸收发消息前后残留滚轮事件,保 agent 输出从底部开始(锁过期或轮末 enterInputMode 解)
905
1196
  if (active && base) {
906
- setRegion(5); // 1 虚拟空 + 1 状态 + 1 上线 + 1 输入 + 1 下线
1197
+ setRegion(6); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行
907
1198
  paintInput({
908
1199
  prompt: '❯ ',
909
1200
  lines: [''],
@@ -926,8 +1217,9 @@ export function enterAltScreen() {
926
1217
  return;
927
1218
  active = true;
928
1219
  stdout.write(esc.altOn);
929
- stdout.write(esc.mouseOn); // SGR 鼠标追踪:滚轮发 \x1B[<btn;col;rowM 报表,经 mouse.consumeMouse 重组 → scrollBy(滚轮滚动靠此 + onKey/onRunningKey 顶部守卫)
930
- setRegion(5); // 1 虚拟空 + 1 状态 + 1 上线 + 1 输入 + 1 下线(底栏始终含上下线)
1220
+ stdout.write(esc.mouseOn); // 完整鼠标追踪(按下/拖动/释放/滚轮)→ mouse.swallow 重组 → handleMouseEvent
1221
+ mouse.setHandler(handleMouseEvent);
1222
+ setRegion(6); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行(两行式底栏)
931
1223
  contentRow = 1;
932
1224
  contentCol = 1;
933
1225
  segmentStartRow = 1;
@@ -935,26 +1227,39 @@ export function enterAltScreen() {
935
1227
  scrollLockUntil = 0;
936
1228
  mdActive = false;
937
1229
  mdBuf = '';
1230
+ selection = null;
1231
+ selecting = false;
938
1232
  content.reset();
939
1233
  exitHandler = () => exitAltScreen();
940
1234
  process.on('exit', exitHandler);
941
1235
  sigwinchHandler = () => {
942
1236
  if (!active)
943
1237
  return;
1238
+ // 立即(同步)更新行号 + 重设 DECSTBM 区域:不 debounce,否则快速拖动边框时
1239
+ // contentRow 停在旧值、区域未更新,spinner/contentWrite 画到旧行号(「思考中在消息堆里」根因)。
1240
+ // 重画 repaintViewport 防抖(下面 timer),避免连续拖动闪烁;但行号/区域必须立即正确。
1241
+ const g = getGeo(footerH);
1242
+ const total = content.totalRows();
1243
+ // 缩小:contentRow > 新 bottom → 钳到新 bottom
1244
+ if (contentRow > g.contentBottom)
1245
+ contentRow = g.contentBottom;
1246
+ // 放大:contentRow < 新 bottom 且回尾(offset=0)→ 推进到 min(total, bottom)
1247
+ if (scrollOffset === 0 && contentRow < g.contentBottom) {
1248
+ contentRow = Math.min(total, g.contentBottom);
1249
+ }
1250
+ if (frameRow && frameRow > g.contentBottom)
1251
+ frameRow = g.contentBottom;
1252
+ const maxOff = Math.max(0, total - g.contentBottom);
1253
+ if (scrollOffset > maxOff)
1254
+ scrollOffset = maxOff;
1255
+ setRegion(footerH); // 用新 rows 立即重设 DECSTBM 区域(contentBottom 变)
1256
+ // 重画防抖(仅 repaint,避免快速拖动闪烁);行号/区域已立即更新
944
1257
  if (resizeTimer)
945
1258
  clearTimeout(resizeTimer);
946
1259
  resizeTimer = setTimeout(() => {
947
1260
  resizeTimer = null;
948
1261
  if (!active)
949
1262
  return;
950
- const g = getGeo(footerH);
951
- if (contentRow > g.contentBottom)
952
- contentRow = g.contentBottom;
953
- // offset 钳到新 maxOff(高度缩可能使旧 offset 失效)
954
- const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
955
- if (scrollOffset > maxOff)
956
- scrollOffset = maxOff;
957
- setRegion(footerH); // 用新 rows 重设区域(contentBottom 变)
958
1263
  repaintViewport(); // 内容区按新高度 + 当前 offset 重画(物理行不 reflow)
959
1264
  repaint(); // 底栏重画
960
1265
  }, 100);
@@ -972,7 +1277,10 @@ export function exitAltScreen() {
972
1277
  scrollLockUntil = 0; // 清轮首滚动锁(防状态泄漏到下次进 alt 屏)
973
1278
  frameRow = 0; // 清 spinner 帧位置(防状态泄漏到下次进 alt 屏)
974
1279
  frameCol = 0;
975
- mouse.resetMouse(); // 清鼠标重组残留(防退出后状态泄漏到下次进 alt 屏)
1280
+ mouse.setHandler(null);
1281
+ mouse.resetMouse();
1282
+ selection = null;
1283
+ selecting = false;
976
1284
  // raw 还原独立 try:非 TTY / 不支持时 setRawMode 抛错,不应阻断 stdout 恢复(alt 退屏必须执行)。
977
1285
  try {
978
1286
  stdin.setRawMode(false); // 还原 raw(RUNNING 态常驻 raw,退出时必须还原,否则终端残留 raw 模式)
@@ -983,7 +1291,7 @@ export function exitAltScreen() {
983
1291
  try {
984
1292
  stdout.write('\x1B[r'); // 复位 DECSTBM margins
985
1293
  stdout.write(esc.cursorShow);
986
- stdout.write(esc.mouseOff); // 关 SGR 鼠标追踪(反序:先 1006l 再 1000l)
1294
+ stdout.write(esc.mouseOff); // 关鼠标追踪(反序:先 1006l 再 1002l 再 1000l)
987
1295
  stdout.write(esc.altOff); // 退 alt(恢复主屏 + 光标)
988
1296
  }
989
1297
  catch {