mocode-ai 0.1.9 → 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.
package/README.md CHANGED
@@ -62,9 +62,9 @@ mocode config
62
62
 
63
63
  也可手写配置文件。mocode 按以下优先级加载(后者覆盖前者,仅回填未设置的环境变量;shell 里 `export` 的永远最优先):
64
64
 
65
- 1. `~/.mocode/config` — 全局(`mocode config` 写此文件)
66
- 2. `<cwd>/.mocode/config` — 项目级覆盖
67
- 3. `<cwd>/.env` — 旧用法兼容(源码仓库内有 `.env.example` 可参考)
65
+ 1. `<cwd>/.env` — 旧用法兼容,优先级最低(源码仓库内有 `.env.example` 可参考)
66
+ 2. `~/.mocode/config` — 全局(`/model` 与 `mocode config` 写此文件)
67
+ 3. `<cwd>/.mocode/config` — 项目级覆盖,优先级最高
68
68
 
69
69
  必填三项:
70
70
 
@@ -76,30 +76,35 @@ LLM_MODEL=glm-4.6 # 换成你的模型名
76
76
 
77
77
  常见后端 `base_url`:
78
78
 
79
- | 后端 | base_url |
80
- |------|----------|
81
- | GLM(智谱) | `https://open.bigmodel.cn/api/v3` |
82
- | DeepSeek | `https://api.deepseek.com` |
83
- | Qwen(阿里) | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
84
- | 本地 Ollama | `http://localhost:11434/v1` |
85
- | 本地 vLLM | `http://localhost:8000/v1` |
79
+ | 后端 | base\_url |
80
+ | --------- | --------------------------------------------------- |
81
+ | GLM(智谱) | `https://open.bigmodel.cn/api/v3` |
82
+ | DeepSeek | `https://api.deepseek.com` |
83
+ | Qwen(阿里) | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
84
+ | 本地 Ollama | `http://localhost:11434/v1` |
85
+ | 本地 vLLM | `http://localhost:8000/v1` |
86
86
 
87
87
  > 模型必须支持 OpenAI 风格的 function calling,否则工具不会触发。
88
88
 
89
89
  ### 可选配置
90
90
 
91
- | 环境变量 | 说明 | 默认值 |
92
- |----------|------|--------|
93
- | `MAX_TOKENS` | 单次回复最大 token | 不限 |
94
- | `CONTEXT_WINDOW_TOKENS` | 模型上下文窗口,须对齐真实模型 | `128000` |
95
- | `COMPACT_THRESHOLD` | 自动压缩触发阈值(占窗口比例) | `0.85` |
96
- | `LLM_STREAM_USAGE` | 流式请求带 `stream_options.include_usage` 拿真实用量 | `true` |
97
- | `AUTO_COMPACT` | 自动压缩总开关 | `true` |
98
- | `AUTO_REFLECT` | 后台反思 pass 总开关(定期从会话挖掘记忆) | `true` |
99
- | `REFLECT_EVERY_N` | 每 N 轮触发一次后台反思(与 agent 并发,不阻塞) | `5` |
100
- | `ANYSEARCH_API_KEY` | 联网搜索 API key(不配走匿名免费额度) | 无 |
101
- | `ANYSEARCH_BASE_URL` | 搜索 API 端点 | `https://api.anysearch.com` |
102
- | `SKILLS_DIRS` | 覆盖默认 skill 扫描目录(平台分隔符) | 三目录自动扫描 |
91
+ | 环境变量 | 说明 | 默认值 |
92
+ | ----------------------- | ------------------------------------------ | --------------------------- |
93
+ | `MAX_TOKENS` | 单次回复最大 token | 不限 |
94
+ | `CONTEXT_WINDOW_TOKENS` | 模型上下文窗口,须对齐真实模型 | `128000` |
95
+ | `COMPACT_THRESHOLD` | 自动压缩触发阈值(占窗口比例) | `0.85` |
96
+ | `LLM_STREAM_USAGE` | 流式请求带 `stream_options.include_usage` 拿真实用量 | `true` |
97
+ | `AUTO_COMPACT` | 自动压缩总开关 | `true` |
98
+ | `AUTO_REFLECT` | 后台反思 pass 总开关(定期从会话挖掘记忆) | `true` |
99
+ | `REFLECT_EVERY_N` | 每 N 轮触发一次后台反思(与 agent 并发,不阻塞) | `5` |
100
+ | `ANYSEARCH_API_KEY` | 联网搜索 API key(不配走匿名免费额度) | 无 |
101
+ | `ANYSEARCH_BASE_URL` | 搜索 API 端点 | `https://api.anysearch.com` |
102
+ | `SKILLS_DIRS` | 覆盖默认 skill 扫描目录(平台分隔符) | 三目录自动扫描 |
103
+ | `MOCODE_CONTEXT_OPTIMIZE` | 工具结果进 LLM 前的类型化编码(树/搜索/日志…),关掉则原样进(仅长度裁剪) | `true` |
104
+ | `MAX_STEPS` | 每轮 agent 循环最大步数(防无限循环) | `200` |
105
+ | `SUB_AGENT_MAX_STEPS` | 子 agent(task 工具派生)默认步数上限 | `50` |
106
+ | `SANDBOX_ROOT` | 沙箱根目录(文件操作边界;未配则用 cwd 兜底) | 无 |
107
+ | `MOCODE_THEME` | 颜色主题(default/dark/light…;shell 设置优先于文件) | `default` |
103
108
 
