@oh-my-pi/pi-coding-agent 14.9.8 → 15.0.0

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 (138) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/package.json +7 -7
  3. package/scripts/build-binary.ts +11 -0
  4. package/scripts/format-prompts.ts +1 -1
  5. package/src/cli/args.ts +2 -2
  6. package/src/cli/stats-cli.ts +2 -0
  7. package/src/cli.ts +24 -1
  8. package/src/commands/acp.ts +24 -0
  9. package/src/commands/launch.ts +6 -4
  10. package/src/commit/agentic/prompts/system.md +1 -1
  11. package/src/config/model-resolver.ts +30 -0
  12. package/src/config/settings-schema.ts +61 -9
  13. package/src/config/settings.ts +18 -1
  14. package/src/edit/index.ts +22 -1
  15. package/src/edit/modes/patch.ts +10 -0
  16. package/src/edit/modes/replace.ts +3 -0
  17. package/src/edit/renderer.ts +10 -0
  18. package/src/edit/streaming.ts +1 -1
  19. package/src/eval/js/context-manager.ts +10 -9
  20. package/src/eval/js/shared/rewrite-imports.ts +120 -48
  21. package/src/eval/js/shared/runtime.ts +31 -4
  22. package/src/eval/js/tool-bridge.ts +43 -21
  23. package/src/extensibility/extensions/runner.ts +54 -1
  24. package/src/extensibility/extensions/types.ts +11 -0
  25. package/src/extensibility/skills.ts +33 -1
  26. package/src/hashline/grammar.lark +1 -1
  27. package/src/hashline/input.ts +11 -5
  28. package/src/internal-urls/docs-index.generated.ts +7 -7
  29. package/src/internal-urls/index.ts +1 -0
  30. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  31. package/src/internal-urls/router.ts +6 -3
  32. package/src/internal-urls/types.ts +22 -1
  33. package/src/main.ts +13 -9
  34. package/src/modes/acp/acp-agent.ts +361 -54
  35. package/src/modes/acp/acp-client-bridge.ts +152 -0
  36. package/src/modes/acp/acp-event-mapper.ts +180 -15
  37. package/src/modes/acp/terminal-auth.ts +37 -0
  38. package/src/modes/components/read-tool-group.ts +29 -1
  39. package/src/modes/controllers/command-controller.ts +14 -6
  40. package/src/modes/controllers/event-controller.ts +24 -11
  41. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  42. package/src/modes/controllers/input-controller.ts +72 -39
  43. package/src/modes/interactive-mode.ts +71 -7
  44. package/src/modes/rpc/rpc-mode.ts +17 -2
  45. package/src/modes/types.ts +6 -2
  46. package/src/modes/utils/ui-helpers.ts +15 -3
  47. package/src/prompts/agents/designer.md +5 -5
  48. package/src/prompts/agents/explore.md +7 -7
  49. package/src/prompts/agents/init.md +9 -9
  50. package/src/prompts/agents/librarian.md +14 -14
  51. package/src/prompts/agents/plan.md +4 -4
  52. package/src/prompts/agents/reviewer.md +5 -5
  53. package/src/prompts/agents/task.md +10 -10
  54. package/src/prompts/commands/orchestrate.md +2 -2
  55. package/src/prompts/compaction/branch-summary.md +3 -3
  56. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  57. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  58. package/src/prompts/compaction/compaction-summary.md +5 -5
  59. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  60. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  61. package/src/prompts/memories/consolidation.md +2 -2
  62. package/src/prompts/memories/read-path.md +1 -1
  63. package/src/prompts/memories/stage_one_input.md +1 -1
  64. package/src/prompts/memories/stage_one_system.md +5 -5
  65. package/src/prompts/review-request.md +4 -4
  66. package/src/prompts/system/agent-creation-architect.md +17 -17
  67. package/src/prompts/system/agent-creation-user.md +2 -2
  68. package/src/prompts/system/commit-message-system.md +2 -2
  69. package/src/prompts/system/custom-system-prompt.md +2 -2
  70. package/src/prompts/system/eager-todo.md +6 -6
  71. package/src/prompts/system/handoff-document.md +1 -1
  72. package/src/prompts/system/plan-mode-active.md +22 -21
  73. package/src/prompts/system/plan-mode-approved.md +4 -4
  74. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  75. package/src/prompts/system/plan-mode-reference.md +2 -2
  76. package/src/prompts/system/plan-mode-subagent.md +8 -8
  77. package/src/prompts/system/plan-mode-tool-decision-reminder.md +2 -2
  78. package/src/prompts/system/project-prompt.md +4 -4
  79. package/src/prompts/system/subagent-system-prompt.md +7 -7
  80. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  81. package/src/prompts/system/system-prompt.md +72 -71
  82. package/src/prompts/system/ttsr-interrupt.md +1 -1
  83. package/src/prompts/tools/apply-patch.md +1 -1
  84. package/src/prompts/tools/ast-edit.md +3 -3
  85. package/src/prompts/tools/ast-grep.md +3 -3
  86. package/src/prompts/tools/browser.md +3 -3
  87. package/src/prompts/tools/checkpoint.md +3 -3
  88. package/src/prompts/tools/exit-plan-mode.md +2 -2
  89. package/src/prompts/tools/find.md +3 -3
  90. package/src/prompts/tools/github.md +2 -5
  91. package/src/prompts/tools/hashline.md +20 -20
  92. package/src/prompts/tools/image-gen.md +3 -3
  93. package/src/prompts/tools/irc.md +1 -1
  94. package/src/prompts/tools/lsp.md +2 -2
  95. package/src/prompts/tools/patch.md +6 -6
  96. package/src/prompts/tools/read.md +7 -7
  97. package/src/prompts/tools/replace.md +5 -5
  98. package/src/prompts/tools/retain.md +1 -1
  99. package/src/prompts/tools/rewind.md +2 -2
  100. package/src/prompts/tools/search.md +2 -2
  101. package/src/prompts/tools/ssh.md +2 -2
  102. package/src/prompts/tools/task.md +12 -6
  103. package/src/prompts/tools/web-search.md +2 -2
  104. package/src/prompts/tools/write.md +3 -3
  105. package/src/sdk.ts +69 -12
  106. package/src/session/agent-session.ts +231 -22
  107. package/src/session/client-bridge.ts +81 -0
  108. package/src/session/compaction/errors.ts +31 -0
  109. package/src/session/compaction/index.ts +1 -0
  110. package/src/slash-commands/acp-builtins.ts +46 -0
  111. package/src/slash-commands/builtin-registry.ts +699 -116
  112. package/src/slash-commands/helpers/context-report.ts +39 -0
  113. package/src/slash-commands/helpers/format.ts +23 -0
  114. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  115. package/src/slash-commands/helpers/mcp.ts +532 -0
  116. package/src/slash-commands/helpers/parse.ts +85 -0
  117. package/src/slash-commands/helpers/ssh.ts +193 -0
  118. package/src/slash-commands/helpers/todo.ts +279 -0
  119. package/src/slash-commands/helpers/usage-report.ts +91 -0
  120. package/src/slash-commands/types.ts +126 -0
  121. package/src/task/executor.ts +10 -3
  122. package/src/task/index.ts +29 -51
  123. package/src/task/render.ts +6 -3
  124. package/src/task/worktree.ts +170 -239
  125. package/src/tools/bash.ts +176 -2
  126. package/src/tools/browser/tab-supervisor.ts +13 -13
  127. package/src/tools/conflict-detect.ts +6 -6
  128. package/src/tools/fetch.ts +15 -4
  129. package/src/tools/find.ts +19 -1
  130. package/src/tools/gh-renderer.ts +0 -12
  131. package/src/tools/gh.ts +682 -176
  132. package/src/tools/github-cache.ts +548 -0
  133. package/src/tools/index.ts +3 -0
  134. package/src/tools/read.ts +110 -27
  135. package/src/tools/write.ts +23 -1
  136. package/src/tui/code-cell.ts +70 -2
  137. package/src/utils/git.ts +5 -0
  138. package/src/task/isolation-backend.ts +0 -94
@@ -0,0 +1,152 @@
1
+ /**
2
+ * ACP-side `ClientBridge` implementation. Wraps `AgentSideConnection` so the
3
+ * `read`/`write`/`bash`/`edit` tools (and the permission gate in
4
+ * `AgentSession`) can route through the client when it advertises the
5
+ * relevant capabilities at `initialize` time.
6
+ */
7
+ import type {
8
+ PermissionOption as AcpPermissionOption,
9
+ TerminalHandle as AcpTerminalHandle,
10
+ AgentSideConnection,
11
+ ClientCapabilities,
12
+ RequestPermissionRequest,
13
+ ToolCallUpdate,
14
+ } from "@agentclientprotocol/sdk";
15
+ import type {
16
+ ClientBridge,
17
+ ClientBridgeCapabilities,
18
+ ClientBridgeCreateTerminalParams,
19
+ ClientBridgePermissionOption,
20
+ ClientBridgePermissionOutcome,
21
+ ClientBridgePermissionToolCall,
22
+ ClientBridgeTerminalHandle,
23
+ } from "../../session/client-bridge";
24
+
25
+ export function createAcpClientBridge(
26
+ connection: AgentSideConnection,
27
+ sessionId: string,
28
+ clientCapabilities: ClientCapabilities | undefined,
29
+ ): ClientBridge {
30
+ const capabilities: ClientBridgeCapabilities = {
31
+ readTextFile: clientCapabilities?.fs?.readTextFile === true,
32
+ writeTextFile: clientCapabilities?.fs?.writeTextFile === true,
33
+ terminal: clientCapabilities?.terminal === true,
34
+ // Permission requests are always usable on the connection; gating is
35
+ // the agent's policy choice rather than a client capability.
36
+ requestPermission: true,
37
+ };
38
+
39
+ const bridge: ClientBridge = { capabilities };
40
+
41
+ if (capabilities.readTextFile) {
42
+ bridge.readTextFile = async params => {
43
+ const response = await connection.readTextFile({
44
+ sessionId,
45
+ path: params.path,
46
+ ...(typeof params.line === "number" ? { line: params.line } : {}),
47
+ ...(typeof params.limit === "number" ? { limit: params.limit } : {}),
48
+ });
49
+ return response.content;
50
+ };
51
+ }
52
+
53
+ if (capabilities.writeTextFile) {
54
+ bridge.writeTextFile = async params => {
55
+ await connection.writeTextFile({
56
+ sessionId,
57
+ path: params.path,
58
+ content: params.content,
59
+ });
60
+ };
61
+ }
62
+
63
+ if (capabilities.terminal) {
64
+ bridge.createTerminal = (params: ClientBridgeCreateTerminalParams) =>
65
+ createTerminalHandle(connection, sessionId, params);
66
+ }
67
+
68
+ bridge.requestPermission = (toolCall, options, signal) =>
69
+ requestPermission(connection, sessionId, toolCall, options, signal);
70
+
71
+ return bridge;
72
+ }
73
+
74
+ async function createTerminalHandle(
75
+ connection: AgentSideConnection,
76
+ sessionId: string,
77
+ params: ClientBridgeCreateTerminalParams,
78
+ ): Promise<ClientBridgeTerminalHandle> {
79
+ const handle = await connection.createTerminal({
80
+ sessionId,
81
+ command: params.command,
82
+ ...(params.args ? { args: params.args } : {}),
83
+ ...(params.env ? { env: params.env } : {}),
84
+ ...(params.cwd ? { cwd: params.cwd } : {}),
85
+ ...(typeof params.outputByteLimit === "number" ? { outputByteLimit: params.outputByteLimit } : {}),
86
+ });
87
+ return wrapTerminalHandle(handle);
88
+ }
89
+
90
+ function wrapTerminalHandle(handle: AcpTerminalHandle): ClientBridgeTerminalHandle {
91
+ return {
92
+ terminalId: handle.id,
93
+ async currentOutput() {
94
+ const out = await handle.currentOutput();
95
+ return {
96
+ output: out.output,
97
+ truncated: out.truncated,
98
+ exitStatus: out.exitStatus ?? null,
99
+ };
100
+ },
101
+ async waitForExit() {
102
+ const status = await handle.waitForExit();
103
+ return { exitCode: status.exitCode ?? null, signal: status.signal ?? null };
104
+ },
105
+ async kill() {
106
+ await handle.kill();
107
+ },
108
+ async release() {
109
+ await handle.release();
110
+ },
111
+ };
112
+ }
113
+
114
+ async function requestPermission(
115
+ connection: AgentSideConnection,
116
+ sessionId: string,
117
+ toolCall: ClientBridgePermissionToolCall,
118
+ options: ClientBridgePermissionOption[],
119
+ signal: AbortSignal | undefined,
120
+ ): Promise<ClientBridgePermissionOutcome> {
121
+ const update: ToolCallUpdate = {
122
+ toolCallId: toolCall.toolCallId,
123
+ title: toolCall.title,
124
+ ...(toolCall.kind ? { kind: toolCall.kind as ToolCallUpdate["kind"] } : {}),
125
+ ...(toolCall.rawInput !== undefined ? { rawInput: toolCall.rawInput } : {}),
126
+ ...(toolCall.locations ? { locations: toolCall.locations } : {}),
127
+ };
128
+ const acpOptions: AcpPermissionOption[] = options.map(option => ({
129
+ optionId: option.optionId,
130
+ name: option.name,
131
+ kind: option.kind,
132
+ }));
133
+ const request: RequestPermissionRequest = {
134
+ sessionId,
135
+ toolCall: update,
136
+ options: acpOptions,
137
+ };
138
+ if (signal?.aborted) {
139
+ return { outcome: "cancelled" };
140
+ }
141
+ const response = await connection.requestPermission(request);
142
+ const outcome = response.outcome;
143
+ if (outcome.outcome === "cancelled") {
144
+ return { outcome: "cancelled" };
145
+ }
146
+ const matched = options.find(option => option.optionId === outcome.optionId);
147
+ return {
148
+ outcome: "selected",
149
+ optionId: outcome.optionId,
150
+ ...(matched ? { kind: matched.kind } : {}),
151
+ };
152
+ }
@@ -6,10 +6,24 @@ import type {
6
6
  ToolKind,
7
7
  } from "@agentclientprotocol/sdk";
