mocode-ai 0.6.10 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { MAX_OUTPUT } from '../constants.js';
3
- import { getSandboxRoot, filterEnv, isCommandDenied } from '../../sandbox/index.js';
3
+ import { getSandboxRoot, filterEnv, isCommandDenied, jailResolve } from '../../sandbox/index.js';
4
4
  import { t } from '../../i18n/index.js';
5
5
  const OUTPUT_HEAD_LIMIT = Math.floor(MAX_OUTPUT * 0.4);
6
6
  const OUTPUT_TAIL_LIMIT = MAX_OUTPUT - OUTPUT_HEAD_LIMIT;
@@ -25,6 +25,119 @@ class BoundedCommandOutput {
25
25
  return `${this.head}\n${t('command.outputTruncated', { count: removed })}\n${this.tail}`;
26
26
  }
27
27
  }
28
+ /** Execute a command with the same sandbox, output cap and cancellation semantics as run_command. */
29
+ export async function runCommandRaw(command, timeout = 120000, signal, cwd) {
30
+ const startedAt = Date.now();
31
+ const deny = isCommandDenied(command);
32
+ if (deny) {
33
+ return { status: 'denied', exitCode: null, output: `错误:${deny}`, durationMs: 0 };
34
+ }
35
+ let executionCwd = getSandboxRoot() ?? process.cwd();
36
+ if (cwd) {
37
+ try {
38
+ executionCwd = jailResolve(cwd);
39
+ }
40
+ catch (error) {
41
+ const message = error instanceof Error ? error.message : String(error);
42
+ return { status: 'denied', exitCode: null, output: `错误:${message}`, durationMs: 0 };
43
+ }
44
+ }
45
+ return new Promise((done) => {
46
+ const isWin = process.platform === 'win32';
47
+ const child = spawn(isWin ? 'cmd.exe' : 'bash', isWin ? ['/d', '/s', '/c', command] : ['-c', command], {
48
+ cwd: executionCwd,
49
+ env: filterEnv(process.env),
50
+ // Without this, Node re-quotes cmd.exe arguments and `node -e "..."` can become
51
+ // a string literal that exits 0, causing false-positive validation on Windows.
52
+ windowsVerbatimArguments: isWin,
53
+ });
54
+ const output = new BoundedCommandOutput();
55
+ let finished = false;
56
+ let timer;
57
+ const killTree = () => {
58
+ try {
59
+ if (isWin) {
60
+ if (child.pid != null) {
61
+ spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
62
+ }
63
+ }
64
+ else {
65
+ child.kill('SIGTERM');
66
+ }
67
+ }
68
+ catch {
69
+ // Process already exited or best-effort termination failed.
70
+ }
71
+ };
72
+ const finish = (result) => {
73
+ if (finished)
74
+ return;
75
+ finished = true;
76
+ if (timer)
77
+ clearTimeout(timer);
78
+ signal?.removeEventListener('abort', onAbort);
79
+ done({ ...result, durationMs: Date.now() - startedAt });
80
+ };
81
+ const onAbort = () => {
82
+ killTree();
83
+ finish({ status: 'aborted', exitCode: null, output: output.render().trim() });
84
+ };
85
+ const onChunk = (chunk) => output.append(chunk.toString('utf8'));
86
+ child.stdout.on('data', onChunk);
87
+ child.stderr.on('data', onChunk);
88
+ child.on('error', (error) => {
89
+ finish({ status: 'spawn_error', exitCode: null, output: error.message });
90
+ });
91
+ child.on('close', (code) => {
92
+ finish({
93
+ status: code === 0 ? 'passed' : 'failed',
94
+ exitCode: code,
95
+ output: output.render().trim(),
96
+ });
97
+ });
98
+ timer = setTimeout(() => {
99
+ killTree();
100
+ finish({ status: 'timed_out', exitCode: null, output: output.render().trim() });
101
+ }, timeout);
102
+ if (signal) {
103
+ if (signal.aborted)
104
+ onAbort();
105
+ else
106
+ signal.addEventListener('abort', onAbort, { once: true });
107
+ }
108
+ });
109
+ }
110
+ /** Preserve the public run_command text protocol while exposing structured status internally. */
111
+ export function formatCommandResult(result) {
112
+ const output = result.output.trim();
113
+ if (result.status === 'denied')
114
+ return result.output;
115
+ if (result.status === 'aborted')
116
+ return `${t('command.interrupted')}\n${output}`;
117
+ if (result.status === 'timed_out')
118
+ return `${t('command.timedOut')}\n${output}`;
119
+ if (result.status === 'spawn_error')
120
+ return t('command.executionFailed', { message: result.output });
121
+ return `${t('command.exitCode', { code: result.exitCode ?? 'null' })}\n${output || t('toolSummary.noOutput')}`;
122
+ }
123
+ /** Convert the raw process status into the common structured tool contract. */
124
+ function commandOutcome(result) {
125
+ const output = formatCommandResult(result);
126
+ switch (result.status) {
127
+ case 'passed':
128
+ return { status: 'success', code: 'OK', retryable: false, output, durationMs: result.durationMs };
129
+ case 'aborted':
130
+ return { status: 'aborted', code: 'ABORTED', retryable: false, output, durationMs: result.durationMs };
131
+ case 'denied':
132
+ return { status: 'denied', code: 'SANDBOX_DENIED', retryable: false, output, durationMs: result.durationMs };
133
+ case 'timed_out':
134
+ return { status: 'error', code: 'TIMEOUT', retryable: false, output, durationMs: result.durationMs };
135
+ case 'failed':
136
+ return { status: 'error', code: 'PROCESS_FAILED', retryable: false, output, durationMs: result.durationMs };
137
+ case 'spawn_error':
138
+ return { status: 'error', code: 'EXECUTION_ERROR', retryable: false, output, durationMs: result.durationMs };
139
+ }
140
+ }
28
141
  // ---------- run_command ----------
