jetbrains-pi-acp 0.1.4 → 0.1.7

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
@@ -11,7 +11,7 @@ Requires **Node.js 22.19.0+** and a JetBrains IDE with ACP support, such as Inte
11
11
  1. Sign in to your model provider from a terminal:
12
12
 
13
13
  ```bash
14
- npx --yes jetbrains-pi-acp@0.1.4 --setup
14
+ npx --yes jetbrains-pi-acp@0.1.7 --setup
15
15
  ```
16
16
 
17
17
  In pi, run `/login`, complete sign-in, then exit pi and return to the IDE. Credentials are managed by pi.
@@ -23,7 +23,7 @@ Requires **Node.js 22.19.0+** and a JetBrains IDE with ACP support, such as Inte
23
23
  "agent_servers": {
24
24
  "pi": {
25
25
  "command": "npx",
26
- "args": ["--yes", "jetbrains-pi-acp@0.1.4"],
26
+ "args": ["--yes", "jetbrains-pi-acp@0.1.7"],
27
27
  "use_idea_mcp": true,
28
28
  "use_custom_mcp": true
29
29
  }
@@ -38,10 +38,10 @@ The IDE must be able to find `npx`; use its absolute path if needed. Menu locati
38
38
  ## Features
39
39
 
40
40
  - ACP v1 and v2 draft support, with streaming responses, reasoning, tool status, terminal output, and usage reporting.
41
- - Native pi session history: list, load, resume, rename, fork, and delete sessions.
41
+ - Native pi session history: list, load, resume, rename, fork, and delete sessions. Use `/tree` to view the session tree in a selection form and switch context while preserving previous branches. Selecting a user message returns to before it was sent and shows the original text for resending (reattach any attachments). Switching does not revert files, remove existing IDE messages, or generate a branch summary. Clients without form support only display the tree.
42
42
  - Model and reasoning-level selection, plus full-access, standard, read-only, and plan modes.
43
43
  - UI text (config names, mode and command descriptions, permission options, errors) follows the system language: Chinese for `zh*` locales, English otherwise, based on the first non-empty `LC_ALL`, `LC_MESSAGES`, or `LANG`, falling back to the system locale. Tool descriptions and system prompts sent to the model are always English. Restart the adapter after changing the language.
44
- - File diffs for built-in `edit`/`write` tools, with optional Air Git patches and per-turn file reports.
44
+ - File diffs for built-in `edit`/`write` tools, with optional Air Git patches and per-turn file reports. File report snapshots follow Git ignore rules, including `.gitignore`, while retaining tracked files; non-Git projects or systems without Git use filesystem scanning that skips common dependency, build, and cache directories such as `node_modules/`, `vendor/`, `target/`, `build/`, `dist/`, `coverage/`, `.cache/`, and `.venv/`, including nested directories. Snapshots always skip `.DS_Store`.
45
45
  - stdio/HTTP MCP forwarding, including IDE tools; subagents and background shell tasks with targeted cancellation.
46
46
  - Steering, terminal stdin, forms, and URL interactions when supported by the client. Air v1 provides `/stdin text` and `/eof` during command execution.
47
47
 
@@ -52,7 +52,7 @@ The IDE must be able to find `npx`; use its absolute path if needed. Menu locati
52
52
  Append pi options after `--` in the agent's `args`, for example:
53
53
 
54
54
  ```json
55
- ["--yes", "jetbrains-pi-acp@0.1.4", "--", "--model", "provider/model-id", "--thinking", "medium"]
55
+ ["--yes", "jetbrains-pi-acp@0.1.7", "--", "--model", "provider/model-id", "--thinking", "medium"]
56
56
  ```
57
57
 
58
58
  Use configuration options here, not initial prompts, interactive mode, or session-selection arguments. The adapter manages RPC mode and sessions.
@@ -61,6 +61,8 @@ To use an external pi executable, add `--pi-command /absolute/path/to/pi` before
61
61
 
62
62
  Sessions use pi's native storage, normally `~/.pi/agent/sessions/`, and respect pi's session-directory settings. History is filtered by the project working directory. Avoid accessing the same session simultaneously from multiple adapters or from pi and the IDE.
63
63
 
64
+ The first time you open a historical session after restarting ACP, pi reads its current configuration, including newly added models, extensions, skills, and prompts. For an open session, send `/reload` to refresh configuration and the IDE's model and command lists. Historical sessions retain their selected model and reasoning level; changing the default model in `settings.json` does not switch an existing session. Select the new model in the IDE's model menu. Custom models normally live in `~/.pi/agent/models.json`, or under `PI_CODING_AGENT_DIR` when set. Authentication for added models must also be available to the pi process launched by the IDE.
65
+
64
66
  Restart the agent connection after updating the adapter or changing startup options.
65
67
 
66
68
  ## Limitations
package/README.zh-CN.md CHANGED
@@ -11,7 +11,7 @@
11
11
  1. 在终端中登录模型提供商:
12
12
 
13
13
  ```bash
14
- npx --yes jetbrains-pi-acp@0.1.4 --setup
14
+ npx --yes jetbrains-pi-acp@0.1.7 --setup
15
15
  ```
16
16
 
17
17
  在 pi 中运行 `/login`,完成登录后退出 pi 并返回 IDE。凭据由 pi 管理。
@@ -23,7 +23,7 @@
23
23
  "agent_servers": {
24
24
  "pi": {
25
25
  "command": "npx",
26
- "args": ["--yes", "jetbrains-pi-acp@0.1.4"],
26
+ "args": ["--yes", "jetbrains-pi-acp@0.1.7"],
27
27
  "use_idea_mcp": true,
28
28
  "use_custom_mcp": true
29
29
  }
@@ -39,10 +39,10 @@ IDE 必须能找到 `npx`,必要时使用其绝对路径。菜单位置随 IDE
39
39
 
40
40
  - 支持 ACP v1 和 v2 草案,流式展示回答、思考、工具状态、终端输出和用量。
41
41
  - 每轮汇总模型请求与自动压缩的输入、输出及缓存读写 token,取消仍保留已发生用量;通过 `usage_update.cost` 发布 pi 记录的会话累计美元费用,恢复历史后继续累计。可用 `/status` 查看累计 token、费用和上下文用量。
42
- - 使用 pi 原生历史,支持会话列表、加载、恢复、改名、分叉和删除。
42
+ - 使用 pi 原生历史,支持会话列表、加载、恢复、改名、分叉和删除。输入 `/tree` 可通过选择表单查看会话树并切换上下文;原分支保留,选择用户消息会回到发送前,并显示原文供重新发送(附件需重新添加)。切换不会回滚文件或清除 IDE 中已有消息,也不会自动生成分支摘要;不支持表单的客户端仅展示会话树。
43
43
  - 支持切换模型与推理级别,以及完全访问、标准访问、只读和规划模式。
44
44
  - 界面文案(配置名称、模式说明、命令说明、授权选项、报错等)按系统语言自动切换中英文,给模型看的工具描述和系统提示固定使用英文。语言判断依次读取非空的 `LC_ALL`、`LC_MESSAGES`、`LANG`,未设置时使用系统默认语言;中文使用中文,其余语言使用英文。更改系统语言后需重启适配器。
45
- - 展示内置 `edit/write` 工具的文件 diff,可协商 Air Git patch 和每轮文件报告。
45
+ - 展示内置 `edit/write` 工具的文件 diff,可协商 Air Git patch 和每轮文件报告。文件报告快照遵守 `.gitignore` 等 Git 忽略规则,保留已跟踪文件;非 Git 项目或 Git 不可用时使用文件系统扫描,跳过 `node_modules/`、`vendor/`、`target/`、`build/`、`dist/`、`coverage/`、`.cache/`、`.venv/` 等常见依赖、构建和缓存目录(包括嵌套目录)。快照始终跳过 `.DS_Store`。
46
46
  - 转发 stdio/HTTP MCP 配置及 IDE 工具;支持子 agent、后台 shell 任务和定向停止。
47
47
  - 按客户端能力提供 steering、终端 stdin、表单和 URL 交互。Air v1 可在命令执行期间发送 `/stdin 文本` 和 `/eof`。
48
48
 
@@ -53,7 +53,7 @@ IDE 必须能找到 `npx`,必要时使用其绝对路径。菜单位置随 IDE
53
53
  在 Agent 的 `args` 末尾添加 `--`,随后传递 pi 参数,例如:
54
54
 
55
55
  ```json
56
- ["--yes", "jetbrains-pi-acp@0.1.4", "--", "--model", "provider/model-id", "--thinking", "medium"]
56
+ ["--yes", "jetbrains-pi-acp@0.1.7", "--", "--model", "provider/model-id", "--thinking", "medium"]
57
57
  ```
58
58
 
59
59
  这里只传配置选项,不传初始提示、交互模式或会话选择参数。RPC 模式和会话由适配器管理。
@@ -62,6 +62,8 @@ IDE 必须能找到 `npx`,必要时使用其绝对路径。菜单位置随 IDE
62
62
 
63
63
  历史使用 pi 原生存储,通常位于 `~/.pi/agent/sessions/`,同时遵循 pi 的会话目录设置。列表按项目工作目录过滤。避免多个适配器,或终端 pi 与 IDE,同时操作同一会话。
64
64
 
65
+ 历史会话在 ACP 重启后首次打开时读取当前 pi 配置,可使用新增的模型、扩展、skills 和提示词。已打开的会话可发送 `/reload`,刷新配置及 IDE 中的模型、命令列表。历史会话会保留上次选择的模型和推理级别;修改 `settings.json` 的默认模型不会自动切换历史会话,请在 IDE 模型菜单中选择新模型。自定义模型通常配置在 `~/.pi/agent/models.json`;设置了 `PI_CODING_AGENT_DIR` 时,以该目录为准,新增模型的认证也必须对 IDE 启动的 pi 可用。
66
+
65
67
  更新适配器或调整启动参数后,需要重启 Agent 连接。
66
68
 
67
69
  ## 限制
package/extensions/acp.ts CHANGED
@@ -143,6 +143,13 @@ export default function (pi: any) {
143
143
  await ctx.fork(JSON.parse(args), { position: 'at' });
144
144
  },
145
145
  });
146
+ pi.registerCommand('__acp_tree', {
147
+ description: 'Navigate the ACP session tree',
148
+ handler: async (args: string, ctx: any) => {
149
+ const result = await ctx.navigateTree(JSON.parse(args), { summarize: false });
150
+ ctx.ui.notify(`PI_ACP_TREE:${JSON.stringify({ cancelled: result.cancelled })}`, 'info');
151
+ },
152
+ });
146
153
  pi.registerCommand('__acp_goal', {
147
154
  description: 'Sync the ACP goal',
148
155
  handler: async (args: string) => {
@@ -191,6 +198,11 @@ export default function (pi: any) {
191
198
  ctx.ui.notify('PI_ACP_READY', 'info');
192
199
  },
193
200
  });
201
+ pi.registerCommand('__acp_reload', {
202
+ description: 'Reload models.json, extensions, skills and prompts',
203
+ // reload 不会刷新 models.json,需先刷新模型注册表。
204
+ handler: async (_args: string, ctx: any) => { await ctx.modelRegistry.refresh(); await ctx.reload(); },
205
+ });
194
206
  pi.registerCommand('__acp_archive', {
195
207
  description: 'Sync the ACP archive state',
196
208
  handler: async (args: string) => { pi.appendEntry('acp_archive', args.trim() === 'true'); },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jetbrains-pi-acp",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "连接 JetBrains IDE 与 pi coding agent 的 ACP 适配器",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -14,7 +14,7 @@ import { SessionIndex } from './session-index.js';
14
14
  import { SessionFork } from './session-fork.js';
15
15
  import { Commands } from './commands.js';
16
16
  import { convertMcp, convertPrompt } from './convert.js';
17
- import { changedFiles, fileSnapshot } from './file-changes.js';
17
+ import { bashDiffs, changedFiles, fileSnapshot, snapshotTexts } from './file-changes.js';
18
18
  import { air, invalid, logError, modelKey, requireCwd, requireId, restrictedModes } from './shared.js';
19
19
  import { t } from './i18n.js';
20
20
 
@@ -244,8 +244,10 @@ export class Agent {
244
244
  if (session.turn?.finishing) throw invalid(t('sessionReporting'));
245
245
  if (session.changingConfig) throw invalid(t('configChanging'));
246
246
  const prompt = convertPrompt(params.prompt);
247
- if (!steering && !prompt.images && this.commands.isLocal(prompt.message.trim())) {
248
- return this.commands.run(session, prompt.message.trim(), this.fileReportRequest(params));
247
+ // Air 会把选中的提交、文件等附件以 "context: renderer=..." 块追加在文本后,命令判断需先去掉。
248
+ const command = prompt.message.split(/\n\ncontext: renderer=/)[0].trim();
249
+ if (!steering && !prompt.images && this.commands.isLocal(command)) {
250
+ return this.commands.run(session, command, this.fileReportRequest(params));
249
251
  }
250
252
  return this.prompt(session, prompt, steering, steering ? undefined : this.fileReportRequest(params));
251
253
  }
@@ -334,49 +336,58 @@ export class Agent {
334
336
  this.update(session, { sessionUpdate: 'session_info_update', ...air({ agentFileChangeReport: report }) });
335
337
  }
336
338
 
339
+ // 建立回合状态。prompt() 与 pi 自行发起的回合(扩展 triggerTurn,如子代理完成通知)共用。
340
+ beginTurn(session, steering, fileReportRequest) {
341
+ this.onForegroundState?.(session, 'running');
342
+ let resolve;
343
+ const completed = new Promise(done => { resolve = done; });
344
+ const turn = { completed, pending: 0, starts: 0, settled: false,
345
+ cancelled: false, error: null, stopReason: 'end_turn', usage: Promise.resolve(),
346
+ fileReportRequest, changedPaths: new Set(), editedPaths: new Set() };
347
+ if (fileReportRequest) turn.filesBefore = fileSnapshot(session.cwd)
348
+ .then(async snapshot => ({ ...snapshot, texts: await snapshotTexts(snapshot).catch(() => undefined) }));
349
+ turn.finish = () => {
350
+ if (session.turn !== turn || !turn.settled || turn.pending || turn.finishing) return;
351
+ const complete = () => {
352
+ this.interactions.cancel(session, { keepAcceptedUrls: true });
353
+ session.turn = null;
354
+ if (steering && turn.error && !turn.cancelled) {
355
+ this.update(session, { sessionUpdate: 'agent_message_chunk',
356
+ content: { type: 'text', text: `\n${t('executionFailed', { error: turn.error.message })}\n` } });
357
+ }
358
+ resolve();
359
+ };
360
+ if (!turn.filesBefore || turn.cancelled || turn.error) return complete();
361
+ turn.finishing = true;
362
+ turn.filesBefore.then(async before => {
363
+ const after = await fileSnapshot(session.cwd);
364
+ for (const path of changedFiles(before, after)) turn.changedPaths.add(path);
365
+ turn.filesIncomplete = before.incomplete || after.incomplete;
366
+ const diffs = await bashDiffs(before, changedFiles(before, after), turn.editedPaths);
367
+ if (diffs.length) this.update(session, { sessionUpdate: 'tool_call', toolCallId: `acp-files-${randomUUID()}`,
368
+ title: t('changedFilesTitle'), kind: 'edit', status: 'completed', content: diffs,
369
+ locations: diffs.map(({ path }) => ({ path })) });
370
+ }).catch(() => { turn.filesIncomplete = true; }).finally(complete);
371
+ };
372
+ turn.settle = () => { turn.settled = true; turn.finish(); };
373
+ session.turn = turn;
374
+ const task = this.tasks.tasks.get(session.taskId);
375
+ if (task && task.state !== 'running') {
376
+ task.state = 'running';
377
+ task.output = '';
378
+ task.error = undefined;
379
+ task.truncated = false;
380
+ turn.taskWrite = this.tasks.save(this.sessions.get(task.owner), task);
381
+ task.completion = turn.completed.then(() => this.tasks.finish(task,
382
+ turn.cancelled ? 'stopped' : turn.error ? 'failed' : 'completed', turn.error));
383
+ task.completion.catch(error => logError('saveTaskFailed')(error));
384
+ }
385
+ return turn;
386
+ }
387
+
337
388
  async prompt(session, prompt, steering, fileReportRequest) {
338
389
  const created = !session.turn;
339
- if (created) {
340
- this.onForegroundState?.(session, 'running');
341
- let resolve;
342
- const completed = new Promise(done => { resolve = done; });
343
- const turn = { completed, pending: 0, starts: 0, settled: false,
344
- cancelled: false, error: null, stopReason: 'end_turn', usage: Promise.resolve(),
345
- fileReportRequest, changedPaths: new Set() };
346
- if (fileReportRequest) turn.filesBefore = fileSnapshot(session.cwd);
347
- turn.finish = () => {
348
- if (session.turn !== turn || !turn.settled || turn.pending || turn.finishing) return;
349
- const complete = () => {
350
- this.interactions.cancel(session, { keepAcceptedUrls: true });
351
- session.turn = null;
352
- if (steering && turn.error && !turn.cancelled) {
353
- this.update(session, { sessionUpdate: 'agent_message_chunk',
354
- content: { type: 'text', text: `\n${t('executionFailed', { error: turn.error.message })}\n` } });
355
- }
356
- resolve();
357
- };
358
- if (!turn.filesBefore || turn.cancelled || turn.error) return complete();
359
- turn.finishing = true;
360
- turn.filesBefore.then(async before => {
361
- const after = await fileSnapshot(session.cwd);
362
- for (const path of changedFiles(before, after)) turn.changedPaths.add(path);
363
- turn.filesIncomplete = before.incomplete || after.incomplete;
364
- }).catch(() => { turn.filesIncomplete = true; }).finally(complete);
365
- };
366
- turn.settle = () => { turn.settled = true; turn.finish(); };
367
- session.turn = turn;
368
- const task = this.tasks.tasks.get(session.taskId);
369
- if (task && task.state !== 'running') {
370
- task.state = 'running';
371
- task.output = '';
372
- task.error = undefined;
373
- task.truncated = false;
374
- turn.taskWrite = this.tasks.save(this.sessions.get(task.owner), task);
375
- task.completion = turn.completed.then(() => this.tasks.finish(task,
376
- turn.cancelled ? 'stopped' : turn.error ? 'failed' : 'completed', turn.error));
377
- task.completion.catch(error => logError('saveTaskFailed')(error));
378
- }
379
- }
390
+ if (created) this.beginTurn(session, steering, fileReportRequest);
380
391
  const turn = session.turn;
381
392
  turn.pending++;
382
393
  // 串行提交,避免前一条提示尚在预处理时再次启动运行。
@@ -763,6 +774,24 @@ export class Agent {
763
774
  }
764
775
  }
765
776
 
777
+ // 重新加载 pi 配置后同步模型列表、命令和扩展内存状态(计划模式、目标)。
778
+ async reloadSession(session) {
779
+ await session.pi.request('prompt', { message: '/__acp_reload' });
780
+ if (session.mode === 'plan') await session.pi.request('prompt', { message: '/__acp_mode plan' });
781
+ await session.pi.request('prompt', { message: '/__acp_check' });
782
+ const state = await session.pi.request('get_state');
783
+ const available = await session.pi.request('get_available_models');
784
+ session.availableModels = available?.models ?? [];
785
+ const current = state?.model;
786
+ if (current && !session.availableModels.some(model => modelKey(model) === modelKey(current))) {
787
+ session.availableModels.push(current);
788
+ }
789
+ if (current) session.currentModelId = modelKey(current);
790
+ await this.refreshThinking(session);
791
+ this.update(session, { sessionUpdate: 'config_option_update', configOptions: this.modelConfig(session) });
792
+ await this.commands.publish(session);
793
+ }
794
+
766
795
  async refreshThinking(session) {
767
796
  const [state, available] = await Promise.all([
768
797
  session.pi.request('get_state'), session.pi.request('get_available_thinking_levels'),
@@ -828,6 +857,12 @@ export class Agent {
828
857
  });
829
858
  return;
830
859
  }
860
+ if (!session.turn && event.type === 'agent_start' && !session.parentId) {
861
+ // 没有等待中的 session/prompt:回合由 pi 内部扩展发起,补建回合以转发输出并报告空闲。
862
+ const turn = this.beginTurn(session, true);
863
+ turn.completed.then(() => this.onForegroundState?.(session, 'idle',
864
+ { stopReason: turn.cancelled ? 'cancelled' : turn.error ? 'error' : turn.stopReason }));
865
+ }
831
866
  if (!session.turn) return;
832
867
  switch (event.type) {
833
868
  case 'agent_start':
package/src/commands.js CHANGED
@@ -1,14 +1,34 @@
1
1
  import { invalid, logError } from './shared.js';
2
2
  import { t } from './i18n.js';
3
3
 
4
- // 适配器自己实现的斜杠命令(/status、/compact、/goal),以及发布给客户端的命令列表。
4
+ // 将会话树展平为唯一的选项;迭代遍历避免长会话耗尽调用栈。
5
+ export function treeOptions(tree, leafId) {
6
+ const options = new Map();
7
+ const pending = [...tree].reverse().map(node => ({ node, depth: 0 }));
8
+ while (pending.length) {
9
+ const { node, depth } = pending.pop();
10
+ const { entry } = node;
11
+ const content = entry.message?.content ?? entry.summary ?? entry.content;
12
+ const text = typeof content === 'string' ? content : (content ?? [])
13
+ .map(block => block.text ?? block.name ?? '').join(' ');
14
+ const preview = (node.label || text || entry.customType || entry.type).replace(/\s+/g, ' ').slice(0, 100);
15
+ const label = `${' '.repeat(Math.min(depth, 12))}${entry.id === leafId ? '●' : '○'} ${entry.message?.role ?? entry.type}: ${preview} [${entry.id}]`;
16
+ options.set(label, entry);
17
+ for (let index = node.children.length - 1; index >= 0; index--) {
18
+ pending.push({ node: node.children[index], depth: depth + 1 });
19
+ }
20
+ }
21
+ return options;
22
+ }
23
+
24
+ // 适配器自己实现的斜杠命令,以及发布给客户端的命令列表。
5
25
  export class Commands {
6
26
  constructor(agent) {
7
27
  this.agent = agent;
8
28
  }
9
29
 
10
30
  isLocal(text) {
11
- return text === '/status' || /^\/(compact|goal)(?:\s|$)/.test(text);
31
+ return text === '/status' || text === '/reload' || text === '/tree' || /^\/(compact|goal)(?:\s|$)/.test(text);
12
32
  }
13
33
 
14
34
  // Air 聊天框中的 /stdin、/eof 转为唯一可输入终端的输入;返回 true 表示已处理。
@@ -57,6 +77,33 @@ export class Commands {
57
77
  await this.agent.sendUsage(session, stats);
58
78
  return finish({ stopReason: 'end_turn' });
59
79
  }
80
+ if (text === '/tree') {
81
+ const command = { cancelled: false };
82
+ session.command = command;
83
+ session.changingConfig = true;
84
+ try {
85
+ await this.tree(session, command);
86
+ return finish({ stopReason: command.cancelled ? 'cancelled' : 'end_turn' });
87
+ } catch (error) {
88
+ command.error = error;
89
+ if (this.agent.airSessionFailure) return finish(this.agent.failureResult(session, error));
90
+ finish({});
91
+ throw error;
92
+ } finally {
93
+ session.command = null;
94
+ session.changingConfig = false;
95
+ }
96
+ }
97
+ if (text === '/reload') {
98
+ session.changingConfig = true;
99
+ try {
100
+ await this.agent.reloadSession(session);
101
+ this.agent.update(session, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: t('reloadDone') } });
102
+ return finish({ stopReason: 'end_turn' });
103
+ } finally {
104
+ session.changingConfig = false;
105
+ }
106
+ }
60
107
  const customInstructions = text.slice('/compact'.length).trim() || undefined;
61
108
  const command = { cancelled: false };
62
109
  session.command = command;
@@ -82,11 +129,44 @@ export class Commands {
82
129
  }
83
130
  }
84
131
 
132
+ async tree(session, command) {
133
+ const show = text => this.agent.update(session, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } });
134
+ const { tree, leafId } = await session.pi.request('get_tree');
135
+ if (command.cancelled) return;
136
+ const options = treeOptions(tree, leafId);
137
+ if (!options.size) return show(t('treeEmpty'));
138
+ if (this.agent.clientCapabilities?.elicitation?.form == null) {
139
+ return show(`${t('treeUnsupported')}\n\n${[...options.keys()].join('\n')}`);
140
+ }
141
+ const result = await this.agent.interactions.request(session, 'elicitation', 'elicitation/create', {
142
+ sessionId: session.interactionSessionId ?? session.id, mode: 'form', message: t('treeSelect'),
143
+ requestedSchema: { type: 'object', properties: { value: { type: 'string', enum: [...options.keys()] } }, required: ['value'] },
144
+ });
145
+ if (session.failure) throw session.failure;
146
+ if (command.cancelled || result?.action !== 'accept') return;
147
+ const entry = options.get(result.content?.value);
148
+ if (!entry) throw invalid(t('treeSelectionInvalid'));
149
+ await session.pi.request('prompt', { message: `/__acp_tree ${JSON.stringify(entry.id)}` });
150
+ if (!command.treeResult) throw new Error(t('treeNavigationFailed'));
151
+ if (command.treeResult.cancelled) return show(t('treeCancelled'));
152
+ await session.pi.request('prompt', { message: '/__acp_check' });
153
+ this.agent.goalUpdate(session);
154
+ await this.agent.refreshUsage(session);
155
+ show(t('treeDone', { id: entry.id }));
156
+ if (entry.message?.role === 'user' || entry.type === 'custom_message') {
157
+ const content = entry.message?.content ?? entry.content;
158
+ const text = typeof content === 'string' ? content : (content ?? []).filter(block => block.type === 'text').map(block => block.text).join('\n');
159
+ if (text) show(`\n\n${t('treeResend')}\n${text}`);
160
+ }
161
+ }
162
+
85
163
  async publish(session) {
86
164
  const result = await session.pi.request('get_commands');
87
165
  const availableCommands = new Map([
88
166
  ['status', { name: 'status', description: t('cmdStatus'), input: null }],
89
167
  // Air 的 ACP v1 解析器需要 input 的类型判别字段。
168
+ ['tree', { name: 'tree', description: t('cmdTree'), input: null }],
169
+ ['reload', { name: 'reload', description: t('cmdReload'), input: null }],
90
170
  ['compact', { name: 'compact', description: t('cmdCompact'), input: { type: 'unstructured', hint: t('cmdCompactHint') } }],
91
171
  ['goal', { name: 'goal', description: t('cmdGoal'),
92
172
  input: { type: 'unstructured', hint: t('cmdGoalHint') } }],
@@ -1,5 +1,15 @@
1
+ import { execFile } from 'node:child_process';
1
2
  import { lstat, opendir } from 'node:fs/promises';
2
3
  import { join } from 'node:path';
4
+ import { fileText } from './file-diff.js';
5
+ import { promisify } from 'node:util';
6
+
7
+ const executeFile = promisify(execFile);
8
+ const fallbackIgnoredDirectories = new Set([
9
+ 'node_modules', 'vendor', 'target', 'build', 'dist', 'out', 'coverage',
10
+ '.gradle', '.next', '.nuxt', '.output', '.cache',
11
+ '.venv', 'venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.tox',
12
+ ]);
3
13
 
4
14
  export async function fileSnapshot(cwd) {
5
15
  const files = new Map();
@@ -7,16 +17,27 @@ export async function fileSnapshot(cwd) {
7
17
  const deadline = Date.now() + 2000;
8
18
  let entries = 0;
9
19
  let incomplete = false;
20
+ const ignored = new Set();
21
+ let fallback = false;
22
+ try {
23
+ // Git 保留已跟踪文件,并处理嵌套 .gitignore、否定规则及仓库本地忽略规则。
24
+ const { stdout } = await executeFile('git',
25
+ ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'],
26
+ { cwd, timeout: 2000, maxBuffer: 4 * 1024 * 1024 });
27
+ for (const path of stdout.split('\0')) if (path) ignored.add(join(cwd, path.replace(/\/$/, '')));
28
+ } catch { fallback = true; }
10
29
  while (directories.length) {
11
30
  if (Date.now() > deadline) return { files, incomplete: true };
12
31
  const directory = directories.pop();
13
32
  try {
14
33
  if ((await lstat(directory)).isSymbolicLink()) { incomplete = true; continue; }
15
34
  for await (const entry of await opendir(directory)) {
16
- if (entry.name === '.git') continue;
35
+ if (entry.name === '.git' || entry.name === '.DS_Store' ||
36
+ (fallback && entry.isDirectory() && fallbackIgnoredDirectories.has(entry.name))) continue;
37
+ const path = join(directory, entry.name);
38
+ if (ignored.has(path)) continue;
17
39
  // shortcut: 每次最多扫描 20000 项或 2 秒,大项目需要增量文件监听时再升级。
18
40
  if (++entries > 20000 || Date.now() > deadline) return { files, incomplete: true };
19
- const path = join(directory, entry.name);
20
41
  try {
21
42
  const info = await lstat(path, { bigint: true });
22
43
  if (info.isDirectory()) directories.push(path);
@@ -37,3 +58,30 @@ export function changedFiles(before, after) {
37
58
  }
38
59
  return paths;
39
60
  }
61
+
62
+ // shortcut: 仅缓存 ≤64 KiB 的文本文件,总量 8 MiB、耗时 2 秒,用于给 bash 改动补 diff;更大的文件只报路径。
63
+ export async function snapshotTexts(snapshot) {
64
+ const texts = new Map();
65
+ const deadline = Date.now() + 2000;
66
+ let total = 0;
67
+ for (const [path, stamp] of snapshot.files) {
68
+ if (Date.now() > deadline || total > 8 * 1024 * 1024) break;
69
+ if (Number(stamp.split(':')[2]) > 64 * 1024) continue;
70
+ const text = await fileText(path);
71
+ if (typeof text === 'string') { texts.set(path, text); total += text.length; }
72
+ }
73
+ return texts;
74
+ }
75
+
76
+ // 为 edit/write 之外变更的文本文件生成整文件 diff,最多 20 个。
77
+ export async function bashDiffs(before, paths, skip) {
78
+ const diffs = [];
79
+ for (const path of paths) {
80
+ if (skip.has(path) || diffs.length >= 20) continue;
81
+ const oldText = before.files.has(path) ? before.texts?.get(path) : before.incomplete ? undefined : null;
82
+ const newText = await fileText(path);
83
+ if (oldText === undefined || newText === undefined || oldText === newText) continue;
84
+ diffs.push({ type: 'diff', path, oldText, newText: newText ?? '' });
85
+ }
86
+ return diffs;
87
+ }
package/src/i18n.js CHANGED
@@ -107,6 +107,7 @@ const zh = {
107
107
  goalObjectiveInvalid: 'goal objective 无效',
108
108
  noGoalSet: '当前没有 goal',
109
109
  fileReportUncertainty: '包含内置 edit/write 报告及工作目录前后快照的文件变化(含命令、MCP、扩展);不扫描 .git 和符号链接目标。无法区分外部并发修改,可能遗漏修改后恢复、目录外及轮次结束后的后台修改。',
110
+ changedFilesTitle: '其他文件变更',
110
111
  fileReportIncomplete: ' 快照扫描未完成(读取失败或超过 20000 项/2 秒限制),新增和删除可能漏报。',
111
112
  compactingContext: '压缩对话上下文',
112
113
 
@@ -142,6 +143,17 @@ const zh = {
142
143
 
143
144
  // 斜杠命令
144
145
  cmdStatus: '显示当前模型、token、成本和上下文使用情况',
146
+ cmdTree: '查看会话树并切换历史节点',
147
+ treeSelect: '选择会话节点(● 为当前位置);用户消息会回到发送前。切换上下文不会回滚文件,IDE 中已有消息会保留。',
148
+ treeEmpty: '会话树为空',
149
+ treeUnsupported: '客户端不支持选择表单,当前仅展示会话树,未切换上下文:',
150
+ treeSelectionInvalid: '所选会话节点无效',
151
+ treeNavigationFailed: 'pi 未返回会话树切换结果',
152
+ treeCancelled: 'pi 扩展取消了会话树切换',
153
+ treeDone: '已切换到会话节点 {id} 对应的上下文,后续消息从此处继续。原分支和 IDE 中已有消息仍保留;文件未回滚。',
154
+ treeResend: '如需重新发送该消息,请复制以下原文(附件需重新添加):',
155
+ cmdReload: '重新加载 pi 的模型、扩展、skills 和提示词配置',
156
+ reloadDone: '已重新加载 pi 配置',
145
157
  cmdCompact: '压缩对话上下文,可附带摘要要求',
146
158
  cmdCompactHint: '可选摘要要求',
147
159
  cmdGoal: '设置或查看长期目标,也支持 pause、resume、clear',
@@ -311,6 +323,7 @@ const en = {
311
323
  goalObjectiveInvalid: 'Invalid goal objective',
312
324
  noGoalSet: 'No goal is set',
313
325
  fileReportUncertainty: 'Includes file changes reported by built-in edit/write and from before/after snapshots of the working directory (including commands, MCP, and extensions); .git and symlink targets are not scanned. Concurrent external changes cannot be distinguished, and changes that were reverted, made outside the directory, or made by background work after the turn may be missed.',
326
+ changedFilesTitle: 'Other file changes',
314
327
  fileReportIncomplete: ' The snapshot scan did not finish (read failure or the 20000-entry / 2-second limit), so additions and deletions may be missing.',
315
328
  compactingContext: 'Compacting conversation context',
316
329
 
@@ -342,6 +355,17 @@ const en = {
342
355
  promptContentUnsupported: 'Unsupported prompt content: {type}',
343
356
 
344
357
  cmdStatus: 'Show the current model, tokens, cost, and context usage',
358
+ cmdTree: 'View the session tree and switch to a history node',
359
+ treeSelect: 'Select a session node (● marks the current position); user messages return to before they were sent. Switching context does not revert files or remove existing IDE messages.',
360
+ treeEmpty: 'The session tree is empty',
361
+ treeUnsupported: 'This client does not support selection forms. Showing the session tree without switching context:',
362
+ treeSelectionInvalid: 'Invalid session node selection',
363
+ treeNavigationFailed: 'pi did not return a tree navigation result',
364
+ treeCancelled: 'A pi extension cancelled tree navigation',
365
+ treeDone: 'Switched to the context for session node {id}. Subsequent messages continue from here. The previous branch and existing IDE messages are preserved; files were not reverted.',
366
+ treeResend: 'To resend this message, copy the original text below (reattach any attachments):',
367
+ cmdReload: 'Reload pi models, extensions, skills and prompts',
368
+ reloadDone: 'pi configuration reloaded',
345
369
  cmdCompact: 'Compact the conversation context, optionally with summary instructions',
346
370
  cmdCompactHint: 'Optional summary instructions',
347
371
  cmdGoal: 'Set or view the goal; also supports pause, resume, and clear',
@@ -17,6 +17,9 @@ const notifyHandlers = {
17
17
  if (message.role === 'assistant') session.assistantMessageId = message.id;
18
18
  if (message.role === 'user') agent.onUserMessage?.(session, message.id);
19
19
  },
20
+ 'PI_ACP_TREE:'(agent, session, payload) {
21
+ if (session.command) session.command.treeResult = JSON.parse(payload);
22
+ },
20
23
  'PI_ACP_GOAL:'(agent, session, payload) {
21
24
  session.goal = JSON.parse(payload);
22
25
  if (session.availableModels && !session.changingConfig) agent.goalUpdate(session);
package/src/transcript.js CHANGED
@@ -109,7 +109,10 @@ export class Transcript {
109
109
  }
110
110
  const tool = session.tools.get(event.toolCallId);
111
111
  if (end && !event.isError && ['edit', 'write'].includes(tool?.title) &&
112
- tool.locations?.[0]?.path) session.turn.changedPaths?.add(tool.locations[0].path);
112
+ tool.locations?.[0]?.path) {
113
+ session.turn.changedPaths?.add(tool.locations[0].path);
114
+ session.turn.editedPaths?.add(tool.locations[0].path);
115
+ }
113
116
  this.agent.update(session, { sessionUpdate: 'tool_call_update', toolCallId: event.toolCallId,
114
117
  status: !end ? 'in_progress' : event.isError ? 'failed' : 'completed',
115
118
  ...this.toolResult(session, event.toolCallId, event.result ?? event.partialResult, event.isError || !end, end) });