104
109
  ## 运行
105
110
 
@@ -118,40 +123,45 @@ agent 工作在**启动时所在的工作目录**——想让它操作某个项
118
123
 
119
124
  ## 工具
120
125
 
121
- | 工具 | 作用 |
122
- |------|------|
123
- | `read_file` | 读文件,带行号,支持 `offset` / `limit` |
124
- | `write_file` | 创建/覆盖文件,自动建父目录 |
125
- | `edit_file` | 精确字符串替换(`old_string` 须唯一匹配) |
126
- | `run_command` | 执行 shell 命令,合并 stdout+stderr,默认 120s 超时 |
127
- | `glob` | 按 glob 模式找文件(排除 node_modules/.git) |
128
- | `grep` | 内容正则搜索,纯 JS 实现,不依赖 `rg` |
129
- | `codegraph` | 已建 `.codegraph/` 索引时,查代码符号源码与调用链(比 read_file/grep 更准更省) |
130
- | `web_search` | 联网搜索(AnySearch),返回标题/URL/摘要/正文 |
131
- | `web_fetch` | 抓取指定 URL,HTML 清洗成纯文本 |
132
- | `use_skill` | 加载某 skill 的完整 SKILL.md 指令 |
133
- | `ask_human` | 决策点弹终端问答面板,用户选预设项或自由输入(阻塞至回应) |
134
- | `switch_mode` | 在 `plan`(只读规划)与 `auto`(全量执行)间切换;agent 可自行调用,先探查再动手 |
135
- | `task` | 派生子 agent 执行独立子任务(独立历史、可受限工具集、可设步数上限);连续多个自动并行,只回摘要 |
136
- | `memory_save` | 存一条跨会话长期记忆(标题进索引,正文按需取) |
137
- | `memory_search` | 按关键词搜记忆正文,命中即提升召回计数(影响遗忘衰减) |
138
- | `memory_list` | 列记忆索引(id/标题/摘要,无正文) |
139
- | `memory_update` | 原地改一条记忆(id 不变;纠正过时事实 / 改摘要 / 改 pin) |
140
- | `memory_forget` | 遗忘记忆:默认归档(可复活),`mode=delete` 硬删(pinned 拒删) |
126
+ | 工具 | 作用 |
127
+ | --------------- | -------------------------------------------------------- |
128
+ | `read_file` | 读文件,带行号,支持 `offset` / `limit` |
129
+ | `write_file` | 创建/覆盖文件,自动建父目录 |
130
+ | `edit_file` | 精确字符串替换(`old_string` 须唯一匹配) |
131
+ | `run_command` | 执行 shell 命令,合并 stdout+stderr,默认 120s 超时 |
132
+ | `glob` | 按 glob 模式找文件(排除 node\_modules/.git) |
133
+ | `grep` | 内容正则搜索,纯 JS 实现,不依赖 `rg` |
134
+ | `codegraph` | 已建 `.codegraph/` 索引时,查代码符号源码与调用链(比 read\_file/grep 更准更省) |
135
+ | `web_search` | 联网搜索(AnySearch),返回标题/URL/摘要/正文 |
136
+ | `web_fetch` | 抓取指定 URL,HTML 清洗成纯文本 |
137
+ | `use_skill` | 加载某 skill 的完整 SKILL.md 指令 |
138
+ | `ask_human` | 决策点弹终端问答面板,用户选预设项或自由输入(阻塞至回应) |
139
+ | `switch_mode` | 在 `plan`(只读规划)与 `auto`(全量执行)间切换;agent 可自行调用,先探查再动手 |
140
+ | `drop_context` | 把历史里无关的旧工具结果替换为存根释放上下文(保 tool_call_id 配对,不动 system 与当前轮;幂等) |
141
+ | `task` | 派生子 agent 执行独立子任务(独立历史、可受限工具集、可设步数上限);连续多个自动并行,只回摘要 |
142
+ | `memory_save` | 存一条跨会话长期记忆(标题进索引,正文按需取) |
143
+ | `memory_search` | 按关键词搜记忆正文,命中即提升召回计数(影响遗忘衰减) |
144
+ | `memory_list` | 列记忆索引(id/标题/摘要,无正文) |
145
+ | `memory_update` | 原地改一条记忆(id 不变;纠正过时事实 / 改摘要 / 改 pin) |
146
+ | `memory_forget` | 遗忘记忆:默认归档(可复活),`mode=delete` 硬删(pinned 拒删) |
141
147
 
