mocode-ai 0.1.8 → 0.1.10

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.
Files changed (37) hide show
  1. package/README.md +65 -52
  2. package/dist/agent/core.js +8 -4
  3. package/dist/agent/index.js +5 -10
  4. package/dist/commands/config.js +1 -1
  5. package/dist/config/file.js +1 -1
  6. package/dist/config/index.js +56 -13
  7. package/dist/context/classifier.js +2 -1
  8. package/dist/index.js +2 -1
  9. package/dist/llm/index.js +13 -1
  10. package/dist/repl/index.js +204 -15
  11. package/dist/session/drop.js +135 -0
  12. package/dist/session/index.js +1 -0
  13. package/dist/tools/builtins/ask-human.js +4 -4
  14. package/dist/tools/builtins/codegraph.js +5 -3
  15. package/dist/tools/builtins/drop-context.js +68 -0
  16. package/dist/tools/builtins/edit-file.js +1 -1
  17. package/dist/tools/builtins/glob.js +2 -2
  18. package/dist/tools/builtins/grep.js +2 -2
  19. package/dist/tools/builtins/index.js +2 -0
  20. package/dist/tools/builtins/memory-forget.js +1 -1
  21. package/dist/tools/builtins/memory-list.js +1 -1
  22. package/dist/tools/builtins/memory-save.js +1 -1
  23. package/dist/tools/builtins/memory-search.js +1 -1
  24. package/dist/tools/builtins/memory-update.js +1 -1
  25. package/dist/tools/builtins/read-file.js +2 -2
  26. package/dist/tools/builtins/run-command.js +1 -1
  27. package/dist/tools/builtins/switch-mode.js +3 -5
  28. package/dist/tools/builtins/task.js +4 -5
  29. package/dist/tools/builtins/use-skill.js +1 -1
  30. package/dist/tools/builtins/web-fetch.js +1 -1
  31. package/dist/tools/builtins/web-search.js +1 -1
  32. package/dist/tools/builtins/write-file.js +1 -1
  33. package/dist/tools/registry.js +6 -1
  34. package/dist/ui/intervention.js +0 -11
  35. package/dist/ui/layout.js +173 -91
  36. package/dist/ui/prompt.js +2 -14
  37. package/package.json +40 -40
@@ -4,8 +4,8 @@ import { MAX_FILE_LINES } from '../constants.js';
4
4
  // ---------- read_file ----------