29
142
  export const runCommandTool = {
30
143
  name: 'run_command',
@@ -41,70 +154,6 @@ export const runCommandTool = {
41
154
  async execute(args, ctx) {
42
155
  const command = String(args.command);
43
156
  const timeout = Number(args.timeout ?? 120000);
44
- // 沙箱 best-effort:灾难性文件操作 denylist(非安全边界,只挡误操作;真隔离需 OS jailer)。
45
- const deny = isCommandDenied(command);
46
- if (deny)
47
- return `错误:${deny}`;
48
- return new Promise((done) => {
49
- const isWin = process.platform === 'win32';
50
- // 沙箱 best-effort:cwd 钉死 sandbox root(相对路径写落在牢内)+ env 脱敏(剥 *KEY/*TOKEN 等,防 LLM_API_KEY 泄子进程)
51
- const child = spawn(isWin ? 'cmd.exe' : 'bash', isWin ? ['/c', command] : ['-c', command], { cwd: getSandboxRoot() ?? process.cwd(), env: filterEnv(process.env) });
52
- const output = new BoundedCommandOutput();
53
- let finished = false;
54
- let timer;
55
- // 杀整棵进程树。child.kill() 在 Windows 只杀 cmd.exe、npm 等子进程会孤儿继续跑(占锁、污染下一步),
56
- // 故 Win 用 taskkill /T /F 树杀;Unix child.kill('SIGTERM')(bash -c 通常转发给前台子进程,best-effort)。
57
- const killTree = () => {
58
- try {
59
- if (isWin) {
60
- if (child.pid != null) {
61
- spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
62
- stdio: 'ignore',
63
- });
64
- }
65
- }
66
- else {
67
- child.kill('SIGTERM');
68
- }
69
- }
70
- catch {
71
- // 进程已退出 / kill 失败:忽略(close 事件会兜底 finish)
72
- }
73
- };
74
- // abort(用户 Ctrl+C,经 executeTool ctx.signal 透传)→ 杀子进程树 + 返[已中断]
75
- const onAbort = () => {
76
- killTree();
77
- finish(`${t('command.interrupted')}\n${output.render().trim()}`);
78
- };
79
- const finish = (s) => {
80
- if (finished)
81
- return;
82
- finished = true;
83
- clearTimeout(timer);
84
- ctx?.signal?.removeEventListener('abort', onAbort);
85
- done(s);
86
- };
87
- const onChunk = (chunk) => {
88
- output.append(chunk.toString('utf8'));
89
- };
90
- child.stdout.on('data', onChunk);
91
- child.stderr.on('data', onChunk);
92
- child.on('error', (e) => finish(t('command.executionFailed', { message: e.message })));
93
- child.on('close', (code) => {
94
- const result = output.render().trim();
95
- finish(`${t('command.exitCode', { code: code ?? 'null' })}\n${result || t('toolSummary.noOutput')}`);
96
- });
97
- timer = setTimeout(() => {
98
- killTree();
99
- finish(`${t('command.timedOut')}\n${output.render().trim()}`);
100
- }, timeout);
101
- // 外部 abort signal:已 aborted 即时杀(防御;agent 循环顶检查通常会先拦),否则挂监听
102
- if (ctx?.signal) {
103
- if (ctx.signal.aborted)
104
- onAbort();
105
- else
106
- ctx.signal.addEventListener('abort', onAbort, { once: true });
107
- }
108
- });
157
+ return commandOutcome(await runCommandRaw(command, timeout, ctx?.signal));
109
158
  },