142
148
  ## 斜杠命令
143
149
 
144
- | 命令 | 作用 |
145
- |------|------|
146
- | `/exit` `/quit` | 退出 mocode |
147
- | `/clear` | 清空历史(保留系统提示)+ 清屏 |
148
- | `/context` | 显示上下文用量条(token / 消息数 / 估算或实测) |
149
- | `/skills` | 列出已发现的 skill |
150
- | `/compact` | 压缩历史(可带焦点 `/compact …`) |
151
- | `/resume` | 续接已保存的会话 |
152
- | `/think` | 展开折叠思考段(`/think N`) |
153
- | `/rollback` | 菜单选轮次回滚(↑↓ · Enter) |
154
- | `/model` | 配置大模型(baseURL / apiKey / model / 上下文窗口),即时生效 + 持久化 |
150
+ | 命令 | 作用 |
151
+ | --------------- | -------------------------------------------------- |
152
+ | `/exit` `/quit` | 退出 mocode |
153
+ | `/clear` | 清空历史(保留系统提示)+ 清屏 |
154
+ | `/context` | 显示上下文用量条(token / 消息数 / 估算或实测) |
155
+ | `/skills` | 列出已发现的 skill |
156
+ | `/compact` | 压缩历史(可带焦点 `/compact …`) |
157
+ | `/resume` | 续接已保存的会话 |
158
+ | `/think` | 展开折叠思考段(`/think N`) |
159
+ | `/rollback` | 菜单选轮次回滚(↑↓ · Enter) |
160
+ | `/model` | 配置大模型(baseURL / apiKey / model / 上下文窗口),即时生效 + 持久化 |
161
+ | `/init` | 扫描项目生成 `MOCODE.md` 项目记忆(发给 agent 执行) |
162
+ | `/theme` | 切换颜色主题(↑↓ · Enter,或 `/theme <name>` 直切) |
163
+ | `/plan` | 切到 plan 模式(只读探查 + 产出计划,审批后切 auto 执行) |
164
+ | `/auto` | 切回 auto 模式(全量工具执行) |
155
165
 
156
166
  输入 `/` 触发下拉菜单,继续打字过滤;Esc 取消。
157
167
 
@@ -55,16 +55,11 @@ onContextUpdate) {
55
55
  // 开新轮次(回滚用):首行截断 40,供 /rollback 轮次菜单展示。
56
56
  beginTurn(truncateDisplay(userInput.split('\n')[0] ?? '', 40));
57
57
  layout.contentMode(); // 防御性:运行态光标归输入框光标位供 IME 锚定(enterRunningMode 已置,这里兜底)
58
- // spinner:续写位内联转圈(思考中 / 执行 工具时,内容区不再「干等」)。
59
- // 只走内联 paintLiveAtCursor——不调 setStatus,状态行不重复 spinner 文字(状态行只显走时,
60
- // turnTimer 200ms 续刷);内联帧不进缓冲、停时清掉,随后结果即写在该行——故 spinner 不入历史、PgUp 看不到。
58
+ // spinner:状态行最前面转圈(思考中 / 生成 / 执行 工具时,状态栏 lead 位显帧 + 文字)。
59
+ // setStatus 注入状态行(spinnerFrame + statusText),composeStatus 把帧 + 文字放 lead 位;
60
+ // 不画内容区续写位——内容区在等待期间保持干净,首 token 到达即从续写位开始写正文。
61
61
  const spinner = new Spinner((msg, frame) => {
62
- if (frame) {
63
- layout.paintLiveAtCursor(` ${ui.brightMagenta}${frame}${ui.reset} ${ui.dim}${msg}…${ui.reset}`);
64
- }
65
- else {
66
- layout.clearLiveAtCursor();
67
- }
62
+ layout.setStatus(frame ? `${msg}…` : '', frame ?? undefined);
68
63
  });
69
64
  // lastChar 镜像:core 跟踪流式末字符决定补换行,但 TUI hooks 需读它决定 layout.contentWrite('\n')。
70
65
  // core 的 onTextEnd hook 只在 lastChar !== '\n' 时才调,调后置 '\n';镜像与此同步。
@@ -84,7 +79,7 @@ onContextUpdate) {
84
79
  lastChar = '\n';
85
80
  }
86
81
  if (name)
87
- spinner.start(`生成 ${name}…`);
82
+ spinner.start(`生成 ${name}`);
88
83
  },
89
84
  onStepStart: () => spinner.start('思考中'),
90
85
  onChatDone: () => spinner.stop(),
@@ -4,15 +4,17 @@ import path from 'node:path';
4
4
  import dotenv from 'dotenv';
5
5
  /**
6
6
  * 按优先级加载配置文件并回填 process.env:
7
- * 候选(文件内升序、后者覆盖前者):~/.mocode/config(全局)→ <cwd>/.mocode/config → <cwd>/.env(兼容旧用法)。
7
+ * 候选(后者覆盖前者,优先级升序):<cwd>/.env(兼容旧用法,最低)→ ~/.mocode/config(全局)→ <cwd>/.mocode/config(项目级覆盖,最高)。
8
8
  * 合并后只回填 process.env 里**尚未设置**的键——shell 里 export 的环境变量永远优先。
9
- * 故 `mocode` 可在任意目录 / 任意终端启动:全局配置(~/.mocode/config)兜底,项目级文件按需覆盖。
9
+ * 故 `mocode` 可在任意目录 / 任意终端启动:全局配置(~/.mocode/config)兜底(/model 与 mocode config 写此),
10
+ * 项目级 .mocode/config 按需覆盖全局;旧用法 .env 优先级最低,不再盖过全局 config——
11
+ * 否则 /model 写入 ~/.mocode/config 的 LLM 键会被项目 .env 里的同名旧值盖回。
10
12
  */