5
5
  export const readFileTool = {
6
6
  name: 'read_file',
7
- description: 'Read file content, returning text with line numbers. Read before editing code. Optional offset (start line, 1-based, default 1) and limit (number of lines, default 2000).' +
8
- ' Note: when understanding code architecture/call chains, if a .codegraph/ index exists, prefer the codegraph tool over piecing together via read_file one file at a time.',
7
+ description: 'Read file content with line numbers. Read before editing. offset (1-based, default 1), limit (default 2000).' +
8
+ ' For architecture/call chains, prefer codegraph over reading files one at a time.',
9
9
  parameters: {
10
10
  type: 'object',
11
11
  properties: {
@@ -4,7 +4,7 @@ import { getSandboxRoot, filterEnv, isCommandDenied } from '../../sandbox/index.
4
4
  // ---------- run_command ----------
5
5
  export const runCommandTool = {
6
6
  name: 'run_command',
7
- description: 'Execute a shell command, returning combined stdout+stderr. Default timeout 120s. Use for running tests, builds, git, etc.',
7
+ description: 'Run a shell command, merging stdout+stderr. Default timeout 120s. For tests, builds, git, etc.',
8
8
  parameters: {
9
9
  type: 'object',
10
10
  properties: {
@@ -3,11 +3,9 @@ import { setAgentMode, getAgentMode } from '../../agent/mode.js';
3
3
  export const switchModeTool = {
4
4
  name: 'switch_mode',
5
5
  description: [
6
- 'Switch the agent mode between "plan" (read-only investigation + planning) and "auto" (full tool execution).',
7
- 'In plan mode, write_file / edit_file / run_command / memory_save / memory_update / memory_forget are disabled; in auto mode, all tools are available.',
8
- 'Use this to autonomously transition from planning to execution WITHIN THE SAME TURN: investigate in plan mode, produce a plan, then call switch_mode("auto") and continue implementing it in the same turn — ONLY when the user has explicitly asked you to "plan first then execute" / "先 plan 再 auto" / autonomous execution.',
9
- 'Do NOT switch to auto if the user entered plan mode manually (via /plan or Shift+Tab) for a safety review — in that case present the plan and STOP; the user will approve via a prompt and execution happens in a follow-up turn.',
10
- 'Switching to plan from auto is rarely needed; do it only if you realize you should investigate before changing anything.',
6
+ 'Switch agent mode between "plan" (read-only investigation) and "auto" (full tool execution).',
7
+ 'Use to transition from planning to execution WITHIN THE SAME TURN: in plan, after presenting a plan, call switch_mode("auto") and continue — ONLY when the user asked for autonomous execution ("先 plan 再 auto" etc.).',
8
+ 'If the user entered plan mode manually (/plan or Shift+Tab) for review, do NOT switch to auto — present the plan and STOP for approval.',
11
9
  ].join(' '),
12
10
  parameters: {
13
11
  type: 'object',
@@ -9,10 +9,9 @@ import { MAX_OUTPUT } from '../constants.js';
9
9
  export const taskTool = {
10
10
  name: 'task',
11
11
  description: [
12
- 'Spawn a sub-agent to handle an isolated sub-task with its own conversation history (independent of the main thread). The sub-agent runs to completion and returns a concise summary.',
13
- 'Use when: a task can be decomposed into independent sub-tasks, you want to explore multiple files/areas without polluting the main history, or a sub-task involves many tool calls that would bloat the main context window.',
14
- 'The sub-agent has its own history; only its final summary is returned to you as the tool result. It cannot recursively spawn further sub-agents (no "task" tool available to it).',
15
- 'Optionally restrict the sub-agent to a subset of tools (e.g. read-only tools for pure investigation) via the "tools" parameter.',
12
+ 'Spawn a sub-agent for an isolated sub-task (independent history; only its final summary returns to you).',
13
+ 'Use when a task splits into independent parts or its many tool calls would bloat your context.',
14
+ 'Cannot recursively spawn sub-agents.',
16
15
  ].join(''),
17
16
  parameters: {
18
17
  type: 'object',
@@ -24,7 +23,7 @@ export const taskTool = {
24
23
  tools: {
25
24
  type: 'array',
26
25
  items: { type: 'string' },
27
- description: 'Optional whitelist of tool names the sub-agent is allowed to use (e.g. ["read_file","glob","grep","codegraph"] for read-only investigation). Omit to allow all tools.',
26
+ description: 'Optional whitelist of tool names the sub-agent is allowed to use (e.g. ["read_file","glob","grep","codegraph"] for read-only investigation). Omit to allow all tools. If the sub-task needs verification/build/test (running scripts, typecheck, etc.), remember to include "run_command".',
28
27
  },
29
28
  maxSteps: {
30
29
  type: 'number',
@@ -4,7 +4,7 @@ import { getSkillBody } from '../../skills/index.js';
4
4
  // 系统提示里已列出可用 skill 的 name + description(何时用),模型据此决定调用。
5
5
  export const useSkillTool = {
6
6
  name: 'use_skill',
7
- description: 'Load and return the full SKILL.md instructions for a given skill (pass name). See the skill list in the system prompt for when to use each.',
7
+ description: 'Load the full SKILL.md instructions for a given skill. See the skill list in the system prompt for when to use each.',
8
8
  parameters: {
9
9
  type: 'object',
10
10
  properties: {
@@ -4,7 +4,7 @@ const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,
4
4
  // ---------- web_fetch ----------
5
5
  export const webFetchTool = {
6
6
  name: 'web_fetch',
7
- description: 'Fetch the web page at a given URL and clean it into plain text (strips HTML tags/scripts/styles, keeps the body). Use to read a link from search results, or a specific URL given by the user.',
7
+ description: 'Fetch a URL and clean HTML to body text. Use to read a link from search results or a URL given by the user.',
8
8
  parameters: {
9
9
  type: 'object',
10
10
  properties: {
@@ -6,7 +6,7 @@ const MAX_CONTENT_CHARS = 800;
6
6
  // ---------- web_search ----------
7
7
  export const webSearchTool = {
8
8
  name: 'web_search',
9
- description: 'Search the web (AnySearch). Returns title/url/snippet/body for each result. Optional tag to switch sub-domain capability (see tag param).',
9
+ description: 'Search the web (AnySearch). Returns title/url/snippet/body per result. Optional tag for sub-domain capability.',
10
10
  parameters: {
11
11
  type: 'object',
12
12
  properties: {
@@ -3,7 +3,7 @@ import { resolve, dirname } from 'node:path';
3
3
  // ---------- write_file ----------
4
4
  export const writeFileTool = {
5
5
  name: 'write_file',
6
- description: 'Create or overwrite a file, creating parent directories as needed.',
6
+ description: 'Create or overwrite a file; parent dirs created.',
7
7
  parameters: {
8
8
  type: 'object',
9
9
  properties: {
@@ -11,6 +11,7 @@ export const tools = builtinTools;
11
11
  * signal 透传给 tool.execute(经 ctx):长任务工具(run_command/web_fetch)abort 即时取消,
12
12
  * 让用户 Ctrl+C 能跟手中断工具执行(而非等命令跑完 / 超时)。
13
13
  * opts.skipRollback:子 agent 逻辑隔离用——跳过 recordMutation,子 agent 改动不进主回滚快照链。
14
+ * opts.dropContext:上下文剔除回调(drop_context 工具用),透传给 tool.execute 经 ctx。
14
15
  */
15
16
  export async function executeTool(name, argsRaw, signal, opts) {
16
17
  const tool = tools.find((t) => t.name === name);
@@ -38,7 +39,11 @@ export async function executeTool(name, argsRaw, signal, opts) {
38
39
  args.path) {
39
40
  recordMutation(args.path);
40
41
  }
41
- return await tool.execute(args, { signal, skipRollback: opts?.skipRollback });
42
+ return await tool.execute(args, {
43
+ signal,
44
+ skipRollback: opts?.skipRollback,
45
+ dropContext: opts?.dropContext,
46
+ });
42
47
  }
43
48
  catch (e) {
44
49
  return `错误:工具 ${name} 执行失败: ${e instanceof Error ? e.message : String(e)}`;
@@ -3,7 +3,6 @@ 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';
7
6
  import { Spinner } from './spinner.js';
8
7
  const emitter = stdin;
9
8
  /** choice 末尾的"自定义输入"项标签(纯 ASCII,宽度安全)。选中后切到 input 子态。 */
@@ -148,16 +147,6 @@ export async function promptIntervention(req) {
148
147
  function onKey(_str, key) {
149
148
  if (resolved || !key)
150
149
  return;
151
- // SGR 鼠标 fragment:readline 拆碎 \x1B[<…M,经 mouse.consumeMouse 重组;
152
- // 滚轮 → scrollBy(±5) 滚动查看 agent 输出内容(菜单只靠 ↑↓ 选,滚轮不选菜单);
153
- // fragment 期间 suppress 吞掉防砸进 onKeyInput 的可打印插入。菜单经 scrollBy 末尾
154
- // repaint()→paintInput(lastView) 重画,不被 viewport 滚动覆盖。
155
- const _m = mouse.consumeMouse(key.sequence ?? '');
156
- if (_m.suppress) {
157
- if (_m.wheel)
158
- layout.scrollBy(_m.wheel * 5);
159
- return;
160
- }
161
150
  // Ctrl+C → 取消(不 reject SIGINT——否则经 executeTool 的 try/catch 变成 tool 错误串)
162
151
  if (key.ctrl && key.name === 'c') {
163
152
  finish({ action: 'cancelled' });
package/dist/ui/layout.js CHANGED
@@ -5,12 +5,11 @@ import { join } from 'node:path';
5
5
  import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, } 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
8
  import { renderMarkdown } from './markdown.js';
10
9
  // ── 内部状态 ──
11
10
  let active = false;
12
11
  let mode = 'input';
13
- let footerH = 5; // 1 虚拟空行 + 1 状态行 + 1 上线 + 输入行数 + 1 下线(虚拟空行隔开内容与状态栏,上下线框住输入区)
12
+ let footerH = 6; // 1 虚拟空行 + 1 spinner行 + 1 上线 + 输入行数 + 1 下线 + 1 model行(两行式底栏)
14
13
  let contentRow = 1; // 续写位行(1-based,屏坐标,[1,contentBottom])
15
14
  let contentCol = 1; // 续写位列(1-based)
16
15
  // 上一次 paintLiveAtCursor 实际画帧的屏坐标(0=未画)。clearLiveAtCursor 清"这行"而非当前续写位——
@@ -50,8 +49,13 @@ let mdBuf = '';
50
49
  const esc = {
51
50
  altOn: '\x1B[?1049h',
52
51
  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 编码再关追踪)
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',
55
59
  cursorShow: '\x1B[?25h',
56
60
  cursorHide: '\x1B[?25l',
57
61
  clearLine: '\x1B[2K',
@@ -164,11 +168,19 @@ export function contentWrite(s) {
164
168
  commitMd(); // 非 md 写接续:先收尾 md 段(清 segMark,否则 setLines 会截到旧段)
165
169
  // 滚动回看时(scrollOffset>0)只喂缓冲 + 推进续写位,不物理写——否则新流式内容覆盖 viewport 历史行。
166
170
  // 回尾(scrollOffset===0)才物理写;写起点 = 当前续写位(loop 会推进续写位,故先捕获)。
167
- const startRow = contentRow;
168
- const startCol = contentCol;
169
171
  const g = getGeo();
170
172
  const cols = g.cols;
171
173
  const bottom = g.contentBottom;
174
+ // resize 后 contentRow 可能过时(拖终端框):
175
+ // - 缩小:contentRow > 新 bottom → 钳到新 bottom
176
+ // - 放大:contentRow < 新 bottom 且回尾 → 推进到 min(total, bottom)
177
+ if (contentRow > bottom)
178
+ contentRow = bottom;
179
+ else if (scrollOffset === 0 && contentRow < bottom) {
180
+ contentRow = Math.min(content.totalRows(), bottom);
181
+ }
182
+ const startRow = contentRow;
183
+ const startCol = contentCol;
172
184
  const advanceRow = (r) => (r >= bottom ? bottom : r + 1); // 到底则滚动,续写位留底
173
185
  // 滚动回看冻结(仿 ink store.pushBlock 的 offset+=newLines):scrollOffset>0 时新内容只入缓冲,
174
186
  // 但缓冲增长后若 offset 不变,下次 scrollBy→repaintViewport 会取漂移窗口、把新内容画进历史视图
@@ -416,6 +428,12 @@ export function scrollBy(delta) {
416
428
  const off = Math.max(0, Math.min(scrollOffset + delta, maxOff));
417
429
  if (off === scrollOffset)
418
430
  return;
431
+ // 进入滚动态前清掉 spinner 内联帧:滚动态由状态行心跳兜底,内联帧若残留会卡在历史视图某行。
432
+ if (scrollOffset === 0 && off > 0 && frameRow) {
433
+ stdout.write(cup(Math.min(frameRow, g.contentBottom), frameCol) + esc.clearLine);
434
+ frameRow = 0;
435
+ frameCol = 0;
436
+ }
419
437
  scrollOffset = off;
420
438
  repaintViewport();
421
439
  repaint();
@@ -443,66 +461,96 @@ export function unlockScroll() {
443
461
  export function isScrollLocked() {
444
462
  return Date.now() < scrollLockUntil;
445
463
  }
446
- // ── 状态行 ──
447
- /** 组合状态行可见串(带色),按 cols 截断防溢出(不折到输入行)。 */
448
- function composeStatus(status, cols) {
449
- const sep = ' ';
450
- const sepW = sep.length;
451
- // 运行态(含滚动回看)用心跳帧(♥/♡)替换静态 ◆:agent 跑起来时状态行前导符始终跳动,
452
- // 滑到上面看历史也能感知 agent 在跑。帧宽=1(与 ◆ 同),fixed 不变,布局不乱。INPUT 态仍显 ◆。
464
+ // ── 状态行(两行式:spinner 行 + model 行)──
465
+ const STATUS_SEP = ' ';
466
+ const STATUS_SEP_W = STATUS_SEP.length;
467
+ /** 通用两段式排版:左段左端对齐 + 空格填充 + 右段右端对齐。pad 至少 1 空格防粘连。 */
468
+ function twoColumn(leftStr, leftW, rightStr, rightW, cols) {
469
+ const pad = Math.max(1, cols - leftW - rightW);
470
+ return `${leftStr}${' '.repeat(pad)}${rightStr}`;
471
+ }
472
+ /** 上线之上那行(spinner 行):左段 = spinner 帧 + 状态文字 + 走时(全部左对齐,紧跟不分离)。
473
+ * 右段仅在滚动回看时显历史指示(右端对齐)。
474
+ * 示例:
475
+ * INPUT: ◆ 空闲
476
+ * 思考中: ⠹ 思考中… 0.5s
477
+ * 运行心跳: ♥ 0.5s
478
+ * 滚动回看: ◆ 历史 ↑3 (PgDn 回底) */
479
+ function composeSpinnerLine(status, cols) {
453
480
  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 态)
481
+ const hasSpinner = !!status.spinnerFrame;
464
482
  const scrolled = scrollOffset > 0;
465
- let st;
483
+ // 走时(仅运行态)
484
+ const elapsed = (mode === 'running' && turnStart != null)
485
+ ? fmtElapsed(Date.now() - turnStart)
486
+ : '';
487
+ // ── 左段:帧 + 状态 + 走时,全部紧跟 ──
488
+ let lead;
489
+ let leadW;
466
490
  if (scrolled) {
467
- st = `历史 ↑${scrollOffset} (PgDn 回底)`; // 滚动回看显历史指示,不显走时
491
+ // 滚动回看:左段仅显 ◆(或心跳帧),右段显历史指示
492
+ lead = `${spinning ? ui.brightMagenta : ui.brightCyan}${spinning ? RUNNING_FRAMES[runningFrame] : '◆'}${ui.reset}`;
493
+ leadW = 1;
494
+ }
495
+ else if (hasSpinner) {
496
+ // spinner 激活(思考中/执行工具…):帧 + 状态 + 走时
497
+ const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
498
+ lead = `${ui.brightMagenta}${status.spinnerFrame}${ui.reset} ${ui.dim}${status.status}${ui.reset}${ePart}`;
499
+ leadW = 1 + 1 + displayWidth(status.status) + (elapsed ? 1 + displayWidth(elapsed) : 0);
500
+ }
501
+ else if (spinning) {
502
+ // 运行态心跳帧(流式输出中):帧 + 走时
503
+ const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
504
+ lead = `${ui.brightMagenta}${RUNNING_FRAMES[runningFrame]}${ui.reset}${ePart}`;
505
+ leadW = 1 + (elapsed ? 1 + displayWidth(elapsed) : 0);
468
506
  }
469
507
  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
- }
508
+ // INPUT 态:◆ + 状态文字(无走时)
509
+ lead = `${ui.brightCyan}◆${ui.reset} ${ui.dim}${status.status}${ui.reset}`;
510
+ leadW = 1 + 1 + displayWidth(status.status);
511
+ }
512
+ // ── 右段:仅滚动回看时显历史指示 ──
513
+ let tail = '';
514
+ let tailW = 0;
515
+ if (scrolled) {
516
+ tail = `历史 ↑${scrollOffset} (PgDn 回底)`;
517
+ tailW = displayWidth(tail);
481
518
  }
482
- const stW = displayWidth(st);
483
- const fixed = displayWidth(lead) + modeChipW + displayWidth(model) + sepW * 3 + ctxW + stW;
484
- const cwdBudget = cols - fixed - 1;
519
+ const rightStr = tail ? `${ui.yellow}${tail}${ui.reset}` : '';
520
+ return twoColumn(lead, leadW, rightStr, tailW, cols);
521
+ }
522
+ /** 下线之下那行(model 行):左 = 模式标识(auto 跟随主题色 / plan 显亮黄);右 = context + cwd,右端对齐。 */
523
+ function composeModelLine(status, cols) {
524
+ const ctx = status.contextBar; // 已带色
525
+ const ctxW = ansiDisplayWidth(ctx);
526
+ // 左段:模式标识(auto 显 brightCyan 随主题 / plan 显亮黄)
527
+ const modeTag = status.modeTag ?? '';
528
+ const leftStr = modeTag
529
+ ? `${modeTag === 'plan' ? ui.yellow : ui.brightCyan}${modeTag}${ui.reset}`
530
+ : '';
531
+ const leftW = modeTag ? displayWidth(modeTag) : 0;
532
+ // 右段:ctx + sep + cwd,右端对齐。cwd 按预算截断,极窄(<6)隐藏。
533
+ const minGap = 2;
534
+ const cwdBudget = cols - leftW - minGap - ctxW - STATUS_SEP_W - 1;
485
535
  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 频繁调。 */
536
+ const cwdW = displayWidth(cwd);
537
+ const rightStr = `${ctx}${STATUS_SEP}${ui.dim}${cwd}${ui.reset}`;
538
+ const rightW = ctxW + STATUS_SEP_W + cwdW;
539
+ return twoColumn(leftStr, leftW, rightStr, rightW, cols);
540
+ }
541
+ /** 画状态行(spinner 行 + model 行,两行)。RUNNING 态 spinner 频繁调。
542
+ * 行号(footerH=6):spinner 行=contentBottom+2,model 行=rows(屏底)。
543
+ * 上线 contentBottom+3 / 输入 contentBottom+4 / 下线 contentBottom+5 由 paintInput 画,此函数只刷两行信息。 */
497
544
  export function drawStatusBar(status) {
498
545
  if (!active || !base)
499
546
  return;
500
547
  const s = status ?? { ...base, status: statusText, spinnerFrame };
501
548
  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);
549
+ const spinnerRow = g.contentBottom + 2; // +1 虚拟空行,+2 spinner 行
550
+ const modelRow = g.rows; // 屏底:model 行
551
+ // 一次写入:cup spinner 行 + clearLine + spinner 行内容 + cup model 行 + clearLine + model 行内容 + cup 回。
552
+ let out = cup(spinnerRow, 1) + esc.clearLine + composeSpinnerLine(s, g.cols) +
553
+ cup(modelRow, 1) + esc.clearLine + composeModelLine(s, g.cols);
506
554
  if (mode === 'running') {
507
555
  // 运行态(回尾 / 滚动回看均):cup 回输入框光标位(供 IME 锚定)。
508
556
  const p = runningCaretPos();
@@ -568,18 +616,44 @@ function dbgSpinner(msg) {
568
616
  */
569
617
  export function paintLiveAtCursor(text) {
570
618
  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()}`);
619
+ // 滚动态 / 暂停态:不画新帧。但若有旧帧残留(frameRow),必须清掉——
620
+ // 否则旧 spinner 帧停在历史视图某行,用户看到「思考中」卡在消息堆里(根因)。
621
+ // 滚动态由状态行心跳兜底显示运行状态,不需内联帧;清完后 frameRow 归零,回尾时下一帧自然重画。
622
+ if (frameRow) {
623
+ // resize 后 contentBottom 可能缩小:清旧帧时钳 frameRow 到当前可视区,避免 cup 到屏外行
624
+ const g = getGeo();
625
+ const fr = Math.min(frameRow, g.contentBottom);
626
+ let out = cup(fr, frameCol) + esc.clearLine;
627
+ if (mode === 'running') {
628
+ const p = runningCaretPos();
629
+ out += cup(p.row, p.col);
630
+ }
631
+ stdout.write(out);
632
+ frameRow = 0;
633
+ frameCol = 0;
634
+ }
574
635
  return;
575
636
  }
637
+ // resize 后 contentRow 可能过时(拖终端框):
638
+ // - 缩小:contentRow > 新 bottom → 钳到新 bottom
639
+ // - 放大:contentRow < 新 bottom 且回尾 → 推进到 min(total, bottom)(内容末尾或新区底)
640
+ // 内容够填满:推进到 bottom(最底部);内容不够:推进到 total(紧跟内容尾)
641
+ // 否则 cup 到旧行号→帧画在屏幕中间(旧 bottom 位置),而非内容末尾/最底部。
642
+ const g = getGeo();
643
+ const total = content.totalRows();
644
+ if (contentRow > g.contentBottom)
645
+ contentRow = g.contentBottom;
646
+ if (scrollOffset === 0 && contentRow < g.contentBottom) {
647
+ contentRow = Math.min(total, g.contentBottom);
648
+ }
576
649
  // 临时诊断:续写位 != 上次画帧位置 = spinner 运行期间续写位漂移(泄漏根因嫌疑)
577
650
  if (frameRow && (frameRow !== contentRow || frameCol !== contentCol)) {
578
651
  dbgSpinner(`DRIFT-PAINT old=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) mode=${mode} off=${scrollOffset}`);
579
652
  }
580
653
  let out = '';
581
654
  if (frameRow && (frameRow !== contentRow || frameCol !== contentCol)) {
582
- out += cup(frameRow, frameCol) + esc.clearLine; // 续写位漂移:先清旧帧行,否则残留
655
+ // 清旧帧同样钳到可视区(resize 后 frameRow 可能 > contentBottom)
656
+ out += cup(Math.min(frameRow, g.contentBottom), frameCol) + esc.clearLine; // 续写位漂移:先清旧帧行,否则残留
583
657
  }
584
658
  out += cup(contentRow, contentCol) + esc.clearLine + text;
585
659
  if (mode === 'running') {
@@ -591,21 +665,17 @@ export function paintLiveAtCursor(text) {
591
665
  frameCol = contentCol;
592
666
  }
593
667
  /** 清掉 paintLiveAtCursor 画过的那行瞬时活动文本。清"实际画过的位置"(frameRow),非当前续写位。
594
- * 不加 isStreamingPaused 守卫——stop 时必须无条件清(只写一次、结尾 cup 回输入框,不扰 IME);否则打字中 stop 会跳过清帧、制造泄漏。 */
668
+ * 不加 isStreamingPaused 守卫——stop 时必须无条件清(只写一次、结尾 cup 回输入框,不扰 IME);否则打字中 stop 会跳过清帧、制造泄漏。
669
+ * 滚动态(scrollOffset≠0)也必须清残留帧——否则旧 spinner 帧卡在历史视图某行(「思考中在消息堆里」根因)。 */
595
670
  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)
671
+ if (!active || !ui.isTTY)
601
672
  return;
602
673
  if (!frameRow)
603
674
  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; // 清"画过的行",非"当前续写位"
675
+ // 滚动态也清:旧帧不该残留。清"画过的行"(frameRow),回尾后该行由 repaintViewport 重画历史内容。
676
+ // resize 后 contentBottom 可能缩小:frameRow 钳到可视区,避免 cup 到屏外行清错位置。
677
+ const g = getGeo();
678
+ let out = cup(Math.min(frameRow, g.contentBottom), frameCol) + esc.clearLine;
609
679
  if (mode === 'running') {
610
680
  const p = runningCaretPos();
611
681
  out += cup(p.row, p.col);
@@ -726,7 +796,7 @@ export function paintInput(view) {
726
796
  startVis: 0,
727
797
  }
728
798
  : windowInputVis(view.lines, view.cursorLine, view.cursorCol, preGeo.cols, promptW, preGeo.rows);
729
- const needFooterH = 4 + vis.inputRows; // 1 虚拟空 + 1 状态 + 1 上线 + 输入行 + 1 下线
799
+ const needFooterH = 5 + vis.inputRows; // 1 虚拟空 + 1 spinner 行 + 1 上线 + 输入行 + 1 下线 + 1 model 行
730
800
  let g = preGeo;
731
801
  if (needFooterH !== footerH) {
732
802
  // setRegion 自己 write(DECSTBM + 清行 + 归位):先把已累积的擦除 flush 出去保序(擦除用的是旧几何的
@@ -746,15 +816,17 @@ export function paintInput(view) {
746
816
  }
747
817
  // 2c. 虚拟空行(内容区与状态栏之间的视觉间隔,属底栏非内容):恒清空,防底栏撑高时旧内容残留该行
748
818
  buf += cup(g.contentBottom + 1, 1) + esc.clearLine;
749
- // 3. 状态行(footerH 变或始终重画——便宜且避免旧状态行残留)
750
- const statusRow = g.contentBottom + 2; // +1 虚拟空行,+2 状态行
819
+ // 3. 状态行:spinner 行 + model 行(两行式底栏)
820
+ const spinnerRow = g.contentBottom + 2; // +1 虚拟空行,+2 spinner 行
821
+ const modelRow = g.rows; // 屏底:model 行
751
822
  const status = { ...base, status: statusText, spinnerFrame };
752
- buf += cup(statusRow, 1) + esc.clearLine + composeStatus(status, g.cols);
823
+ buf += cup(spinnerRow, 1) + esc.clearLine + composeSpinnerLine(status, g.cols);
824
+ buf += cup(modelRow, 1) + esc.clearLine + composeModelLine(status, g.cols);
753
825
  // 3b. 上线(输入框顶):满屏宽细线 ─(cyan),框住输入区上边界
754
826
  buf += cup(g.contentBottom + 3, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
755
827
  // 4. 输入行(g.contentBottom+4 .. rows-1)——按可视行画,首行带 prompt、其余缩进 promptW
756
828
  const firstInputRow = g.contentBottom + 4;
757
- const inputRowsAvail = g.footerH - 4; // 去掉虚拟空/状态/上线/下线,留输入行
829
+ const inputRowsAvail = g.footerH - 5; // 去掉虚拟空/spinner行/上线/下线/model行,留输入行
758
830
  const indent = ' '.repeat(promptW);
759
831
  const showCaret = view.caret !== false; // 默认 true;picker 等非文本输入传 false 关闭块状光标
760
832
  for (let i = 0; i < inputRowsAvail; i++) {
@@ -775,8 +847,8 @@ export function paintInput(view) {
775
847
  }
776
848
  buf += cup(r, 1) + esc.clearLine + text;
777
849
  }
778
- // 4b. 下线(输入框底):满屏宽细线 ─(cyan),固定屏底 rows
779
- buf += cup(g.rows, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
850
+ // 4b. 下线(输入框底):满屏宽细线 ─(cyan),在 model 行上一行(model 行占屏底 rows)
851
+ buf += cup(g.rows - 1, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
780
852
  // 5. 向上菜单(画在内容区底,底栏正上方)
781
853
  if (view.menu && view.menu.lines.length > 0) {
782
854
  const menuRows = Math.min(view.menu.lines.length, g.contentBottom);
@@ -883,7 +955,7 @@ export function enterInputMode(status = '空闲') {
883
955
  repaintViewport();
884
956
  }
885
957
  if (active && base) {
886
- setRegion(5); // 1 虚拟空 + 1 状态 + 1 上线 + 1 输入 + 1 下线
958
+ setRegion(6); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行(两行式底栏)
887
959
  paintInput({
888
960
  prompt: '❯ ',
889
961
  lines: [''],
@@ -894,7 +966,7 @@ export function enterInputMode(status = '空闲') {
894
966
  stdout.write(esc.cursorShow); // 回 INPUT 态:显真光标(运行态藏了)
895
967
  }
896
968
  }
897
- /** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒 5(虚拟空+状态+上线+输入+下线)。新轮回尾(确保新内容可见)。 */
969
+ /** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒 6(虚拟空+spinner行+上线+输入+下线+model行)。新轮回尾(确保新内容可见)。 */
898
970
  export function enterRunningMode(status, placeholder) {
899
971
  mode = 'running';
900
972
  statusText = status;
@@ -903,7 +975,7 @@ export function enterRunningMode(status, placeholder) {
903
975
  resetScroll(); // 若上轮 INPUT 滚动过(未打字回底),新轮回尾
904
976
  lockScrollToBottom(); // 轮首短时锁:吸收发消息前后残留滚轮事件,保 agent 输出从底部开始(锁过期或轮末 enterInputMode 解)
905
977
  if (active && base) {
906
- setRegion(5); // 1 虚拟空 + 1 状态 + 1 上线 + 1 输入 + 1 下线
978
+ setRegion(6); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行
907
979
  paintInput({
908
980
  prompt: '❯ ',
909
981
  lines: [''],
@@ -926,8 +998,8 @@ export function enterAltScreen() {
926
998
  return;
927
999
  active = true;
928
1000
  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 下线(底栏始终含上下线)
1001
+ stdout.write(esc.altScrollOn); // alt 屏滚轮转发 ↑/↓(不抓鼠标点击/拖拽,原生选区复制不受影响)
1002
+ setRegion(6); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行(两行式底栏)
931
1003
  contentRow = 1;
932
1004
  contentCol = 1;
933
1005
  segmentStartRow = 1;
@@ -941,20 +1013,31 @@ export function enterAltScreen() {
941
1013
  sigwinchHandler = () => {
942
1014
  if (!active)
943
1015
  return;
1016
+ // 立即(同步)更新行号 + 重设 DECSTBM 区域:不 debounce,否则快速拖动边框时
1017
+ // contentRow 停在旧值、区域未更新,spinner/contentWrite 画到旧行号(「思考中在消息堆里」根因)。
1018
+ // 重画 repaintViewport 防抖(下面 timer),避免连续拖动闪烁;但行号/区域必须立即正确。
1019
+ const g = getGeo(footerH);
1020
+ const total = content.totalRows();
1021
+ // 缩小:contentRow > 新 bottom → 钳到新 bottom
1022
+ if (contentRow > g.contentBottom)
1023
+ contentRow = g.contentBottom;
1024
+ // 放大:contentRow < 新 bottom 且回尾(offset=0)→ 推进到 min(total, bottom)
1025
+ if (scrollOffset === 0 && contentRow < g.contentBottom) {
1026
+ contentRow = Math.min(total, g.contentBottom);
1027
+ }
1028
+ if (frameRow && frameRow > g.contentBottom)
1029
+ frameRow = g.contentBottom;
1030
+ const maxOff = Math.max(0, total - g.contentBottom);
1031
+ if (scrollOffset > maxOff)
1032
+ scrollOffset = maxOff;
1033
+ setRegion(footerH); // 用新 rows 立即重设 DECSTBM 区域(contentBottom 变)
1034
+ // 重画防抖(仅 repaint,避免快速拖动闪烁);行号/区域已立即更新
944
1035
  if (resizeTimer)
945
1036
  clearTimeout(resizeTimer);
946
1037
  resizeTimer = setTimeout(() => {
947
1038
  resizeTimer = null;
948
1039
  if (!active)
949
1040
  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
1041
  repaintViewport(); // 内容区按新高度 + 当前 offset 重画(物理行不 reflow)
959
1042
  repaint(); // 底栏重画
960
1043
  }, 100);
@@ -972,7 +1055,6 @@ export function exitAltScreen() {
972
1055
  scrollLockUntil = 0; // 清轮首滚动锁(防状态泄漏到下次进 alt 屏)
973
1056
  frameRow = 0; // 清 spinner 帧位置(防状态泄漏到下次进 alt 屏)
974
1057
  frameCol = 0;
975
- mouse.resetMouse(); // 清鼠标重组残留(防退出后状态泄漏到下次进 alt 屏)
976
1058
  // raw 还原独立 try:非 TTY / 不支持时 setRawMode 抛错,不应阻断 stdout 恢复(alt 退屏必须执行)。
977
1059
  try {
978
1060
  stdin.setRawMode(false); // 还原 raw(RUNNING 态常驻 raw,退出时必须还原,否则终端残留 raw 模式)
@@ -983,7 +1065,7 @@ export function exitAltScreen() {
983
1065
  try {
984
1066
  stdout.write('\x1B[r'); // 复位 DECSTBM margins
985
1067
  stdout.write(esc.cursorShow);
986
- stdout.write(esc.mouseOff); // 关 SGR 鼠标追踪(反序:先 1006l 再 1000l)
1068
+ stdout.write(esc.altScrollOff); // 关 alt 屏滚轮转发
987
1069
  stdout.write(esc.altOff); // 退 alt(恢复主屏 + 光标)
988
1070
  }
989
1071
  catch {
package/dist/ui/prompt.js CHANGED
@@ -3,7 +3,6 @@ import { stdin, stdout } from 'node:process';
3
3
  import { ui } from './theme.js';
4
4
  import { displayWidth, padEndDisplay, truncateDisplay } from './render.js';
5
5
  import * as layout from './layout.js';
6
- import * as mouse from './mouse.js';
7
6
  // ── 粘贴检测(块级 + 时间窗)──
8
7
  // 块级:多字节大块(len>8)或含 CR/LF 的小块 = 粘贴(键盘单键 1 字节,Enter=\r 单字节)。
9
8
  // 时间窗:每块重置 50ms 计时器,静默 50ms 即"粘贴结束"→ onPasteEnd。跨多块的大粘贴(块间 <50ms)累积进
@@ -19,10 +18,7 @@ function ensurePasteDetector() {
19
18
  return;
20
19
  pasteDetectorInstalled = true;
21
20
  stdin.on('data', (chunk) => {
22
- const raw = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
23
- // 剔除 SGR 鼠标报告(\x1B[<…M/m,连半截也匹配):防快速滚轮/触控板动量在一个 chunk 里拼 ≥2 条
24
- // 报表(22 字符 > 16 阈值)误判粘贴 50ms、把后续真按键泄进 pasteParts。
25
- const text = raw.replace(/\x1b\[<[0-9;]*[Mm]/g, '');
21
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
26
22
  const hasNL = text.indexOf('\r') >= 0 || text.indexOf('\n') >= 0;
27
23
  // 按字符数(码点)判粘贴,非字节:CJK 汉字占 3 UTF-8 字节,旧阈值(len>8 字节)会把 IME 提交的
28
24
  // 3-10 个汉字误判为粘贴 → 进 50ms 缓冲 → finalizePaste→insertText 落字(且旧 insertText 把光标
@@ -265,14 +261,6 @@ export async function promptWithSlashMenu(opts) {
265
261
  function onKey(_str, key) {
266
262
  if (resolved || !key)
267
263
  return;
268
- // SGR 鼠标滚轮:readline 把 \x1B[<btn;col;rowM 拆成多 fragment,经 mouse.consumeMouse 重组;
269
- // 滚轮 → scrollBy(±5)(只重画内容区,底栏不动)。置于 pasting 之前——鼠标 fragment 永不进 pasteParts。
270
- const _m = mouse.consumeMouse(key.sequence ?? '');
271
- if (_m.suppress) {
272
- if (_m.wheel)
273
- layout.scrollBy(_m.wheel * 5);
274
- return;
275
- }
276
264
  // Shift+Tab:循环切换 agent 模式(auto ↔ plan)。不插字符、不提交、不影响输入文本;
277
265
  // 回调由 repl 注入(翻 agentMode + 重写 history[0] + 设状态行 modeTag),再 redraw() 经
278
266
  // paintInput 重画底栏(状态行 chip 即时刷新 + 光标留输入框)。置于 case 'tab' 之前,故不触发菜单补全。
@@ -306,7 +294,7 @@ export async function promptWithSlashMenu(opts) {
306
294
  }
307
295
  // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑↓ 与 plain ↑/↓ 每次 5 行。
308
296
  // plain ↑/↓ 仅在单行输入且菜单关闭时作滚动(多行编辑留给光标移动,菜单打开留给选项);
309
- // 鼠标滚轮已由 onKey 顶部 SGR 重组守卫处理(不经此分支);此分支仅键盘 plain ↑/↓,放大到 5 行/格。
297
+ // 兼鼠标滚轮——alt 屏内(经 \x1B[?1007h)滚轮转发 ↑/↓,1 行/格太慢故放大到 5。
310
298
  const plainArrowScroll = (key.name === 'up' || key.name === 'down') &&
311
299
  !key.ctrl &&
312
300
  !key.meta &&