110
159
  };
@@ -1,12 +1,13 @@
1
1
  import { spawnAgent } from '../../agent/spawn.js';
2
2
  import { MAX_OUTPUT } from '../constants.js';
3
3
  import { t } from '../../i18n/index.js';
4
+ import { isSubAgentEnabled } from '../../config/index.js';
4
5
  // ---------- task ----------
5
6
  // 派生子 agent 执行独立子任务。子 agent 有独立 history(不污染主对话),
6
7
  // 可受限工具子集 + 低步数上限,最终摘要回灌主 history 供主 agent 继续。
7
8
  //
8
- // 适用:并行探查多个文件 / 分而治之的复杂任务 / 隔离上下文避免子任务工具噪声撑爆主窗口。
9
- // 子 agent 中间过程不写主屏,只返回最终摘要;主 agent 据摘要决定下一步。
9
+ // 适用:分而治之的复杂任务 / 隔离上下文避免子任务工具噪声撑爆主窗口。
10
+ // 多个 task 在共享工作区期间由 capability scheduler 串行执行;隔离 workspace 落地后再开放并行写。
10
11
  export const taskTool = {
11
12
  name: 'task',
12
13
  risk: 'dangerous',
@@ -35,6 +36,8 @@ export const taskTool = {
35
36
  required: ['prompt'],
36
37
  },
37
38
  async execute(args, ctx) {
39
+ if (!isSubAgentEnabled())
40
+ return t('task.disabled');
38
41
  const prompt = String(args.prompt ?? '');
39
42
  if (!prompt)
40
43
  return t('task.missingPrompt');
@@ -1,5 +1,5 @@
1
1
  /** 工具共享的截断 / 上限 / 忽略规则。 */
2
- import { isMemoryEnabled } from '../config/index.js';
2
+ import { isMemoryEnabled, isSubAgentEnabled } from '../config/index.js';
3
3
  export const MAX_FILE_LINES = 2000;
4
4
  export const MAX_OUTPUT = 20000;
5
5
  export const MAX_RESULTS = 100;
@@ -54,3 +54,7 @@ export function getPlanDisabledTools() {
54
54
  next.delete('memory_forget');
55
55
  return next;
56
56
  }
57
+ /** auto/plan 共用的运行时功能开关防线;关闭时即使模型幻觉调用也不得执行。 */
58
+ export function getRuntimeDisabledTools() {
59
+ return isSubAgentEnabled() ? new Set() : new Set(['task']);
60
+ }
@@ -1,7 +1,8 @@
1
1
  import { builtinTools } from './builtins/index.js';
2
- import { beginPathMutation, beginWorkspaceMutation, endPathMutation, endWorkspaceMutation, } from '../rollback/index.js';
2
+ import { beginPathMutation, beginWorkspaceMutation, endPathMutation, endWorkspaceMutation, getCurrentTurnMutationState, } from '../rollback/index.js';
3
3
  import { enforceSandbox } from '../sandbox/index.js';
4
4
  import { t } from '../i18n/index.js';
5
+ import { isToolErrorOutput } from './result.js';
5
6
  /**
6
7
  * 可扩展工具注册表。数组实例始终稳定,使已经持有 tools 引用的 agent/LLM 能看到运行时新增工具。
7
8
  * 扩展按 source 替换,MCP 重连或配置刷新不会累积旧工具。
@@ -33,40 +34,103 @@ function rebuildTools() {
33
34
  }
34
35
  tools.splice(0, tools.length, ...next);
35
36
  }
37
+ const DEFAULT_CAPABILITIES = Object.freeze({
38
+ effect: 'unknown',
39
+ concurrency: 'serial',
40
+ retry: 'never',
41
+ });
42
+ export function findTool(name) {
43
+ return tools.find((tool) => tool.name === name);
44
+ }
45
+ /** 缺少声明或找不到工具时返回保守能力,绝不把未知扩展并发执行。 */
46
+ export function getToolCapabilities(toolOrName) {
47
+ const tool = typeof toolOrName === 'string' ? findTool(toolOrName) : toolOrName;
48
+ return tool?.capabilities ?? DEFAULT_CAPABILITIES;
49
+ }
50
+ export function getToolResourceKeys(toolOrName, args) {
51
+ const capabilities = getToolCapabilities(toolOrName);
52
+ try {
53
+ return capabilities.resources?.(args) ?? [];
54
+ }
55
+ catch {
56
+ return [];
57
+ }
58
+ }
59
+ /** resource-locked write 是可生成文件 diff/按路径记 rollback 的文件 mutation。 */
60
+ export function isFileMutationTool(name) {
61
+ const capabilities = getToolCapabilities(name);
62
+ return capabilities.effect === 'write' && capabilities.concurrency === 'resource-locked';
63
+ }
64
+ function isStructuredOutcome(value) {
65
+ return typeof value === 'object' && value !== null &&
66
+ typeof value.status === 'string' && typeof value.code === 'string' &&
67
+ typeof value.retryable === 'boolean' && typeof value.output === 'string';
68
+ }
69
+ function normalizeOutcome(value, capabilities, durationMs, changedFiles) {
70
+ if (isStructuredOutcome(value)) {
71
+ return {
72
+ ...value,
73
+ durationMs: value.durationMs ?? durationMs,
74
+ changedFiles: value.changedFiles ?? changedFiles,
75
+ };
76
+ }
77
+ const failed = isToolErrorOutput(value);
78
+ return {
79
+ status: failed ? 'error' : 'success',
80
+ code: failed ? 'EXECUTION_ERROR' : 'OK',
81
+ retryable: failed && capabilities.retry !== 'never',
82
+ output: value,
83
+ changedFiles,
84
+ durationMs,
85
+ };
86
+ }
87
+ function terminalOutcome(status, code, output, startedAt, changedFiles = []) {
88
+ return {
89
+ status,
90
+ code,
91
+ retryable: false,
92
+ output,
93
+ changedFiles,
94
+ durationMs: Date.now() - startedAt,
95
+ };
96
+ }
36
97
  /**
37
- * 按名调度工具,统一 try/catch + JSON 解析,返回字符串而非抛错。
38
- * signal 透传给 tool.execute(经 ctx):长任务工具(run_command/web_fetch)abort 即时取消,
39
- * 让用户 Ctrl+C 能跟手中断工具执行(而非等命令跑完 / 超时)。
40
- * opts.dropContext:上下文剔除回调(drop_context 工具用),透传给 tool.execute 经 ctx。
98
+ * 结构化工具调度入口。永不抛错;旧字符串工具在此归一化为 ToolOutcome。
99
+ * 权限仍由 Agent 在展示工具头之前预检,保持现有交互时序。
41
100
  */
42
- export async function executeTool(name, argsRaw, signal, opts) {
43
- const tool = tools.find((t) => t.name === name);
44
- if (!tool)
45
- return t('toolError.unknown', { name });
101
+ export async function executeToolOutcome(name, argsRaw, signal, opts) {
102
+ const startedAt = Date.now();
103
+ if (signal?.aborted) {
104
+ return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt);
105
+ }
106
+ const tool = findTool(name);
107
+ if (!tool) {
108
+ return terminalOutcome('error', 'UNKNOWN_TOOL', t('toolError.unknown', { name }), startedAt);
109
+ }
46
110
  let args;
47
111
  try {
48
112
  args = argsRaw.trim() ? JSON.parse(argsRaw) : {};
49
113
  }
50
114
  catch {
51
- return t('toolError.invalidJson', { name, arguments: argsRaw });
115
+ return terminalOutcome('error', 'INVALID_JSON', t('toolError.invalidJson', { name, arguments: argsRaw }), startedAt);
52
116
  }
117
+ const capabilities = getToolCapabilities(tool);
118
+ const mutationBefore = getCurrentTurnMutationState();
53
119
  try {
54
- // 沙箱先重写/校验路径,保证快照与实际执行目标完全一致。
55
- const sbErr = enforceSandbox(name, args);
56
- if (sbErr)
57
- return sbErr;
58
- const pathCapture = (name === 'write_file' || name === 'edit_file') &&
59
- typeof args.path === 'string' &&
60
- args.path
120
+ const sandboxError = enforceSandbox(name, args);
121
+ if (sandboxError) {
122
+ return terminalOutcome('denied', 'SANDBOX_DENIED', sandboxError, startedAt);
123
+ }
124
+ const pathCapture = isFileMutationTool(name) && typeof args.path === 'string' && args.path
61
125
  ? beginPathMutation(args.path)
62
126
  : null;
63
- // shell 与 MCP 的副作用无法从参数可靠推断:以工作区前后状态识别实际改动。
64
- // task 本身不扫描;其子 agent 共享当前轮,并在各自真实写工具处记账。
65
- const workspaceCapture = name === 'run_command' || name.startsWith('mcp__')
127
+ // 进程和未知扩展可能间接改动任意文件;已声明 write 的非文件工具自行管理其状态。
128
+ const workspaceCapture = capabilities.effect === 'process' || capabilities.effect === 'unknown'
66
129
  ? beginWorkspaceMutation()
67
130
  : null;
131
+ let raw;
68
132
  try {
69
- return await tool.execute(args, {
133
+ raw = await tool.execute(args, {
70
134
  signal,
71
135
  dropContext: opts?.dropContext,
72
136
  });
@@ -77,11 +141,30 @@ export async function executeTool(name, argsRaw, signal, opts) {
77
141
  if (workspaceCapture)
78
142
  endWorkspaceMutation(workspaceCapture, name);
79
143
  }
144
+ const mutationAfter = getCurrentTurnMutationState();
145
+ const changedFiles = mutationAfter.version !== mutationBefore.version
146
+ ? mutationAfter.changedFiles.map((item) => item.path)
147
+ : [];
148
+ if (signal?.aborted) {
149
+ return terminalOutcome('aborted', 'ABORTED', String(isStructuredOutcome(raw) ? raw.output : raw), startedAt, changedFiles);
150
+ }
151
+ return normalizeOutcome(raw, capabilities, Date.now() - startedAt, changedFiles);
80
152
  }
81
- catch (e) {
82
- return t('toolError.execution', {
153
+ catch (error) {
154
+ const mutationAfter = getCurrentTurnMutationState();
155
+ const changedFiles = mutationAfter.version !== mutationBefore.version
156
+ ? mutationAfter.changedFiles.map((item) => item.path)
157
+ : [];
158
+ if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
159
+ return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt, changedFiles);
160
+ }
161
+ return terminalOutcome('error', 'EXECUTION_ERROR', t('toolError.execution', {
83
162
  name,
84
- message: e instanceof Error ? e.message : String(e),
85
- });
163
+ message: error instanceof Error ? error.message : String(error),
164
+ }), startedAt, changedFiles);
86
165
  }
87
166
  }
167
+ /** 字符串兼容入口:现有调用方、TUI 和 LLM history 无需同步迁移。 */
168
+ export async function executeTool(name, argsRaw, signal, opts) {
169
+ return (await executeToolOutcome(name, argsRaw, signal, opts)).output;
170
+ }
package/dist/ui/prompt.js CHANGED
@@ -90,6 +90,59 @@ export async function promptWithSlashMenu(opts) {
90
90
  let resolved = false;
91
91
  let resolve;
92
92
  let reject;
93
+ const queryHistory = opts.queryHistory ?? [];
94
+ let historyIndex = queryHistory.length; // length = 草稿哨兵;0..length-1 = 历史项
95
+ let historyDraft = null;
96
+ /** 用历史 query 替换编辑缓冲;历史内容恢复为普通可编辑文本。 */
97
+ function loadHistoryEntry(value) {
98
+ lines = value.split('\n');
99
+ cl = lines.length - 1;
100
+ cc = lines[cl].length;
101
+ chip = null;
102
+ chipPre = '';
103
+ selected = 0;
104
+ menuTop = 0;
105
+ justSawCR = false;
106
+ computeFiltered();
107
+ redraw();
108
+ }
109
+ /** 在首次离开最新位置时保存完整草稿,然后向更旧的 query 移动。 */
110
+ function recallPrevious() {
111
+ if (queryHistory.length === 0 || historyIndex <= 0)
112
+ return;
113
+ if (historyIndex === queryHistory.length) {
114
+ historyDraft = {
115
+ lines: [...lines],
116
+ cl,
117
+ cc,
118
+ chip,
119
+ chipPre,
120
+ };
121
+ }
122
+ historyIndex--;
123
+ loadHistoryEntry(queryHistory[historyIndex]);
124
+ }
125
+ /** 向更新的 query 移动;越过最新历史时恢复进入历史前的草稿。 */
126
+ function recallNext() {
127
+ if (historyIndex >= queryHistory.length)
128
+ return;
129
+ historyIndex++;
130
+ if (historyIndex < queryHistory.length) {
131
+ loadHistoryEntry(queryHistory[historyIndex]);
132
+ return;
133
+ }
134
+ const draft = historyDraft;
135
+ lines = draft ? [...draft.lines] : [''];
136
+ cl = draft?.cl ?? 0;
137
+ cc = draft?.cc ?? 0;
138
+ chip = draft?.chip ?? null;
139
+ chipPre = draft?.chipPre ?? '';
140
+ selected = 0;
141
+ menuTop = 0;
142
+ justSawCR = false;
143
+ computeFiltered();
144
+ redraw();
145
+ }
93
146
  /** 菜单行(预渲染,带色)——向上展开进内容区底,由 layout 贴入。最多显示 MENU_MAX_VISIBLE 条,支持上下滚动。 */
94
147
  function menuLines() {
95
148
  if (!menuOpen || filtered.length === 0)
@@ -456,6 +509,8 @@ export async function promptWithSlashMenu(opts) {
456
509
  filtered = [];
457
510
  selected = 0;
458
511
  menuTop = 0;
512
+ historyIndex = queryHistory.length;
513
+ historyDraft = null;
459
514
  if (pasteTimer) {
460
515
  clearTimeout(pasteTimer);
461
516
  pasteTimer = null;
@@ -518,19 +573,11 @@ export async function promptWithSlashMenu(opts) {
518
573
  pasteParts.push(s);
519
574
  return;
520
575
  }
521
- // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑↓ 与 plain ↑/↓ 每次 5 行。
522
- // plain ↑/↓ 仅在单行输入且菜单关闭时作滚动(多行编辑留给光标移动,菜单打开留给选项);
523
- // 兼鼠标滚轮——alt 屏内(经 \x1B[?1007h)滚轮转发 ↑/↓,1 行/格太慢故放大到 5。
524
- const plainArrowScroll = (key.name === 'up' || key.name === 'down') &&
525
- !key.ctrl &&
526
- !key.meta &&
527
- !key.shift &&
528
- lines.length <= 1 &&
529
- !(menuOpen && filtered.length > 0);
576
+ // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑/↓ 每次 5 行。
577
+ // 裸 ↑/↓ 留给斜杠菜单、多行光标和 query 历史导航;鼠标滚轮由 SGR mouse 事件处理。
530
578
  if (key.name === 'pageup' ||
531
579
  key.name === 'pagedown' ||
532
- (key.ctrl && (key.name === 'up' || key.name === 'down')) ||
533
- plainArrowScroll) {
580
+ (key.ctrl && (key.name === 'up' || key.name === 'down'))) {
534
581
  const pageH = layout.getGeo().contentBottom;
535
582
  if (key.name === 'pageup')
536
583
  layout.scrollBy(pageH);
@@ -622,6 +669,9 @@ export async function promptWithSlashMenu(opts) {
622
669
  cc = Math.min(cc, lines[cl].length);
623
670
  redraw();
624
671
  }
672
+ else {
673
+ recallPrevious();
674
+ }
625
675
  return;
626
676
  case 'down':
627
677
  if (menuOpen && filtered.length) {
@@ -633,6 +683,9 @@ export async function promptWithSlashMenu(opts) {
633
683
  cc = Math.min(cc, lines[cl].length);
634
684
  redraw();
635
685
  }
686
+ else {
687
+ recallNext();
688
+ }
636
689
  return;
637
690
  case 'tab':
638
691
  if (menuOpen && filtered[selected])
@@ -0,0 +1,149 @@
1
+ import path from 'node:path';
2
+ const isWindows = process.platform === 'win32';
3
+ const ROOT_CONFIG_NAMES = new Set([
4
+ 'package.json', 'pnpm-workspace.yaml', 'pnpm-workspace.yml',
5
+ 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb',
6
+ ]);
7
+ function comparisonKey(value) {
8
+ const resolved = path.resolve(value);
9
+ return isWindows ? resolved.toLowerCase() : resolved;
10
+ }
11
+ function isInside(parent, child) {
12
+ const relative = path.relative(comparisonKey(parent), comparisonKey(child));
13
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
14
+ }
15
+ function toNativeSeparators(value) {
16
+ return value.replace(/[\\/]/g, path.sep);
17
+ }
18
+ function canonicalize(profile, input, base) {
19
+ if (!input.trim() || input.includes('\0'))
20
+ return { input, reason: 'invalid_path' };
21
+ try {
22
+ const absolute = path.resolve(base, toNativeSeparators(input));
23
+ if (!isInside(profile.root, absolute))
24
+ return { input, reason: 'outside_project' };
25
+ const relative = path.relative(profile.root, absolute);
26
+ return {
27
+ absolute,
28
+ key: comparisonKey(absolute),
29
+ display: relative === '' ? '.' : relative.split(path.sep).join('/'),
30
+ };
31
+ }
32
+ catch {
33
+ return { input, reason: 'invalid_path' };
34
+ }
35
+ }
36
+ function matchesAnyRoot(file, roots) {
37
+ return roots.some((root) => isInside(root, file));
38
+ }
39
+ function classify(file, owner) {
40
+ if (matchesAnyRoot(file, owner.fixtureRoots))
41
+ return 'fixture';
42
+ if (matchesAnyRoot(file, owner.generatedRoots))
43
+ return 'generated';
44
+ if (matchesAnyRoot(file, owner.vendorRoots))
45
+ return 'vendor';
46
+ if (matchesAnyRoot(file, owner.testRoots))
47
+ return 'test';
48
+ if (matchesAnyRoot(file, owner.sourceRoots))
49
+ return 'source';
50
+ return 'other';
51
+ }
52
+ function rootConfigReason(profile, file) {
53
+ const workspaceKeys = new Set(profile.workspaceConfigPaths.map(comparisonKey));
54
+ if (workspaceKeys.has(file.key))
55
+ return 'workspace_config_change';
56
+ const rootPackage = profile.packages.find((item) => comparisonKey(item.root) === comparisonKey(profile.root));
57
+ const configKeys = new Set([
58
+ ...(rootPackage?.tsconfigPaths ?? []),
59
+ ...(rootPackage?.testConfigPaths ?? []),
60
+ ...(rootPackage?.lintConfigPaths ?? []),
61
+ ].map(comparisonKey));
62
+ if (configKeys.has(file.key))
63
+ return 'root_config_change';
64
+ if (comparisonKey(path.dirname(file.absolute)) !== comparisonKey(profile.root))
65
+ return null;
66
+ const name = path.basename(file.absolute).toLowerCase();
67
+ if (ROOT_CONFIG_NAMES.has(name) || /^tsconfig(?:\..+)?\.json$/i.test(name)) {
68
+ return name.startsWith('pnpm-workspace') ? 'workspace_config_change' : 'root_config_change';
69
+ }
70
+ return null;
71
+ }
72
+ function addReason(selections, packageProfile, reason) {
73
+ const key = comparisonKey(packageProfile.root);
74
+ let selected = selections.get(key);
75
+ if (!selected) {
76
+ selected = { package: packageProfile, reasons: [] };
77
+ selections.set(key, selected);
78
+ }
79
+ if (!selected.reasons.some((item) => item.kind === reason.kind
80
+ && item.changedPath === reason.changedPath
81
+ && item.sourcePackage === reason.sourcePackage)) {
82
+ selected.reasons.push(reason);
83
+ }
84
+ }
85
+ /** Map changed paths to their longest matching package roots without touching the filesystem. */
86
+ export function resolveAffectedPackages(profile, changedFiles, options) {
87
+ const canonicalByKey = new Map();
88
+ const rejected = [];
89
+ for (const input of changedFiles) {
90
+ const result = canonicalize(profile, input, path.resolve(options.changedFilesBase));
91
+ if ('reason' in result)
92
+ rejected.push(result);
93
+ else if (!canonicalByKey.has(result.key))
94
+ canonicalByKey.set(result.key, result);
95
+ }
96
+ const canonical = [...canonicalByKey.values()];
97
+ const packageByDepth = [...profile.packages].sort((left, right) => comparisonKey(right.root).length - comparisonKey(left.root).length);
98
+ const selections = new Map();
99
+ const unmatchedFiles = [];
100
+ let affectsAll = false;
101
+ for (const file of canonical) {
102
+ const configReason = rootConfigReason(profile, file);
103
+ if (configReason) {
104
+ affectsAll = true;
105
+ for (const packageProfile of profile.packages) {
106
+ addReason(selections, packageProfile, {
107
+ kind: configReason,
108
+ changedPath: file.display,
109
+ classification: 'other',
110
+ });
111
+ }
112
+ continue;
113
+ }
114
+ const owner = packageByDepth.find((packageProfile) => isInside(packageProfile.root, file.absolute));
115
+ if (!owner) {
116
+ unmatchedFiles.push(file.display);
117
+ continue;
118
+ }
119
+ addReason(selections, owner, {
120
+ kind: 'direct_change',
121
+ changedPath: file.display,
122
+ classification: classify(file.absolute, owner),
123
+ });
124
+ }
125
+ if (!affectsAll && options.expandDependents && selections.size > 0) {
126
+ const direct = profile.packages.filter((item) => selections.has(comparisonKey(item.root)));
127
+ const sourcePackage = direct.map((item) => item.name).join(', ');
128
+ for (const expanded of options.expandDependents(direct, profile)) {
129
+ const packageProfile = profile.packages.find((item) => comparisonKey(item.root) === comparisonKey(expanded.root));
130
+ if (!packageProfile || selections.has(comparisonKey(packageProfile.root)))
131
+ continue;
132
+ addReason(selections, packageProfile, {
133
+ kind: 'dependent_change',
134
+ changedPath: canonical[0]?.display ?? '.',
135
+ classification: 'other',
136
+ sourcePackage,
137
+ });
138
+ }
139
+ }
140
+ return {
141
+ packages: profile.packages
142
+ .map((item) => selections.get(comparisonKey(item.root)))
143
+ .filter((item) => item !== undefined),
144
+ canonicalChangedFiles: canonical.map((item) => item.display),
145
+ rejected,
146
+ unmatchedFiles,
147
+ affectsAll,
148
+ };
149
+ }