mocode-ai 1.7.2 → 1.7.5

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 (45) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/dist/config/index.js +1 -1
  4. package/dist/config/profiles.js +2 -2
  5. package/dist/flows/cli.js +168 -0
  6. package/dist/flows/flow.js +483 -0
  7. package/dist/flows/runner.js +259 -0
  8. package/dist/flows/trace.js +242 -0
  9. package/dist/i18n/index.js +28 -0
  10. package/dist/index.js +7 -0
  11. package/dist/llm/index.js +1 -1
  12. package/dist/permissions/computer-review.js +40 -0
  13. package/dist/permissions/flow-review.js +41 -0
  14. package/dist/permissions/index.js +47 -28
  15. package/dist/repl/commands/flow.js +210 -0
  16. package/dist/repl/commands/registry.js +2 -0
  17. package/dist/repl/commands/tool-group.js +9 -1
  18. package/dist/repl/commands.js +10 -0
  19. package/dist/repl/running-input.js +75 -14
  20. package/dist/repl/runtime.js +4 -1
  21. package/dist/runtime/input-injector.js +39 -26
  22. package/dist/runtime/shutdown.js +3 -1
  23. package/dist/runtime/uia-selector.js +324 -0
  24. package/dist/runtime/uia-service.js +510 -0
  25. package/dist/runtime/wait-until.js +199 -0
  26. package/dist/runtime/window-list.js +164 -0
  27. package/dist/session/compact.js +1 -1
  28. package/dist/session/gui-actions.js +47 -0
  29. package/dist/tools/builtins/computer.js +647 -10
  30. package/dist/tools/builtins/index.js +4 -0
  31. package/dist/tools/builtins/read-file.js +54 -3
  32. package/dist/tools/builtins/run-flow.js +123 -0
  33. package/dist/tools/constants.js +8 -2
  34. package/dist/tools/router.js +1 -1
  35. package/dist/tools/validation.js +57 -0
  36. package/dist/ui/composer.js +6 -2
  37. package/dist/ui/layout-internal/content-write.js +74 -6
  38. package/dist/ui/layout-internal/core.js +32 -8
  39. package/dist/ui/layout-internal/screen.js +4 -0
  40. package/dist/ui/layout-internal/scroll.js +5 -3
  41. package/dist/ui/layout-internal/state.js +6 -0
  42. package/dist/ui/layout-internal/statusbar.js +16 -1
  43. package/dist/ui/render.js +19 -11
  44. package/docs/usage.md +344 -344
  45. package/package.json +92 -92
@@ -7,6 +7,7 @@ import { config } from '../config/index.js';
7
7
  import { readConfigFile, updateConfigKey } from '../config/file.js';
8
8
  import { getSandboxRoot } from '../sandbox/index.js';
9
9
  import { t } from '../i18n/index.js';
10
+ import { lookupElementRefName } from '../runtime/uia-selector.js';
10
11
  const PERMISSIONS_PATH = path.join(os.homedir(), '.mocode', 'permissions.json');
11
12
  const PERMISSIONS_VERSION = 3;
12
13
  let permanentGrants = [];
@@ -27,23 +28,24 @@ function canonicalProjectRoot(root) {
27
28
  const resolved = path.resolve(root);
28
29
  return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
29
30
  }