8
8
  import type { AgentSessionEvent } from "../../session/agent-session";
9
+ import { resolveToCwd } from "../../tools/path-utils";
9
10
  import type { TodoStatus } from "../../tools/todo-write";
10
11
 
12
+ interface MessageProgress {
13
+ textEmitted: boolean;
14
+ thoughtEmitted: boolean;
15
+ }
16
+
11
17
  interface AcpEventMapperOptions {
12
18
  getMessageId?: (message: unknown) => string | undefined;
19
+ getMessageProgress?: (message: unknown) => MessageProgress | undefined;
20
+ /**
21
+ * Session cwd. Tool call locations sent to ACP clients must be absolute
22
+ * (the editor host needs them to open or focus files). When provided,
23
+ * the mapper resolves raw `path`/`file`/etc. args against this cwd
24
+ * before emitting `ToolCallLocation` entries.
25
+ */
26
+ cwd?: string;
13
27
  }
14
28
 
15
29
  interface ContentArrayContainer {
@@ -127,6 +141,8 @@ export function mapAgentSessionEventToAcpSessionUpdates(
127
141
  switch (event.type) {
128
142
  case "message_update":
129
143
  return mapAssistantMessageUpdate(event, sessionId, options);
144
+ case "message_end":
145
+ return mapAssistantMessageEnd(event, sessionId, options);
130
146
  case "tool_execution_start": {
131
147
  const update: SessionUpdate = {
132
148
  sessionUpdate: "tool_call",
@@ -136,14 +152,16 @@ export function mapAgentSessionEventToAcpSessionUpdates(
136
152
  status: "pending",
137
153
  rawInput: event.args,
138
154
  };
139
- const locations = extractToolLocations(event.args);
155
+ const locations = extractToolLocations(event.args, options.cwd);
140
156
  if (locations.length > 0) {
141
157
  update.locations = locations;
142
158
  }
143
159
  return [toSessionNotification(sessionId, update)];
144
160
  }
145
161
  case "tool_execution_update": {
146
- const content = extractToolCallContent(event.partialResult);
162
+ const terminalContent = extractTerminalToolCallContent(event.partialResult);
163
+ const otherContent = terminalContent.length > 0 ? [] : extractToolCallContent(event.partialResult);
164
+ const content = [...terminalContent, ...otherContent];
147
165
  const update: SessionUpdate = {
148
166
  sessionUpdate: "tool_call_update",
149
167
  toolCallId: event.toolCallId,
@@ -153,10 +171,17 @@ export function mapAgentSessionEventToAcpSessionUpdates(
153
171
  if (content.length > 0) {
154
172
  update.content = content;
155
173
  }
174
+ const locations = extractToolLocations(event.args, options.cwd);
175
+ if (locations.length > 0) {
176
+ update.locations = locations;
177
+ }
156
178
  return [toSessionNotification(sessionId, update)];
157
179
  }
158
180
  case "tool_execution_end": {
159
- const content = extractToolCallContent(event.result);
181
+ const diffContent = extractDiffToolCallContent(event.result);
182
+ const terminalContent = extractTerminalToolCallContent(event.result);
183
+ const otherContent = extractToolCallContent(event.result);
184
+ const content = [...diffContent, ...terminalContent, ...otherContent];
160
185
  const update: SessionUpdate = {
161
186
  sessionUpdate: "tool_call_update",
162
187
  toolCallId: event.toolCallId,
@@ -166,6 +191,10 @@ export function mapAgentSessionEventToAcpSessionUpdates(
166
191
  if (content.length > 0) {
167
192
  update.content = content;
168
193
  }
194
+ const locations = extractToolLocationsFromResult(event.result, options.cwd);
195
+ if (locations.length > 0) {
196
+ update.locations = locations;
197
+ }
169
198
  return [toSessionNotification(sessionId, update)];
170
199
  }
171
200
  case "todo_reminder": {
@@ -194,14 +223,31 @@ function mapAssistantMessageUpdate(
194
223
 
195
224
  let sessionUpdate: "agent_message_chunk" | "agent_thought_chunk";
196
225
  let text: string;
226
+ const progress = options.getMessageProgress?.(event.message);
197
227
  switch (event.assistantMessageEvent.type) {
198
228
  case "text_delta":
199
229
  sessionUpdate = "agent_message_chunk";
200
230
  text = event.assistantMessageEvent.delta;
231
+ if (text.length > 0 && progress) {
232
+ progress.textEmitted = true;
233
+ }
201
234
  break;
202
235
  case "thinking_delta":
203
236
  sessionUpdate = "agent_thought_chunk";
204
237
  text = event.assistantMessageEvent.delta;
238
+ if (text.length > 0 && progress) {
239
+ progress.thoughtEmitted = true;
240
+ }
241
+ break;
242
+ case "done":
243
+ if (progress?.textEmitted) {
244
+ return [];
245
+ }
246
+ sessionUpdate = "agent_message_chunk";
247
+ text = extractAssistantMessageText(event.assistantMessageEvent.message);
248
+ if (text.length > 0 && progress) {
249
+ progress.textEmitted = true;
250
+ }
205
251
  break;
206
252
  case "error":
207
253
  sessionUpdate = "agent_message_chunk";
@@ -224,6 +270,33 @@ function mapAssistantMessageUpdate(
224
270
  ];
225
271
  }
226
272
 
273
+ function mapAssistantMessageEnd(
274
+ event: Extract<AgentSessionEvent, { type: "message_end" }>,
275
+ sessionId: string,
276
+ options: AcpEventMapperOptions,
277
+ ): SessionNotification[] {
278
+ if (!isAssistantMessage(event.message)) {
279
+ return [];
280
+ }
281
+ const progress = options.getMessageProgress?.(event.message);
282
+ if (!progress || progress.textEmitted) {
283
+ return [];
284
+ }
285
+ const text = extractAssistantMessageText(event.message);
286
+ if (text.length === 0) {
287
+ return [];
288
+ }
289
+ progress.textEmitted = true;
290
+ const messageId = options.getMessageId?.(event.message);
291
+ return [
292
+ toSessionNotification(sessionId, {
293
+ sessionUpdate: "agent_message_chunk",
294
+ content: { type: "text", text },
295
+ messageId,
296
+ }),
297
+ ];
298
+ }
299
+
227
300
  function toSessionNotification(sessionId: string, update: SessionUpdate): SessionNotification {
228
301
  return { sessionId, update };
229
302
  }
@@ -257,26 +330,104 @@ function buildToolTitle(toolName: string, args: unknown, intent: string | undefi
257
330
  return toolName;
258
331
  }
259
332
 
260
- function extractToolLocations(args: unknown): ToolCallLocation[] {
333
+ /**
334
+ * Resolve a single raw path against cwd for an ACP location. When `cwd` is
335
+ * omitted we pass the value through unchanged (callers without session
336
+ * context, e.g. some legacy entry points and tests); the ACP-side caller
337
+ * always supplies cwd so notifications carry absolute paths.
338
+ */
339
+ function toAcpLocationPath(value: string, cwd?: string): string {
340
+ if (!cwd) return value;
341
+ try {
342
+ return resolveToCwd(value, cwd);
343
+ } catch {
344
+ return value;
345
+ }
346
+ }
347
+
348
+ function extractToolLocations(args: unknown, cwd?: string): ToolCallLocation[] {
261
349
  const locations: ToolCallLocation[] = [];
262
- const path = extractStringProperty<PathContainer>(args, "path");
263
- if (path) {
350
+ const seen = new Set<string>();
351
+ const pushPath = (raw: string | undefined) => {
352
+ if (!raw) return;
353
+ const path = toAcpLocationPath(raw, cwd);
354
+ if (seen.has(path)) return;
355
+ seen.add(path);
264
356
  locations.push({ path });
265
- }
357
+ };
266
358
 
267
- const oldPath = extractStringProperty<OldPathContainer>(args, "oldPath");
268
- if (oldPath && oldPath !== path) {
269
- locations.push({ path: oldPath });
270
- }
359
+ pushPath(extractStringProperty<PathContainer>(args, "path"));
360
+ pushPath(extractStringProperty<OldPathContainer>(args, "oldPath"));
361
+ pushPath(extractStringProperty<NewPathContainer>(args, "newPath"));
271
362
 
272
- const newPath = extractStringProperty<NewPathContainer>(args, "newPath");
273
- if (newPath && newPath !== path && newPath !== oldPath) {
274
- locations.push({ path: newPath });
275
- }
363
+ return locations;
364
+ }
276
365
 
366
+ /** Pull locations from a tool result's details (e.g. EditToolDetails.perFileResults[].path). */
367
+ function extractToolLocationsFromResult(result: unknown, cwd?: string): ToolCallLocation[] {
368
+ if (typeof result !== "object" || result === null) return [];
369
+ const details = (result as { details?: unknown }).details;
370
+ if (typeof details !== "object" || details === null) return [];
371
+ const direct = extractToolLocations(details, cwd);
372
+ const perFile = (details as { perFileResults?: unknown }).perFileResults;
373
+ if (!Array.isArray(perFile)) {
374
+ return direct;
375
+ }
376
+ const seen = new Set(direct.map(loc => loc.path));
377
+ const locations = [...direct];
378
+ for (const entry of perFile) {
379
+ const raw = extractStringProperty<PathContainer>(entry, "path");
380
+ if (!raw) continue;
381
+ const path = toAcpLocationPath(raw, cwd);
382
+ if (seen.has(path)) continue;
383
+ seen.add(path);
384
+ locations.push({ path });
385
+ }
277
386
  return locations;
278
387
  }
279
388
 
389
+ /** Emit a `diff` ToolCallContent for each per-file edit result that carries oldText/newText. */
390
+ function extractDiffToolCallContent(result: unknown): ToolCallContent[] {
391
+ if (typeof result !== "object" || result === null) return [];
392
+ const details = (result as { details?: unknown }).details;
393
+ if (typeof details !== "object" || details === null) return [];
394
+ const blocks: ToolCallContent[] = [];
395
+ const perFile = (details as { perFileResults?: unknown }).perFileResults;
396
+ const entries: unknown[] = Array.isArray(perFile) ? perFile : [details];
397
+ for (const entry of entries) {
398
+ const block = buildDiffContent(entry);
399
+ if (block) blocks.push(block);
400
+ }
401
+ return blocks;
402
+ }
403
+
404
+ function buildDiffContent(entry: unknown): ToolCallContent | undefined {
405
+ if (typeof entry !== "object" || entry === null) return undefined;
406
+ const candidate = entry as { path?: unknown; oldText?: unknown; newText?: unknown; isError?: unknown };
407
+ if (candidate.isError === true) return undefined;
408
+ const path = typeof candidate.path === "string" && candidate.path.length > 0 ? candidate.path : undefined;
409
+ if (!path) return undefined;
410
+ const oldText = typeof candidate.oldText === "string" ? candidate.oldText : undefined;
411
+ const newText = typeof candidate.newText === "string" ? candidate.newText : undefined;
412
+ if (oldText === undefined && newText === undefined) return undefined;
413
+ return {
414
+ type: "diff",
415
+ path,
416
+ oldText: oldText ?? null,
417
+ newText: newText ?? "",
418
+ };
419
+ }
420
+
421
+ /** Emit a `terminal` ToolCallContent when a tool result carries a `details.terminalId` (e.g. bash routed through ACP terminal/*). */
422
+ function extractTerminalToolCallContent(result: unknown): ToolCallContent[] {
423
+ if (typeof result !== "object" || result === null) return [];
424
+ const details = (result as { details?: unknown }).details;
425
+ if (typeof details !== "object" || details === null) return [];
426
+ const terminalId = (details as { terminalId?: unknown }).terminalId;
427
+ if (typeof terminalId !== "string" || terminalId.length === 0) return [];
428
+ return [{ type: "terminal", terminalId }];
429
+ }
430
+
280
431
  function extractToolCallContent(value: unknown): ToolCallContent[] {
281
432
  const richContent = extractStructuredToolCallContent(value);
282
433
  const fallbackText = extractReadableText(value);
@@ -479,6 +630,20 @@ function extractReadableText(value: unknown): string | undefined {
479
630
  return normalizeText(serialized);
480
631
  }
481
632
 
633
+ function extractAssistantMessageText(value: unknown): string {
634
+ if (typeof value !== "object" || value === null || !("content" in value)) {
635
+ return "";
636
+ }
637
+ const content = (value as ContentArrayContainer).content;
638
+ if (!Array.isArray(content)) {
639
+ return "";
640
+ }
641
+ return content
642
+ .map(block => extractStructuredText(block))
643
+ .filter((chunk): chunk is string => typeof chunk === "string" && chunk.length > 0)
644
+ .join("\n");
645
+ }
646
+
482
647
  function extractStructuredText(value: unknown): string | undefined {
483
648
  const text = extractStringProperty<TextLikeContent>(value, "text");
484
649
  if (!text) {
@@ -0,0 +1,37 @@
1
+ export const ACP_TERMINAL_AUTH_FLAG = "--acp-terminal-auth";
2
+
3
+ export interface AcpTerminalAuthArgs {
4
+ args: string[];
5
+ terminalAuth: boolean;
6
+ }
7
+
8
+ export function prepareAcpTerminalAuthArgs(rawArgs: readonly string[]): AcpTerminalAuthArgs {
9
+ const withoutAuthFlag: string[] = [];
10
+ let terminalAuth = false;
11
+ for (const arg of rawArgs) {
12
+ if (arg === ACP_TERMINAL_AUTH_FLAG) {
13
+ terminalAuth = true;
14
+ continue;
15
+ }
16
+ withoutAuthFlag.push(arg);
17
+ }
18
+
19
+ if (!terminalAuth) {
20
+ return { args: withoutAuthFlag, terminalAuth: false };
21
+ }
22
+
23
+ const args: string[] = [];
24
+ for (let i = 0; i < withoutAuthFlag.length; i++) {
25
+ const arg = withoutAuthFlag[i];
26
+ if (arg === "--mode") {
27
+ i++;
28
+ continue;
29
+ }
30
+ if (arg.startsWith("--mode=")) {
31
+ continue;
32
+ }
33
+ args.push(arg);
34
+ }
35
+
36
+ return { args, terminalAuth: true };
37
+ }
@@ -1,10 +1,38 @@
1
1
  import type { Component } from "@oh-my-pi/pi-tui";
2
2
  import { Container, Text } from "@oh-my-pi/pi-tui";
3
+ import { InternalUrlRouter } from "../../internal-urls";
3
4
  import { getLanguageFromPath, theme } from "../../modes/theme/theme";
5
+ import { splitPathAndSel } from "../../tools/path-utils";
4
6
  import { PREVIEW_LIMITS, shortenPath } from "../../tools/render-utils";
5
7
  import { renderCodeCell } from "../../tui";
6
8
  import type { ToolExecutionHandle } from "./tool-execution";
7
9
 
10
+ /**
11
+ * Read calls whose target is resolved through {@link InternalUrlRouter} are
12
+ * rendered as full tool executions (not collapsed into the read group) so the
13
+ * resolved content is visible. `path` is the canonical arg; `file_path` is the
14
+ * legacy alias still tolerated by the read tool schema.
15
+ */
16
+ function readArgsTarget(args: unknown): string | undefined {
17
+ if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
18
+ const record = args as Record<string, unknown>;
19
+ return typeof record.path === "string"
20
+ ? record.path
21
+ : typeof record.file_path === "string"
22
+ ? record.file_path
23
+ : undefined;
24
+ }
25
+
26
+ export function readArgsHaveTarget(args: unknown): boolean {
27
+ return readArgsTarget(args) !== undefined;
28
+ }
29
+
30
+ export function readArgsTargetInternalUrl(args: unknown): boolean {
31
+ const target = readArgsTarget(args);
32
+ if (!target) return false;
33
+ return InternalUrlRouter.instance().canHandle(target);
34
+ }
35
+
8
36
  type ReadRenderArgs = {
9
37
  path?: string;
10
38
  file_path?: string;
@@ -174,7 +202,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
174
202
  * When expanded: shows full content.
175
203
  */
176
204
  #addContentPreview(entry: ReadEntry): void {
177
- const lang = getLanguageFromPath(entry.path);
205
+ const lang = getLanguageFromPath(splitPathAndSel(entry.path).path);
178
206
  const filePath = shortenPath(entry.path);
179
207
  const correctionSuffix = entry.correctedFrom ? ` (corrected from ${shortenPath(entry.correctedFrom)})` : "";
180
208
  const title = filePath ? `Read ${filePath}${correctionSuffix}` : "Read";
@@ -37,6 +37,7 @@ import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown";
37
37
  import { buildToolsMarkdown } from "../../modes/utils/tools-markdown";
38
38
  import type { AsyncJobSnapshotItem } from "../../session/agent-session";
39
39
  import type { AuthStorage } from "../../session/auth-storage";
40
+ import { CompactionCancelledError, type CompactionOutcome } from "../../session/compaction";
40
41
  import type { NewSessionOptions } from "../../session/session-manager";
41
42
  import { outputMeta } from "../../tools/output-meta";
42
43
  import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils";
@@ -1071,16 +1072,16 @@ export class CommandController {
1071
1072
  this.ctx.ui.requestRender();
1072
1073
  }
1073
1074
 
1074
- async handleCompactCommand(customInstructions?: string): Promise<void> {
1075
+ async handleCompactCommand(customInstructions?: string): Promise<CompactionOutcome> {
1075
1076
  const entries = this.ctx.sessionManager.getEntries();
1076
1077
  const messageCount = entries.filter(e => e.type === "message").length;
1077
1078
 
1078
1079
  if (messageCount < 2) {
1079
1080
  this.ctx.showWarning("Nothing to compact (no messages yet)");
1080
- return;
1081
+ return "ok";
1081
1082
  }
1082
1083
 
1083
- await this.executeCompaction(customInstructions, false);
1084
+ return this.executeCompaction(customInstructions, false);
1084
1085
  }
1085
1086
 
1086
1087
  async handleSkillCommand(skillPath: string, args: string): Promise<void> {
@@ -1098,7 +1099,10 @@ export class CommandController {
1098
1099
  }
1099
1100
  }
1100
1101
 
1101
- async executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto = false): Promise<void> {
1102
+ async executeCompaction(
1103
+ customInstructionsOrOptions?: string | CompactOptions,
1104
+ isAuto = false,
1105
+ ): Promise<CompactionOutcome> {
1102
1106
  if (this.ctx.loadingAnimation) {
1103
1107
  this.ctx.loadingAnimation.stop();
1104
1108
  this.ctx.loadingAnimation = undefined;
@@ -1122,6 +1126,7 @@ export class CommandController {
1122
1126
  this.ctx.statusContainer.addChild(compactingLoader);
1123
1127
  this.ctx.ui.requestRender();
1124
1128
 
1129
+ let outcome: CompactionOutcome = "ok";
1125
1130
  try {
1126
1131
  const instructions = typeof customInstructionsOrOptions === "string" ? customInstructionsOrOptions : undefined;
1127
1132
  const options =
@@ -1135,10 +1140,12 @@ export class CommandController {
1135
1140
  this.ctx.statusLine.invalidate();
1136
1141
  this.ctx.updateEditorTopBorder();
1137
1142
  } catch (error) {
1138
- const message = error instanceof Error ? error.message : String(error);
1139
- if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {
1143
+ if (error instanceof CompactionCancelledError) {
1144
+ outcome = "cancelled";
1140
1145
  this.ctx.showError("Compaction cancelled");
1141
1146
  } else {
1147
+ outcome = "failed";
1148
+ const message = error instanceof Error ? error.message : String(error);
1142
1149
  this.ctx.showError(`Compaction failed: ${message}`);
1143
1150
  }
1144
1151
  } finally {
@@ -1147,6 +1154,7 @@ export class CommandController {
1147
1154
  this.ctx.editor.onEscape = originalOnEscape;
1148
1155
  }
1149
1156
  await this.ctx.flushCompactionQueue({ willRetry: false });
1157
+ return outcome;
1150
1158
  }
1151
1159
 
1152
1160
  async handleHandoffCommand(customInstructions?: string): Promise<void> {
@@ -3,7 +3,11 @@ import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
3
3
  import { type Component, Loader, TERMINAL, Text } from "@oh-my-pi/pi-tui";
4
4
  import { settings } from "../../config/settings";
5
5
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
6
- import { ReadToolGroupComponent } from "../../modes/components/read-tool-group";
6
+ import {
7
+ ReadToolGroupComponent,
8
+ readArgsHaveTarget,
9
+ readArgsTargetInternalUrl,
10
+ } from "../../modes/components/read-tool-group";
7
11
  import { TodoReminderComponent } from "../../modes/components/todo-reminder";
8
12
  import { ToolExecutionComponent } from "../../modes/components/tool-execution";
9
13
  import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification";
@@ -274,16 +278,25 @@ export class EventController {
274
278
  for (const content of this.ctx.streamingMessage.content) {
275
279
  if (content.type !== "toolCall") continue;
276
280
  if (content.name === "read") {
277
- this.#trackReadToolCall(content.id, content.arguments);
278
- const component = this.ctx.pendingTools.get(content.id);
279
- if (component) {
280
- component.updateArgs(content.arguments, content.id);
281
- } else {
282
- const group = this.#getReadGroup();
283
- group.updateArgs(content.arguments, content.id);
284
- this.ctx.pendingTools.set(content.id, group);
281
+ if (!readArgsHaveTarget(content.arguments)) {
282
+ // Args still streaming — defer until path is parseable so we can route to the
283
+ // read group (regular files) vs ToolExecutionComponent (internal URLs).
284
+ // Creating either component now would lock the read into the wrong shape.
285
+ continue;
285
286
  }
286
- continue;
287
+ if (!readArgsTargetInternalUrl(content.arguments)) {
288
+ this.#trackReadToolCall(content.id, content.arguments);
289
+ const component = this.ctx.pendingTools.get(content.id);
290
+ if (component) {
291
+ component.updateArgs(content.arguments, content.id);
292
+ } else {
293
+ const group = this.#getReadGroup();
294
+ group.updateArgs(content.arguments, content.id);
295
+ this.ctx.pendingTools.set(content.id, group);
296
+ }
297
+ continue;
298
+ }
299
+ // Internal URL read falls through to ToolExecutionComponent below.
287
300
  }
288
301
 
289
302
  // Preserve the raw partial JSON for renderers that need to surface fields before the JSON object closes.
@@ -384,7 +397,7 @@ export class EventController {
384
397
  async #handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>): Promise<void> {
385
398
  this.#updateWorkingMessageFromIntent(event.intent);
386
399
  if (!this.ctx.pendingTools.has(event.toolCallId)) {
387
- if (event.toolName === "read") {
400
+ if (event.toolName === "read" && readArgsHaveTarget(event.args) && !readArgsTargetInternalUrl(event.args)) {
388
401
  this.#trackReadToolCall(event.toolCallId, event.args);
389
402
  const component = this.ctx.pendingTools.get(event.toolCallId);
390
403
  if (component) {
@@ -119,7 +119,10 @@ export class ExtensionUiController {
119
119
  abort: () => this.ctx.session.abort(),
120
120
  hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0,
121
121
  shutdown: () => {
122
- // Signal shutdown request (will be handled by main loop)
122
+ // Defer the actual teardown to the main loop, which calls
123
+ // `checkShutdownRequested()` at idle boundaries so any queued
124
+ // steering / follow-up messages drain first (see issue #1020).
125
+ this.ctx.shutdownRequested = true;
123
126
  },
124
127
  getContextUsage: () => this.ctx.session.getContextUsage(),
125
128
  compact: instructionsOrOptions => this.#compactSession(instructionsOrOptions),
@@ -356,7 +359,10 @@ export class ExtensionUiController {
356
359
  abort: () => this.ctx.session.abort(),
357
360
  hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0,
358
361
  shutdown: () => {
359
- // Signal shutdown request (will be handled by main loop)
362
+ // Defer the actual teardown to the main loop, which calls
363
+ // `checkShutdownRequested()` at idle boundaries so any queued
364
+ // steering / follow-up messages drain first (see issue #1020).
365
+ this.ctx.shutdownRequested = true;
360
366
  },
361
367
  getContextUsage: () => this.ctx.session.getContextUsage(),
362
368
  compact: instructionsOrOptions => this.#compactSession(instructionsOrOptions),