11
13
  function loadEnvFiles() {
12
14
  const candidates = [
13
- path.join(os.homedir(), '.mocode', 'config'),
14
- path.join(process.cwd(), '.mocode', 'config'),
15
- path.join(process.cwd(), '.env'),
15
+ path.join(process.cwd(), '.env'), // 兼容旧用法,优先级最低
16
+ path.join(os.homedir(), '.mocode', 'config'), // 全局(/model 与 mocode config 写此)
17
+ path.join(process.cwd(), '.mocode', 'config'), // 项目级覆盖,优先级最高
16
18
  ];
17
19
  const fromFiles = {};
18
20
  for (const p of candidates) {
@@ -75,6 +77,7 @@ ${PLATFORM_NOTE}
75
77
 
76
78
  ## Workflow
77
79
  - Understand before acting: when unsure about requirements or code state, explore first; don't assume.
80
+ - **Code exploration first action**: before reading files with read_file or searching with grep, check if a .codegraph/ index exists. If it does, use the codegraph tool (explore for questions/features, node for a specific symbol) as your FIRST step — it returns source + call paths in one shot. Only fall back to read_file/grep when codegraph misses, you need just-changed content, or you're editing a known small file. Build the index with \`codegraph init\` if none exists.
78
81
  - Small steps: break tasks into verifiable sub-steps. Before each step, think clearly about what to change and why.
79
82
  - Verify after change: run typecheck / tests / build via run_command to confirm it works. Never claim done without verification.
80
83
 
@@ -121,7 +124,7 @@ export const PLAN_MODE_SUFFIX = `
121
124
  ## ⛯ PLAN MODE (active now)
122
125
  You are in PLAN mode: investigate and design only — do NOT execute or change anything.
123
126
  - Your editing / command / memory-write tools (write_file, edit_file, run_command, memory_save, memory_update, memory_forget) have been REMOVED from your tool list. Use only the read-only tools available to you (read_file, glob, grep, codegraph, web_search, web_fetch, use_skill, ask_human, memory_search, memory_list) to investigate.
124
- - Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing. Prefer codegraph when a .codegraph/ index exists.
127
+ - Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing. Your FIRST action for code exploration should be the codegraph tool (explore/node) when a .codegraph/ index exists — not read_file/grep. Use read_file/grep only to fill gaps codegraph leaves.
125
128
  - Then produce a clear, actionable implementation plan: files to change (with paths), what to change in each and why, the ordered steps, edge cases to handle, and how to verify (typecheck / tests / build). Be specific enough to execute against.
126
129
  - Present the plan as your final reply and STOP, unless the user explicitly asked you to "plan first then execute" / "先 plan 再 auto" / autonomous execution: in that case, after presenting the plan, call the switch_mode tool with mode="auto" to switch back to auto mode WITHIN THE SAME TURN and continue implementing the plan yourself (your write/edit/command/memory-write tools become available again immediately). The user will see no approval prompt because you self-switched.
127
130
  - If the user entered plan mode manually (via /plan or Shift+Tab) for a safety review and did NOT ask for autonomous execution, do NOT call switch_mode — present the plan and STOP. Do NOT ask the user for confirmation or approval in your text reply (e.g. "is this plan OK?", "shall I proceed?", "需要你确认") — the REPL automatically shows an approval prompt after you STOP, so asking in text is redundant and forces the user to answer twice. Just present the plan and end your reply.`;
@@ -9,7 +9,6 @@ import { setSandboxRoot } from '../sandbox/root.js';
9
9
  import { ui, setTheme, getTheme, listThemes, themeExists } from '../ui/theme.js';
10
10
  import { bannerString, displayWidth, padEndDisplay, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
11
11
  import * as layout from '../ui/layout.js';
12
- import * as mouse from '../ui/mouse.js';
13
12
  import { promptWithSlashMenu, promptTurnPicker, promptSessionPicker, promptThemePicker, promptRevertChoice, } from '../ui/prompt.js';
14
13
  import { promptIntervention } from '../ui/intervention.js';
15
14
  import { tools } from '../tools/registry.js';
@@ -113,7 +112,7 @@ function renderContextBar(history) {
113
112
  const pct = Math.min(1, est / win);
114
113
  const W = 10;
115
114
  const filled = Math.round(pct * W);
116
- const bar = filled === 0 ? ' '.repeat(W) : '█'.repeat(filled) + '░'.repeat(W - filled);
115
+ const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
117
116
  const src = contextState.lastUsage ? '实测' : '估算';
118
117
  const k = (n) => `${Math.round(n / 1000)}k`;
119
118
  const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.cyan;
@@ -129,7 +128,7 @@ function renderContextBarInline(history) {
129
128
  const pct = Math.min(1, est / win);
130
129
  const W = 10;
131
130
  const filled = Math.round(pct * W);
132
- const bar = filled === 0 ? ' '.repeat(W) : '█'.repeat(filled) + '░'.repeat(W - filled);
131
+ const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
133
132
  const k = (n) => `${Math.round(n / 1000)}k`;
134
133
  const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.cyan;
135
134
  return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${pctCol}${Math.round(pct * 100)}%${ui.reset} ${ui.dim}${k(est)}/${k(win)}${ui.reset}`;
@@ -183,15 +182,8 @@ let pendingPrefill = null; // /rollback 选中后预填的 user 输入(下轮 IN
183
182
  function onRunningKey(_str, key) {
184
183
  if (!key)
185
184
  return;
186
- // SGR 鼠标滚轮:readline \x1B[<btn;col;rowM 拆成多 fragment,经 mouse.consumeMouse 重组;
187
- // 滚轮 scrollBy(±5)(不调 setUserActive——与键盘滚动一致;流式 flushTimer 在 scrollOffset>0 时不重画,安全)。
188
- const _m = mouse.consumeMouse(key.sequence ?? '');
189
- if (_m.suppress) {
190
- if (_m.wheel)
191
- layout.scrollBy(_m.wheel * 5);
192
- return;
193
- }
194
- // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,↑/↓ 每次 5 行(键盘;鼠标滚轮已由上方守卫处理)。
185
+ // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,↑/↓ 每次 5 行(键盘 + alt 屏内鼠标滚轮转发的
186
+ // \x1B[A/\x1B[B,经 DECSET 1007 转发,与键盘 ↑/↓ 同序列,天然共用此分支)。
195
187
  // 运行态无输入光标,↑/↓ 无其他用途,直接作滚动。
196
188
  if (key.name === 'pageup' ||
197
189
  key.name === 'pagedown' ||
@@ -50,9 +50,11 @@ function runCodegraph(args) {
50
50
  }
51
51
  export const codegraphTool = {
52
52
  name: 'codegraph',
53
- description: 'Preferred for understanding/locating code, tracing call chains, assessing impact of changes in repos with a code index (.codegraph/).' +
54
- ' Returns symbol source + call paths in one shot — more accurate/economical than read_file/grep.' +
55
- ' Hints to run `codegraph init` when .codegraph/ is absent.',
53
+ description: 'FIRST CHOICE for understanding/locating code, tracing call chains, or assessing the impact of changes use this before read_file/grep when a .codegraph/ index exists.' +
54
+ ' Returns symbol source + call paths in one shot — more accurate and economical than piecing together read_file/grep.' +
55
+ ' When to use: starting any code exploration; locating a symbol; understanding how a feature works; seeing what calls a function or what a change affects.' +
56
+ ' When NOT to use: no .codegraph/ index (build it first with `codegraph init`); reading a file you just edited; editing a single known small file — in those cases go straight to read_file/edit_file.' +
57
+ ' Fallback: if codegraph misses or the result is incomplete, then use read_file/grep/glob to fill the gaps.',
56
58
  parameters: {
57
59
  type: 'object',
58
60
  properties: {
@@ -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 &&
package/package.json CHANGED
@@ -1,40 +1,40 @@
1
- {
2
- "name": "mocode-ai",
3
- "version": "0.1.9",
4
- "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
- "type": "module",
6
- "bin": {
7
- "mocode": "bin/mocode.js"
8
- },
9
- "files": [
10
- "dist",
11
- "bin",
12
- "README.md"
13
- ],
14
- "engines": {
15
- "node": ">=18"
16
- },
17
- "scripts": {
18
- "start": "tsx src/index.ts",
19
- "build": "tsc -p tsconfig.build.json",
20
- "typecheck": "tsc --noEmit",
21
- "prepare": "npm run build"
22
- },
23
- "dependencies": {
24
- "cli-highlight": "^2.1.11",
25
- "dotenv": "^16.0.0",
26
- "fast-glob": "^3.0.0",
27
- "openai": "^4.0.0"
28
- },
29
- "devDependencies": {
30
- "@types/node": "^22.0.0",
31
- "tsx": "^4.0.0",
32
- "typescript": "^5.0.0"
33
- },
34
- "repository": {
35
- "type": "git",
36
- "url": "git+https://github.com/wanxunyang/mocode.git"
37
- },
38
- "author": "wxy",
39
- "license": "MIT"
40
- }
1
+ {
2
+ "name": "mocode-ai",
3
+ "version": "0.1.10",
4
+ "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
+ "type": "module",
6
+ "bin": {
7
+ "mocode": "bin/mocode.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "bin",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "scripts": {
18
+ "start": "tsx src/index.ts",
19
+ "build": "tsc -p tsconfig.build.json",
20
+ "typecheck": "tsc --noEmit",
21
+ "prepare": "npm run build"
22
+ },
23
+ "dependencies": {
24
+ "cli-highlight": "^2.1.11",
25
+ "dotenv": "^16.0.0",
26
+ "fast-glob": "^3.0.0",
27
+ "openai": "^4.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.0.0",
31
+ "tsx": "^4.0.0",
32
+ "typescript": "^5.0.0"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/wanxunyang/mocode.git"
37
+ },
38
+ "author": "wxy",
39
+ "license": "MIT"
40
+ }