30
- /**
31
- * computer 工具的 type/key 文本内容审查:
32
- * 命中 URL / 密码形态 / 支付关键词时,无论是否已授权都强制 once 级确认——
33
- * 这类文本是「把内容敲进任意应用」的载体,不能因 session/项目授权而放行后续所有输入。
34
- * 纯函数,独立可单测。
35
- */
36
- export function computerTextNeedsReview(text) {
37
- const s = text.toLowerCase();
38
- if (/https?:\/\/|www\.[a-z0-9-]+\.[a-z]{2,}/i.test(s))
39
- return true;
40
- if (/\b(pass(word)?|passwd|pwd|secret|token|api[_-]?key|credential)\b\s*[:=]/i.test(s))
41
- return true;
42
- if (/\b(card\s*number|cvv|cvc|expiry)\b/i.test(s))
43
- return true;
44
- // CJK 关键词没有 \b 词边界概念(\b 只在 \w 与非 \w 之间成立,中文不是 \w),用普通子串匹配。
45
- if (/信用卡|卡号|密码|支付|付款|验证码/.test(s))
46
- return true;
31
+ // 敏感内容谓词住在叶子模块 computer-review.ts(flows/ 要复用,而本文件将反过来读 flow 文件,
32
+ // 放叶子里避免 import 环);这里 re-export 保持既有 API 与测试不变。
33
+ import { computerTargetNeedsReview, computerTextNeedsReview } from './computer-review.js';
34
+ export { computerTargetNeedsReview, computerTextNeedsReview };
35
+ import { reviewFlowRun } from './flow-review.js';
36
+ /** computer 调用是否命中「强制 once」审查:敏感文本(type/key/set_value)或敏感按钮(click_element)。 */
37
+ export function computerArgsNeedReview(args) {
38
+ if ((args.action === 'type' || args.action === 'key' || args.action === 'set_value') &&
39
+ typeof args.text === 'string') {
40
+ if (computerTextNeedsReview(args.text))
41
+ return true;
42
+ }
43
+ if (args.action === 'click_element' && typeof args.ref === 'string') {
44
+ const el = lookupElementRefName(args.ref);
45
+ // ref 无登记(未 inspect / 已过期):工具会以 ELEMENT_NOT_FOUND 拒绝执行,这里不额外弹窗。
46
+ if (el && computerTargetNeedsReview(el.name))
47
+ return true;
48
+ }
47
49
  return false;
48
50
  }
49
51
  /** A grant is bound to the actual command or logical resources, never merely a tool name. */
@@ -57,6 +59,11 @@ export function permissionFingerprint(tool, args) {
57
59
  // 按动作粒度授权:允许一次 left_click = 本会话允许任意坐标的左键。
58
60
  subject = { computer: typeof args.action === 'string' ? args.action : 'unknown' };
59
61
  }
62
+ else if (tool.name === 'run_flow') {
63
+ // run_flow:授权绑定 flow 名 + 文件 hash(flow 被改过,旧授权立即失效);参数值不进指纹。
64
+ const review = reviewFlowRun(args);
65
+ subject = { run_flow: review.name, hash: review.hash ?? 'unloadable' };
66
+ }
60
67
  else {
61
68
  let resources;
62
69
  try {
@@ -119,7 +126,10 @@ function savePermanent() {
119
126
  export function getToolRisk(tool) {
120
127
  return tool.risk ?? 'safe';
121
128
  }
122
- function summarizeArgs(args) {
129
+ function summarizeArgs(args, toolName) {
130
+ // run_flow:弹窗展示整条 flow 的步骤摘要与参数(secret 以 *** 显示),而不是只有一个 flow 名。
131
+ if (toolName === 'run_flow')
132
+ return reviewFlowRun(args).summary;
123
133
  const lines = [];
124
134
  if (typeof args.path === 'string')
125
135
  lines.push(t('permission.path', { value: args.path }));
@@ -130,6 +140,11 @@ function summarizeArgs(args) {
130
140
  const parts = [args.action];
131
141
  if (Array.isArray(args.coordinate))
132
142
  parts.push(`@(${args.coordinate.join(', ')})`);
143
+ if (typeof args.ref === 'string') {
144
+ // click_element/set_value:弹窗显示 ref 对应的元素名,而不是只给一个无意义的 e9。
145
+ const el = lookupElementRefName(args.ref);
146
+ parts.push(el ? `${args.ref} ${el.role} ${JSON.stringify(el.name.slice(0, 60))}` : args.ref);
147
+ }
133
148
  if (typeof args.text === 'string' && args.text) {
134
149
  const preview = args.text.slice(0, 80);
135
150
  parts.push(`"${preview}${args.text.length > 80 ? '…' : ''}"`);
@@ -155,13 +170,13 @@ async function checkPermissionWithSessionGrants(runtimeConfig, runtimeSessionGra
155
170
  loadPermanent();
156
171
  if (permanentToolAllows.has(tool.name))
157
172
  return 'allow';
158
- // computer 的 type/key 命中敏感内容(URL/密码/支付)时强制 once 级确认:
159
- // 不看任何授权缓存,也不提供 session/项目/永久授权选项——这类文本是「敲进任意应用」的载体,
160
- // 一旦放行不该沿用到下一次输入。
161
- const forceOnce = tool.name === 'computer' &&
162
- (args.action === 'type' || args.action === 'key') &&
163
- typeof args.text === 'string' &&
164
- computerTextNeedsReview(args.text);
173
+ // computer 命中敏感内容时强制 once 级确认:type/key/set_value 的文本(URL/密码/支付),
174
+ // 或 click_element 的目标按钮名(删除/发送/支付…)。不看任何授权缓存,也不提供
175
+ // session/项目/永久授权选项——这类动作一旦放行不该沿用到下一次。
176
+ // run_flow 回放内部的单步不再走本函数,所以整条 flow 的敏感判定在启动前一次做完(flow-review.ts)。
177
+ const flowReview = tool.name === 'run_flow' ? reviewFlowRun(args) : undefined;
178
+ const forceOnce = (tool.name === 'computer' && computerArgsNeedReview(args)) ||
179
+ (flowReview !== undefined && flowReview.reasons.length > 0);
165
180
  const fingerprint = permissionFingerprint(tool, args);
166
181
  const projectRoot = canonicalProjectRoot(options.projectRoot ?? getSandboxRoot() ?? process.cwd());
167
182
  if (!forceOnce) {
@@ -186,9 +201,13 @@ async function checkPermissionWithSessionGrants(runtimeConfig, runtimeSessionGra
186
201
  : [onceOption, sessionOption, projectOption, alwaysOption, denyOption];
187
202
  // 新手首次审批引导:第一次弹出确认面板时附一段说明(每个用户只出现一次,
188
203
  // ~/.mocode/config 的 onboarding_approval_hint 标记;读写失败静默,不阻断审批)。
189
- let detail = summarizeArgs(args) + (dangerous ? `\n\n${t('permission.dangerWarning')}` : '');
190
- if (forceOnce)
191
- detail += `\n\n${t('permission.computerSensitiveReview')}`;
204
+ // run_flow:弹窗展示整条 flow 的步骤摘要(不含 secret 取值)与触发强制确认的原因。
205
+ let detail = (flowReview ? flowReview.summary : summarizeArgs(args)) + (dangerous ? `\n\n${t('permission.dangerWarning')}` : '');
206
+ if (forceOnce) {
207
+ detail += flowReview?.reasons.length
208
+ ? `\n\nPer-run approval required:\n${flowReview.reasons.map((r) => ` - ${r}`).join('\n')}`
209
+ : `\n\n${t('permission.computerSensitiveReview')}`;
210
+ }
192
211
  const ONBOARDING_KEY = 'onboarding_approval_hint';
193
212
  try {
194
213
  if (!readConfigFile()[ONBOARDING_KEY]) {
@@ -0,0 +1,210 @@
1
+ /**
2
+ * 桌面流程命令组:/flow list · show <name> · save <name> [--from N] [--to M] [--force] [描述] · run <name> [参数=值 ...]
3
+ *
4
+ * 数据流(design-notes/computer-use-rpa.md §5):computer 动作被结构化录进会话的 gui-trace.jsonl,
5
+ * `/flow save` 把它导出成 `.mocode/flows/<name>.json`,`/flow run` 不经过模型直接回放。
6
+ * 回放受 Computer Use 总开关否决(/cu off);命中敏感内容(secret 参数、敏感文本、删除/发送/支付类目标)
7
+ * 的 flow 在开跑前强制逐次确认,与 run_flow 工具走同一套审查(permissions/flow-review.ts)。
8
+ *
9
+ * 重模块(computer.ts 及其 UIA/抓屏依赖)只在 run 时动态加载,不拖慢启动。
10
+ */
11
+ import * as layout from '../../ui/layout.js';
12
+ import { ui } from '../../ui/theme.js';
13
+ import { t } from '../../i18n/index.js';
14
+ import { isComputerUseRouteAllowed } from '../../config/index.js';
15
+ import { promptIntervention } from '../../ui/intervention.js';
16
+ import { startRunningListener, stopRunningListener } from '../running-input.js';
17
+ import { unhandled, next } from './types.js';
18
+ /** 按空白切词,支持 "双引号"(内部 \" 转义)与 '单引号';key="a b" 视为一个词。 */
19
+ export function tokenizeFlowArgs(s) {
20
+ const out = [];
21
+ let cur = '';
22
+ let quote = null;
23
+ let has = false;
24
+ for (let i = 0; i < s.length; i++) {
25
+ const c = s[i];
26
+ if (quote) {
27
+ if (c === quote)
28
+ quote = null;
29
+ else if (c === '\\' && quote === '"' && s[i + 1] === '"')
30
+ cur += s[++i];
31
+ else
32
+ cur += c;
33
+ }
34
+ else if (c === '"' || c === "'") {
35
+ quote = c;
36
+ has = true;
37
+ }
38
+ else if (/\s/.test(c)) {
39
+ if (has || cur) {
40
+ out.push(cur);
41
+ cur = '';
42
+ has = false;
43
+ }
44
+ }
45
+ else {
46
+ cur += c;
47
+ has = true;
48
+ }
49
+ }
50
+ if (has || cur)
51
+ out.push(cur);
52
+ return out;
53
+ }
54
+ const warn = (msg) => layout.contentWrite(`${ui.yellow}${msg}${ui.reset}\n`);
55
+ const dim = (msg) => layout.contentWrite(`${ui.dim}${msg}${ui.reset}\n`);
56
+ async function flowList() {
57
+ const { listFlows } = await import('../../flows/flow.js');
58
+ const items = listFlows();
59
+ if (items.length === 0) {
60
+ dim(t('flow.noFlows'));
61
+ return;
62
+ }
63
+ for (const it of items) {
64
+ layout.contentWrite(`${ui.accent}${it.name}${ui.reset} ${ui.dim}${it.steps} steps${it.description ? ` · ${it.description}` : ''}${ui.reset}\n`);
65
+ }
66
+ }
67
+ async function flowShow(name) {
68
+ if (!name)
69
+ return warn(t('flow.usage'));
70
+ const { loadFlow, summarizeFlow } = await import('../../flows/flow.js');
71
+ const loaded = loadFlow(name);
72
+ if (!loaded.ok)
73
+ return warn(loaded.error);
74
+ layout.contentWrite(`${summarizeFlow(loaded.flow)}\n${ui.dim}${loaded.path}${ui.reset}\n`);
75
+ }
76
+ async function flowSave(tokens) {
77
+ let from;
78
+ let to;
79
+ let force = false;
80
+ const positional = [];
81
+ for (let i = 0; i < tokens.length; i++) {
82
+ const tok = tokens[i];
83
+ if (tok === '--force')
84
+ force = true;
85
+ else if (tok === '--from' || tok === '--to') {
86
+ const n = Number(tokens[++i]);
87
+ if (!Number.isInteger(n) || n < 1)
88
+ return warn(t('flow.usage'));
89
+ if (tok === '--from')
90
+ from = n;
91
+ else
92
+ to = n;
93
+ }
94
+ else
95
+ positional.push(tok);
96
+ }
97
+ const name = positional.shift();
98
+ if (!name)
99
+ return warn(t('flow.usage'));
100
+ const description = positional.join(' ').trim() || undefined;
101
+ const { exportFlowFromTrace, saveFlow } = await import('../../flows/flow.js');
102
+ const { readTrace } = await import('../../flows/trace.js');
103
+ const exported = exportFlowFromTrace(readTrace(), { name, description, from, to });
104
+ if (!exported.ok)
105
+ return warn(exported.error);
106
+ const saved = saveFlow(exported.flow, { overwrite: force });
107
+ if (!saved.ok)
108
+ return warn(saved.error);
109
+ layout.contentWrite(`${ui.green}${t('flow.saved', { name, steps: exported.flow.steps.length, path: saved.path })}${ui.reset}\n`);
110
+ const required = Object.entries(exported.flow.params)
111
+ .filter(([, p]) => p.default === undefined)
112
+ .map(([k]) => k);
113
+ if (required.length)
114
+ dim(t('flow.savedParams', { name, params: required.join(', ') }));
115
+ const fragile = exported.flow.steps.filter((s) => s.fragile).length;
116
+ if (fragile)
117
+ warn(t('flow.fragile', { count: fragile }));
118
+ }
119
+ async function flowRun(tokens) {
120
+ const name = tokens.shift();
121
+ if (!name)
122
+ return warn(t('flow.usage'));
123
+ if (!isComputerUseRouteAllowed())
124
+ return warn(t('flow.gateOff'));
125
+ const params = {};
126
+ let windowTitleRegex;
127
+ for (let i = 0; i < tokens.length; i++) {
128
+ const tok = tokens[i];
129
+ if (tok === '--window') {
130
+ const re = tokens[++i];
131
+ if (!re)
132
+ return warn(t('flow.usage'));
133
+ try {
134
+ new RegExp(re, 'i');
135
+ }
136
+ catch {
137
+ return warn(t('flow.badParam', { token: `--window ${re}` }));
138
+ }
139
+ windowTitleRegex = re;
140
+ continue;
141
+ }
142
+ const eq = tok.indexOf('=');
143
+ if (eq <= 0)
144
+ return warn(t('flow.badParam', { token: tok }));
145
+ params[tok.slice(0, eq)] = tok.slice(eq + 1);
146
+ }
147
+ const { loadFlow } = await import('../../flows/flow.js');
148
+ const loaded = loadFlow(name);
149
+ if (!loaded.ok)
150
+ return warn(loaded.error);
151
+ // 与 run_flow 工具同一套审查:敏感 flow 开跑前必须逐次确认(回放内部的单步不再弹窗)。
152
+ const { reviewFlowRun } = await import('../../permissions/flow-review.js');
153
+ const review = reviewFlowRun({
154
+ name,
155
+ params,
156
+ ...(windowTitleRegex !== undefined ? { window_title_regex: windowTitleRegex } : {}),
157
+ });
158
+ if (review.reasons.length) {
159
+ const allow = t('permission.allow');
160
+ const deny = t('permission.deny');
161
+ const res = await promptIntervention({
162
+ type: 'choice',
163
+ title: t('permission.dangerTitle', { tool: 'flow run' }),
164
+ detail: `${review.summary}\n\nPer-run approval required:\n${review.reasons.map((r) => ` - ${r}`).join('\n')}`,
165
+ options: [allow, deny],
166
+ allowCustom: false,
167
+ defaultIndex: 1,
168
+ });
169
+ if (res.action === 'cancelled' || res.value !== allow)
170
+ return dim(t('flow.cancelled'));
171
+ }
172
+ const { runFlowWithComputer } = await import('../../tools/builtins/run-flow.js');
173
+ const { formatFlowRunResult } = await import('../../flows/runner.js');
174
+ const signal = startRunningListener(t('flow.running'));
175
+ let result;
176
+ try {
177
+ result = await runFlowWithComputer(loaded.flow, params, signal, { windowTitleRegex });
178
+ }
179
+ finally {
180
+ stopRunningListener();
181
+ }
182
+ const color = result.status === 'completed' ? ui.green : result.status === 'aborted' ? ui.dim : ui.yellow;
183
+ layout.contentWrite(`${color}${formatFlowRunResult(loaded.flow, result)}${ui.reset}\n`);
184
+ }
185
+ export const flowCommands = [
186
+ async (ctx) => {
187
+ const { line } = ctx;
188
+ if (line !== '/flow' && !line.startsWith('/flow '))
189
+ return unhandled();
190
+ const tokens = tokenizeFlowArgs(line.slice('/flow'.length));
191
+ const sub = (tokens.shift() ?? '').toLowerCase();
192
+ switch (sub) {
193
+ case 'list':
194
+ await flowList();
195
+ break;
196
+ case 'show':
197
+ await flowShow(tokens[0]);
198
+ break;
199
+ case 'save':
200
+ await flowSave(tokens);
201
+ break;
202
+ case 'run':
203
+ await flowRun(tokens);
204
+ break;
205
+ default:
206
+ warn(t('flow.usage'));
207
+ }
208
+ return next();
209
+ },
210
+ ];
@@ -20,6 +20,7 @@ import { jobsCommands } from './jobs.js';
20
20
  import { diffCommands } from './diff.js';
21
21
  import { scheduleCommands } from './schedule.js';
22
22
  import { botsCommands } from './bots.js';
23
+ import { flowCommands } from './flow.js';
23
24
  /**
24
25
  * 命令注册表。顺序即匹配优先级 —— 更具体的分支放前面。
25
26
  * 组内顺序由各文件自己保证(见文件头注释);组间顺序互不影响(条件互斥)。
@@ -48,6 +49,7 @@ export const commandHandlers = [
48
49
  ...diffCommands,
49
50
  ...scheduleCommands,
50
51
  ...botsCommands,
52
+ ...flowCommands,
51
53
  ];
52
54
  /**
53
55
  * 依次询问 handler,返回第一个非 unhandled 的结果。
@@ -112,6 +112,12 @@ export const toolGroupCommands = [
112
112
  layout.contentWrite(`${ui.dim}${s.count} calls · total p50 ${s.totalP50}ms / p95 ${s.totalP95}ms · ` +
113
113
  `capture p50 ${s.captureP50}ms · ${kb}KB/frame · ` +
114
114
  `dedup ${(s.skippedRatio * 100).toFixed(0)}%${ui.reset}\n`);
115
+ // 按动作拆开:新动作(inspect / wait_until / 窗口类)单独对比基线,不被截图类动作的分位数淹没。
116
+ const rows = Object.entries(s.byAction)
117
+ .sort((a, b) => b[1].count - a[1].count)
118
+ .map(([action, v]) => `${action} ×${v.count} p50 ${v.p50}ms / p95 ${v.p95}ms`);
119
+ if (rows.length > 0)
120
+ layout.contentWrite(`${ui.dim}${rows.join(' · ')}${ui.reset}\n`);
115
121
  }
116
122
  }
117
123
  catch {
@@ -167,13 +173,15 @@ export const toolGroupCommands = [
167
173
  // 常驻 PowerShell 进程(注入 / 抓屏)不会随主进程自动退出(Windows 尤其),关开关时必须
168
174
  // 显式回收,否则每次 /cu off 都留一组孤儿进程。差分基准帧与几何缓存也一并清掉。
169
175
  try {
170
- const [{ disposeInputInjector }, { disposeCaptureService }, { resetComputerState }] = await Promise.all([
176
+ const [{ disposeInputInjector }, { disposeCaptureService }, { disposeUiaService }, { resetComputerState }] = await Promise.all([
171
177
  import('../../runtime/input-injector.js'),
172
178
  import('../../runtime/screen-capture-service.js'),
179
+ import('../../runtime/uia-service.js'),
173
180
  import('../../tools/builtins/computer.js'),
174
181
  ]);
175
182
  await disposeInputInjector();
176
183
  await disposeCaptureService();
184
+ await disposeUiaService();
177
185
  resetComputerState();
178
186
  }
179
187
  catch {
@@ -74,6 +74,16 @@ export function buildSlashCommands() {
74
74
  { name: 'status', value: '/cu status', desc: d('commands.cuStatus') },
75
75
  ],
76
76
  },
77
+ {
78
+ name: '/flow',
79
+ desc: d('commands.flow'),
80
+ children: [
81
+ { name: 'list', value: '/flow list', desc: d('commands.flowList') },
82
+ { name: 'show', value: '/flow show ', submit: false, desc: d('commands.flowShow') },
83
+ { name: 'save', value: '/flow save ', submit: false, desc: d('commands.flowSave') },
84
+ { name: 'run', value: '/flow run ', submit: false, desc: d('commands.flowRun') },
85
+ ],
86
+ },
77
87
  {
78
88
  name: '/mcp',
79
89
  desc: d('commands.mcp'),
@@ -2,14 +2,18 @@ import { emitKeypressEvents } from 'node:readline';
2
2
  import { stdin } from 'node:process';
3
3
  import * as layout from '../ui/layout.js';
4
4
  import * as mouse from '../ui/mouse.js';
5
+ import { promptComposer } from '../ui/composer.js';
5
6
  import { appendCurrentSessionRuntimeEvent } from '../session/index.js';
6
7
  const emitter = stdin;
7
- // ── 运行态交互(typeahead 输入 + 滚动回看 + Ctrl+C 中断)──
8
+ // ── 运行态交互(typeahead 输入 + 滚动回看 + Ctrl+C 中断 + Ctrl+G 输入面板)──
8
9
  // 只在 await runAgent() 期间挂载;/resume /rollback /compact 等走 askLine(cooked readline)的分支不挂(避免抢 stdin)。
9
- let runningInput = ''; // 运行中已打字缓冲(单行;agent 结束后预填下一轮 INPUT 态)
10
+ let runningInput = ''; // 运行中已打字缓冲(agent 结束后预填下一轮 INPUT 态;经输入面板可含换行)
10
11
  let runningCursor = 0; // 缓冲内光标字符索引(0..len);运行态支持任意位置编辑,与空闲态一致
11
12
  let runningPlaceholder = '';
12
13
  let currentAbort = null;
14
+ let listening = false; // onRunningKey 是否处于挂载期(start → stop 之间)
15
+ /** 运行态打开的输入面板(Ctrl+G);非 null = 面板仍开着。主循环进 INPUT 态前须等它关闭,否则两个 keypress 监听抢键。 */
16
+ let composerPending = null;
13
17
  /** 获取运行中输入缓冲(agent 结束后预填下一轮 INPUT 态)。 */
14
18
  export function getRunningInput() {
15
19
  return runningInput;
@@ -19,7 +23,48 @@ export function clearRunningInput() {
19
23
  runningInput = '';
20
24
  runningCursor = 0;
21
25
  }
22
- /** 运行态按键:滚动优先,再 Ctrl+C 中断,再 typeahead 编辑(单行,Enter=无操作)。 */
26
+ /**
27
+ * 等待运行态打开的输入面板关闭(无则立即返回)。
28
+ * agent 可能在面板打开期间结束:主循环在进入 INPUT 态(promptWithSlashMenu 挂自己的 keypress)前必须 await,
29
+ * 面板确认的内容随后经 getRunningInput 预填进输入框。
30
+ */
31
+ export function waitForRunningComposer() {
32
+ return composerPending ?? Promise.resolve();
33
+ }
34
+ /** 单行回显:换行折为空格(等长替换,光标索引不变),防多行内容撑破底栏输入行。 */
35
+ function paint() {
36
+ layout.paintRunningInput(runningInput.replace(/[\r\n]/g, ' '), runningCursor, runningPlaceholder);
37
+ }
38
+ /**
39
+ * Ctrl+G(运行态):打开输入面板编辑 typeahead 缓冲。
40
+ * 期间摘掉 onRunningKey(面板自己挂 keypress),并标记 overlay——流式输出 / 状态行心跳 / spinner 帧只喂缓冲
41
+ * 不物理写,否则 80ms 心跳与流式会覆盖弹窗、抢走光标。关闭后整幅重画;确认的内容写回 typeahead 缓冲(不发送)。
42
+ */
43
+ async function openRunningComposer() {
44
+ emitter.off('keypress', onRunningKey);
45
+ layout.setOverlayActive(true);
46
+ let result = { text: null };
47
+ try {
48
+ result = await promptComposer({ initialText: runningInput });
49
+ }
50
+ finally {
51
+ layout.setOverlayActive(false);
52
+ if (listening) {
53
+ // agent 仍在跑:恢复运行态监听(面板 finish 会 pause stdin,这里续上)
54
+ emitter.on('keypress', onRunningKey);
55
+ stdin.resume();
56
+ }
57
+ layout.repaintViewport();
58
+ layout.drawStatusBar();
59
+ }
60
+ if (result.text != null) {
61
+ runningInput = result.text;
62
+ runningCursor = runningInput.length;
63
+ }
64
+ if (listening)
65
+ paint();
66
+ }
67
+ /** 运行态按键:滚动优先,再 Ctrl+C 中断,再 Ctrl+G 面板,再 typeahead 编辑(单行,Enter=无操作)。 */
23
68
  function onRunningKey(_str, key) {
24
69
  if (!key)
25
70
  return;
@@ -51,7 +96,7 @@ function onRunningKey(_str, key) {
51
96
  if (runningInput.length > 0) {
52
97
  runningInput = '';
53
98
  runningCursor = 0;
54
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
99
+ paint();
55
100
  }
56
101
  else if (currentAbort && !currentAbort.signal.aborted) {
57
102
  appendCurrentSessionRuntimeEvent('abort', { phase: 'requested', source: 'keyboard' });
@@ -59,47 +104,58 @@ function onRunningKey(_str, key) {
59
104
  }
60
105
  return;
61
106
  }
107
+ // Ctrl+G:打开输入面板(与 INPUT 态一致;确认只填回 typeahead 缓冲,agent 结束后预填,不发送)
108
+ if (key.ctrl && key.name === 'g') {
109
+ if (!composerPending) {
110
+ composerPending = openRunningComposer()
111
+ .catch(() => undefined)
112
+ .finally(() => {
113
+ composerPending = null;
114
+ });
115
+ }
116
+ return;
117
+ }
62
118
  const s = key.sequence ?? '';
63
119
  // 光标移动(单行 typeahead,光标可任意位置,与空闲态一致)
64
120
  if (key.name === 'left') {
65
121
  runningCursor = Math.max(0, runningCursor - 1);
66
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
122
+ paint();
67
123
  return;
68
124
  }
69
125
  if (key.name === 'right') {
70
126
  runningCursor = Math.min(runningInput.length, runningCursor + 1);
71
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
127
+ paint();
72
128
  return;
73
129
  }
74
130
  if (key.name === 'home' || (key.ctrl && key.name === 'a')) {
75
131
  runningCursor = 0;
76
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
132
+ paint();
77
133
  return;
78
134
  }
79
135
  if (key.name === 'end' || (key.ctrl && key.name === 'e')) {
80
136
  runningCursor = runningInput.length;
81
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
137
+ paint();
82
138
  return;
83
139
  }
84
140
  if (key.name === 'backspace') {
85
141
  if (runningCursor > 0) {
86
142
  runningInput = runningInput.slice(0, runningCursor - 1) + runningInput.slice(runningCursor);
87
143
  runningCursor--;
88
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
144
+ paint();
89
145
  }
90
146
  return;
91
147
  }
92
148
  if (key.name === 'delete') {
93
149
  if (runningCursor < runningInput.length) {
94
150
  runningInput = runningInput.slice(0, runningCursor) + runningInput.slice(runningCursor + 1);
95
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
151
+ paint();
96
152
  }
97
153
  return;
98
154
  }
99
155
  if (key.name === 'escape') {
100
156
  runningInput = '';
101
157
  runningCursor = 0;
102
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
158
+ paint();
103
159
  return;
104
160
  }
105
161
  // Enter / Ctrl+J:运行中 no-op(单行 typeahead;agent 结束后预填,用户在 INPUT 态按 Enter 提交)
@@ -110,7 +166,7 @@ function onRunningKey(_str, key) {
110
166
  if (s && s >= ' ' && !key.ctrl && !key.meta) {
111
167
  runningInput = runningInput.slice(0, runningCursor) + s + runningInput.slice(runningCursor);
112
168
  runningCursor += s.length;
113
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
169
+ paint();
114
170
  }
115
171
  }
116
172
  /** 鼠标右键单击输入框(未拖动)时 layout 读剪贴板后回调:在光标处插入 typeahead 缓冲(单行,换行折为空格)。 */
@@ -118,7 +174,7 @@ function onRunningMousePaste(text) {
118
174
  const flat = text.replace(/[\r\n]+/g, ' ');
119
175
  runningInput = runningInput.slice(0, runningCursor) + flat + runningInput.slice(runningCursor);
120
176
  runningCursor += flat.length;
121
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
177
+ paint();
122
178
  }
123
179
  /** 进入运行态:挂 keypress 监听 + raw mode + 新建 abort 控制器,返回其 signal。在 await runAgent 前、enterRunningMode 后调。 */
124
180
  export function startRunningListener(placeholder) {
@@ -134,13 +190,18 @@ export function startRunningListener(placeholder) {
134
190
  }
135
191
  stdin.resume();
136
192
  emitter.on('keypress', onRunningKey);
193
+ listening = true;
137
194
  layout.setPasteHandler(onRunningMousePaste); // 鼠标右键单击输入框(未拖动)→ 读剪贴板贴入
138
195
  const ac = new AbortController();
139
196
  currentAbort = ac;
140
197
  return ac.signal;
141
198
  }
142
- /** 退出运行态:摘监听 + 清 abort。不 pause / 不 setRawMode(false)——紧接着 promptWithSlashMenu 自己接管 raw。 */
199
+ /**
200
+ * 退出运行态:摘监听 + 清 abort。不 pause / 不 setRawMode(false)——紧接着 promptWithSlashMenu 自己接管 raw。
201
+ * 若输入面板仍开着,面板继续持有键盘;关闭时据 listening=false 不再恢复运行态监听(主循环 await waitForRunningComposer)。
202
+ */
143
203
  export function stopRunningListener() {
204
+ listening = false;
144
205
  emitter.off('keypress', onRunningKey);
145
206
  layout.setPasteHandler(null);
146
207
  currentAbort = null;
@@ -32,7 +32,7 @@ import { PROMPT, buildSlashCommands, LLM_ERROR_HINT_KEYS, isCommandShape, sugges
32
32
  import { dispatchCommand } from './commands/registry.js';
33
33
  import { settlePlanStatus, refreshStatusBase, runningStateFor } from './status-bar.js';
34
34
  import { messageAttachments, formatUserMessage, textOf, queryHistoryFromMessages, renderHistory, } from './message-format.js';
35
- import { startRunningListener, stopRunningListener, getRunningInput, clearRunningInput } from './running-input.js';
35
+ import { startRunningListener, stopRunningListener, getRunningInput, clearRunningInput, waitForRunningComposer, } from './running-input.js';
36
36
  const emitter = stdin;
37
37
  // ── 运行态交互已移到 ./running-input.ts ──
38
38
  // ── pendingPrefill / pendingTimer / pendingAttachments 保留在此(awaitPendingRecall / echoInput 用)
@@ -676,6 +676,9 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
676
676
  }
677
677
  let hasSubmittedInput = false;
678
678
  while (true) {
679
+ // 运行态 Ctrl+G 打开的输入面板可能在 agent 结束时仍开着:等它关闭再进 INPUT 态,
680
+ // 否则面板与 promptWithSlashMenu 两个 keypress 监听抢键;面板确认的内容随后经 getRunningInput 预填。
681
+ await waitForRunningComposer();
679
682
  // INPUT 态:画底栏输入框 + 状态行,光标入输入框
680
683
  refreshStatusBase(history);
681
684
  // 后台反思若已完成(上轮 fire-and-forget),在安全点 flush 一行 dim 摘要: