@oh-my-pi/pi-coding-agent 14.9.9 → 15.0.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.
Files changed (230) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +9 -9
  4. package/scripts/build-binary.ts +5 -0
  5. package/scripts/format-prompts.ts +1 -1
  6. package/src/autoresearch/helpers.ts +17 -0
  7. package/src/autoresearch/tools/log-experiment.ts +9 -17
  8. package/src/autoresearch/tools/run-experiment.ts +2 -17
  9. package/src/capability/skill.ts +7 -0
  10. package/src/cli/args.ts +2 -2
  11. package/src/cli/list-models.ts +1 -1
  12. package/src/cli/shell-cli.ts +3 -13
  13. package/src/cli/update-cli.ts +1 -1
  14. package/src/cli.ts +11 -29
  15. package/src/commands/acp.ts +24 -0
  16. package/src/commands/launch.ts +6 -4
  17. package/src/commit/agentic/prompts/system.md +1 -1
  18. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  19. package/src/commit/analysis/conventional.ts +8 -66
  20. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  21. package/src/commit/pipeline.ts +2 -2
  22. package/src/commit/shared-llm.ts +89 -0
  23. package/src/config/config-file.ts +210 -0
  24. package/src/config/model-equivalence.ts +8 -11
  25. package/src/config/model-registry.ts +13 -2
  26. package/src/config/model-resolver.ts +31 -4
  27. package/src/config/settings-schema.ts +102 -1
  28. package/src/config/settings.ts +1 -1
  29. package/src/config.ts +3 -219
  30. package/src/edit/index.ts +22 -1
  31. package/src/edit/modes/patch.ts +10 -0
  32. package/src/edit/modes/replace.ts +3 -0
  33. package/src/edit/renderer.ts +17 -1
  34. package/src/eval/js/context-manager.ts +1 -1
  35. package/src/eval/js/executor.ts +3 -0
  36. package/src/eval/js/shared/rewrite-imports.ts +122 -50
  37. package/src/eval/js/shared/runtime.ts +31 -4
  38. package/src/eval/js/tool-bridge.ts +43 -21
  39. package/src/eval/py/executor.ts +5 -0
  40. package/src/exa/factory.ts +2 -2
  41. package/src/exa/mcp-client.ts +74 -1
  42. package/src/exec/bash-executor.ts +5 -1
  43. package/src/export/html/template.generated.ts +1 -1
  44. package/src/export/html/template.js +0 -11
  45. package/src/extensibility/extensions/runner.ts +55 -2
  46. package/src/extensibility/extensions/types.ts +98 -221
  47. package/src/extensibility/hooks/types.ts +89 -314
  48. package/src/extensibility/shared-events.ts +343 -0
  49. package/src/extensibility/skills.ts +42 -1
  50. package/src/goals/index.ts +3 -0
  51. package/src/goals/runtime.ts +500 -0
  52. package/src/goals/state.ts +37 -0
  53. package/src/goals/tools/goal-tool.ts +237 -0
  54. package/src/hashline/anchors.ts +2 -2
  55. package/src/hindsight/mental-models.ts +1 -1
  56. package/src/internal-urls/agent-protocol.ts +1 -20
  57. package/src/internal-urls/artifact-protocol.ts +1 -19
  58. package/src/internal-urls/docs-index.generated.ts +9 -10
  59. package/src/internal-urls/index.ts +1 -0
  60. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  61. package/src/internal-urls/registry-helpers.ts +25 -0
  62. package/src/internal-urls/router.ts +6 -3
  63. package/src/internal-urls/types.ts +22 -1
  64. package/src/main.ts +24 -11
  65. package/src/mcp/oauth-flow.ts +20 -0
  66. package/src/modes/acp/acp-agent.ts +412 -71
  67. package/src/modes/acp/acp-client-bridge.ts +152 -0
  68. package/src/modes/acp/acp-event-mapper.ts +180 -15
  69. package/src/modes/acp/terminal-auth.ts +37 -0
  70. package/src/modes/components/assistant-message.ts +14 -8
  71. package/src/modes/components/bash-execution.ts +24 -63
  72. package/src/modes/components/custom-message.ts +14 -40
  73. package/src/modes/components/eval-execution.ts +27 -57
  74. package/src/modes/components/execution-shared.ts +102 -0
  75. package/src/modes/components/hook-message.ts +17 -49
  76. package/src/modes/components/mcp-add-wizard.ts +26 -5
  77. package/src/modes/components/message-frame.ts +88 -0
  78. package/src/modes/components/model-selector.ts +1 -1
  79. package/src/modes/components/read-tool-group.ts +29 -1
  80. package/src/modes/components/session-observer-overlay.ts +6 -2
  81. package/src/modes/components/session-selector.ts +1 -1
  82. package/src/modes/components/status-line/segments.ts +55 -4
  83. package/src/modes/components/status-line/types.ts +4 -0
  84. package/src/modes/components/status-line.ts +28 -10
  85. package/src/modes/components/tool-execution.ts +7 -8
  86. package/src/modes/controllers/command-controller-shared.ts +108 -0
  87. package/src/modes/controllers/command-controller.ts +27 -10
  88. package/src/modes/controllers/event-controller.ts +60 -18
  89. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  90. package/src/modes/controllers/input-controller.ts +85 -39
  91. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  92. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  93. package/src/modes/interactive-mode.ts +675 -39
  94. package/src/modes/print-mode.ts +16 -86
  95. package/src/modes/rpc/rpc-mode.ts +30 -88
  96. package/src/modes/runtime-init.ts +115 -0
  97. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  98. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  99. package/src/modes/theme/theme.ts +18 -6
  100. package/src/modes/types.ts +20 -5
  101. package/src/modes/utils/context-usage.ts +13 -13
  102. package/src/modes/utils/ui-helpers.ts +25 -6
  103. package/src/plan-mode/approved-plan.ts +35 -1
  104. package/src/prompts/agents/designer.md +5 -5
  105. package/src/prompts/agents/explore.md +7 -7
  106. package/src/prompts/agents/init.md +9 -9
  107. package/src/prompts/agents/librarian.md +14 -14
  108. package/src/prompts/agents/plan.md +4 -4
  109. package/src/prompts/agents/reviewer.md +5 -5
  110. package/src/prompts/agents/task.md +10 -10
  111. package/src/prompts/commands/orchestrate.md +2 -2
  112. package/src/prompts/compaction/branch-summary.md +3 -3
  113. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  114. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  115. package/src/prompts/compaction/compaction-summary.md +5 -5
  116. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  117. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  118. package/src/prompts/goals/goal-budget-limit.md +16 -0
  119. package/src/prompts/goals/goal-continuation.md +28 -0
  120. package/src/prompts/goals/goal-mode-active.md +23 -0
  121. package/src/prompts/memories/consolidation.md +2 -2
  122. package/src/prompts/memories/read-path.md +1 -1
  123. package/src/prompts/memories/stage_one_input.md +1 -1
  124. package/src/prompts/memories/stage_one_system.md +5 -5
  125. package/src/prompts/review-request.md +4 -4
  126. package/src/prompts/system/agent-creation-architect.md +17 -17
  127. package/src/prompts/system/agent-creation-user.md +2 -2
  128. package/src/prompts/system/commit-message-system.md +2 -2
  129. package/src/prompts/system/custom-system-prompt.md +2 -2
  130. package/src/prompts/system/eager-todo.md +6 -6
  131. package/src/prompts/system/handoff-document.md +1 -1
  132. package/src/prompts/system/plan-mode-active.md +25 -24
  133. package/src/prompts/system/plan-mode-approved.md +4 -4
  134. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  135. package/src/prompts/system/plan-mode-reference.md +2 -2
  136. package/src/prompts/system/plan-mode-subagent.md +8 -8
  137. package/src/prompts/system/plan-mode-tool-decision-reminder.md +3 -3
  138. package/src/prompts/system/project-prompt.md +4 -4
  139. package/src/prompts/system/subagent-system-prompt.md +7 -7
  140. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  141. package/src/prompts/system/system-prompt.md +72 -71
  142. package/src/prompts/system/ttsr-interrupt.md +1 -1
  143. package/src/prompts/tools/apply-patch.md +1 -1
  144. package/src/prompts/tools/ast-edit.md +3 -3
  145. package/src/prompts/tools/ast-grep.md +3 -3
  146. package/src/prompts/tools/bash.md +6 -0
  147. package/src/prompts/tools/browser.md +3 -3
  148. package/src/prompts/tools/checkpoint.md +3 -3
  149. package/src/prompts/tools/find.md +3 -3
  150. package/src/prompts/tools/github.md +2 -5
  151. package/src/prompts/tools/goal.md +13 -0
  152. package/src/prompts/tools/hashline.md +104 -116
  153. package/src/prompts/tools/image-gen.md +3 -3
  154. package/src/prompts/tools/irc.md +1 -1
  155. package/src/prompts/tools/lsp.md +2 -2
  156. package/src/prompts/tools/patch.md +6 -6
  157. package/src/prompts/tools/read.md +8 -7
  158. package/src/prompts/tools/replace.md +5 -5
  159. package/src/prompts/tools/resolve.md +6 -5
  160. package/src/prompts/tools/retain.md +1 -1
  161. package/src/prompts/tools/rewind.md +2 -2
  162. package/src/prompts/tools/search.md +2 -2
  163. package/src/prompts/tools/ssh.md +2 -2
  164. package/src/prompts/tools/task.md +12 -6
  165. package/src/prompts/tools/web-search.md +2 -2
  166. package/src/prompts/tools/write.md +3 -3
  167. package/src/sdk.ts +81 -17
  168. package/src/session/agent-session.ts +656 -125
  169. package/src/session/blob-store.ts +36 -3
  170. package/src/session/client-bridge.ts +81 -0
  171. package/src/session/compaction/errors.ts +31 -0
  172. package/src/session/compaction/index.ts +1 -0
  173. package/src/session/messages.ts +67 -2
  174. package/src/session/session-manager.ts +131 -12
  175. package/src/session/session-storage.ts +33 -15
  176. package/src/session/streaming-output.ts +309 -13
  177. package/src/slash-commands/acp-builtins.ts +46 -0
  178. package/src/slash-commands/builtin-registry.ts +717 -116
  179. package/src/slash-commands/helpers/context-report.ts +39 -0
  180. package/src/slash-commands/helpers/format.ts +23 -0
  181. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  182. package/src/slash-commands/helpers/mcp.ts +532 -0
  183. package/src/slash-commands/helpers/parse.ts +85 -0
  184. package/src/slash-commands/helpers/ssh.ts +193 -0
  185. package/src/slash-commands/helpers/todo.ts +279 -0
  186. package/src/slash-commands/helpers/usage-report.ts +91 -0
  187. package/src/slash-commands/types.ts +126 -0
  188. package/src/ssh/ssh-executor.ts +5 -0
  189. package/src/system-prompt.ts +4 -2
  190. package/src/task/executor.ts +27 -10
  191. package/src/task/index.ts +20 -1
  192. package/src/task/render.ts +27 -18
  193. package/src/task/types.ts +4 -0
  194. package/src/tools/ast-edit.ts +21 -120
  195. package/src/tools/ast-grep.ts +21 -119
  196. package/src/tools/bash-interactive.ts +9 -1
  197. package/src/tools/bash.ts +203 -6
  198. package/src/tools/browser/attach.ts +3 -3
  199. package/src/tools/browser/launch.ts +81 -18
  200. package/src/tools/browser/registry.ts +1 -5
  201. package/src/tools/browser/tab-supervisor.ts +51 -14
  202. package/src/tools/conflict-detect.ts +21 -10
  203. package/src/tools/eval.ts +3 -1
  204. package/src/tools/fetch.ts +15 -4
  205. package/src/tools/find.ts +39 -39
  206. package/src/tools/gh-renderer.ts +0 -12
  207. package/src/tools/gh.ts +689 -182
  208. package/src/tools/github-cache.ts +548 -0
  209. package/src/tools/index.ts +25 -11
  210. package/src/tools/inspect-image.ts +3 -10
  211. package/src/tools/output-meta.ts +176 -37
  212. package/src/tools/path-utils.ts +125 -2
  213. package/src/tools/read.ts +605 -239
  214. package/src/tools/render-utils.ts +92 -0
  215. package/src/tools/renderers.ts +2 -0
  216. package/src/tools/resolve.ts +72 -44
  217. package/src/tools/search.ts +120 -186
  218. package/src/tools/write.ts +67 -10
  219. package/src/tui/code-cell.ts +70 -2
  220. package/src/utils/file-mentions.ts +1 -1
  221. package/src/utils/image-loading.ts +7 -3
  222. package/src/utils/image-resize.ts +32 -43
  223. package/src/vim/parser.ts +0 -17
  224. package/src/vim/render.ts +1 -1
  225. package/src/vim/types.ts +1 -1
  226. package/src/web/search/providers/gemini.ts +35 -95
  227. package/src/prompts/tools/exit-plan-mode.md +0 -6
  228. package/src/tools/exit-plan-mode.ts +0 -97
  229. package/src/utils/fuzzy.ts +0 -108
  230. package/src/utils/image-convert.ts +0 -27
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
4
4
  import { TERMINAL } from "@oh-my-pi/pi-tui";
5
5
  import { formatDuration, formatNumber, getProjectDir, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
6
- import { theme } from "../../../modes/theme/theme";
6
+ import { type ThemeColor, theme } from "../../../modes/theme/theme";
7
7
  import { shortenPath } from "../../../tools/render-utils";
8
8
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
9
9
  import { sanitizeStatusText } from "../../shared";
@@ -76,17 +76,65 @@ const modelSegment: StatusLineSegment = {
76
76
  },
77
77
  };
78
78
 
79
+ function formatGoalBudget(current: number, budget?: number): string {
80
+ const used = formatNumber(current);
81
+ if (budget === undefined) return used;
82
+ return `${used}/${formatNumber(budget)}`;
83
+ }
84
+
85
+ function renderGoalMode(ctx: SegmentContext, mode: { enabled: boolean; paused: boolean }): RenderedSegment {
86
+ const goal = ctx.session.getGoalModeState()?.goal;
87
+ const status = goal?.status ?? (mode.paused ? "paused" : "active");
88
+
89
+ let icon: string = theme.icon.goal;
90
+ let color: ThemeColor = "accent";
91
+ switch (status) {
92
+ case "paused":
93
+ icon = theme.icon.pause || theme.symbol("status.pending");
94
+ color = "warning";
95
+ break;
96
+ case "complete":
97
+ icon = theme.symbol("status.success");
98
+ color = "success";
99
+ break;
100
+ case "budget-limited":
101
+ icon = theme.symbol("status.warning");
102
+ color = "warning";
103
+ break;
104
+ case "dropped":
105
+ icon = theme.symbol("status.aborted");
106
+ color = "dim";
107
+ break;
108
+ default:
109
+ break;
110
+ }
111
+
112
+ const parts: string[] = [withIcon(icon, "Goal")];
113
+ const showBudget = ctx.session.settings.get("goal.statusInFooter") === true;
114
+ if (showBudget && goal) {
115
+ parts.push(formatGoalBudget(goal.tokensUsed, goal.tokenBudget));
116
+ }
117
+ return { content: theme.fg(color, parts.join(" ")), visible: true };
118
+ }
119
+
79
120
  const modeSegment: StatusLineSegment = {
80
121
  id: "mode",
81
122
  render(ctx) {
123
+ const pauseSuffix = theme.icon.pause ? ` ${theme.icon.pause}` : " (paused)";
124
+
82
125
  const plan = ctx.planMode;
83
126
  if (plan && (plan.enabled || plan.paused)) {
84
- const label = plan.paused ? "Plan ⏸" : "Plan";
127
+ const label = plan.paused ? `Plan${pauseSuffix}` : "Plan";
85
128
  const content = withIcon(theme.icon.plan, label);
86
129
  const color = plan.paused ? "warning" : "accent";
87
130
  return { content: theme.fg(color, content), visible: true };
88
131
  }
89
132
 
133
+ const goal = ctx.goalMode;
134
+ if (goal && (goal.enabled || goal.paused)) {
135
+ return renderGoalMode(ctx, goal);
136
+ }
137
+
90
138
  const loop = ctx.loopMode;
91
139
  if (loop?.enabled) {
92
140
  const content = withIcon(theme.icon.loop, "Loop");
@@ -216,8 +264,11 @@ const tokenOutSegment: StatusLineSegment = {
216
264
  const tokenTotalSegment: StatusLineSegment = {
217
265
  id: "token_total",
218
266
  render(ctx) {
219
- const { input, output, cacheRead, cacheWrite } = ctx.usageStats;
220
- const total = input + output + cacheRead + cacheWrite;
267
+ // Excludes cacheRead: that field re-reads the full cached context every
268
+ // turn, making the cumulative sum N×context_size. The dedicated cache_read
269
+ // segment handles cache monitoring; the cost segment handles billing.
270
+ const { input, output, cacheWrite } = ctx.usageStats;
271
+ const total = input + output + cacheWrite;
221
272
  if (!total) return { content: "", visible: false };
222
273
 
223
274
  const content = withIcon(theme.icon.tokens, formatNumber(total));
@@ -27,6 +27,10 @@ export interface SegmentContext {
27
27
  loopMode: {
28
28
  enabled: boolean;
29
29
  } | null;
30
+ goalMode: {
31
+ enabled: boolean;
32
+ paused: boolean;
33
+ } | null;
30
34
  // Cached values for performance (computed once per render)
31
35
  usageStats: {
32
36
  input: number;
@@ -1,5 +1,4 @@
1
1
  import * as fs from "node:fs";
2
- import type { AssistantMessage } from "@oh-my-pi/pi-ai";
3
2
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
4
3
  import { formatCount, getProjectDir } from "@oh-my-pi/pi-utils";
5
4
  import { $ } from "bun";
@@ -7,10 +6,10 @@ import { settings } from "../../config/settings";
7
6
  import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema";
8
7
  import { theme } from "../../modes/theme/theme";
9
8
  import type { AgentSession } from "../../session/agent-session";
10
- import { calculatePromptTokens } from "../../session/compaction/compaction";
11
9
  import * as git from "../../utils/git";
12
10
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../utils/session-color";
13
11
  import { sanitizeStatusText } from "../shared";
12
+ import { computeContextBreakdown } from "../utils/context-usage";
14
13
  import {
15
14
  canReuseCachedPr,
16
15
  createPrCacheContext,
@@ -59,6 +58,7 @@ export class StatusLineComponent implements Component {
59
58
  #sessionStartTime: number = Date.now();
60
59
  #planModeStatus: { enabled: boolean; paused: boolean } | null = null;
61
60
  #loopModeStatus: { enabled: boolean } | null = null;
61
+ #goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
62
62
 
63
63
  // Git status caching (1s TTL)
64
64
  #cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
@@ -73,6 +73,10 @@ export class StatusLineComponent implements Component {
73
73
  #lastTokensPerSecond: number | null = null;
74
74
  #lastTokensPerSecondTimestamp: number | null = null;
75
75
 
76
+ // Context breakdown caching (2s TTL — aligns with /context command output)
77
+ #cachedBreakdown: { usedTokens: number; contextWindow: number } | null = null;
78
+ #breakdownFetchedAt = 0;
79
+
76
80
  constructor(private readonly session: AgentSession) {
77
81
  this.#settings = {
78
82
  preset: settings.get("statusLine.preset"),
@@ -109,6 +113,10 @@ export class StatusLineComponent implements Component {
109
113
  this.#loopModeStatus = status ?? null;
110
114
  }
111
115
 
116
+ setGoalModeStatus(status: { enabled: boolean; paused: boolean } | undefined): void {
117
+ this.#goalModeStatus = status ?? null;
118
+ }
119
+
112
120
  setHookStatus(key: string, text: string | undefined): void {
113
121
  if (text === undefined) {
114
122
  this.#hookStatuses.delete(key);
@@ -301,6 +309,19 @@ export class StatusLineComponent implements Component {
301
309
  return null;
302
310
  }
303
311
 
312
+ #getCachedContextBreakdown(): { usedTokens: number; contextWindow: number } {
313
+ const now = Date.now();
314
+ if (!this.#cachedBreakdown || now - this.#breakdownFetchedAt > 2_000) {
315
+ const breakdown = computeContextBreakdown(this.session);
316
+ this.#cachedBreakdown = {
317
+ usedTokens: breakdown.usedTokens,
318
+ contextWindow: breakdown.contextWindow,
319
+ };
320
+ this.#breakdownFetchedAt = now;
321
+ }
322
+ return this.#cachedBreakdown;
323
+ }
324
+
304
325
  #buildSegmentContext(width: number): SegmentContext {
305
326
  const state = this.session.state;
306
327
 
@@ -318,14 +339,10 @@ export class StatusLineComponent implements Component {
318
339
  tokensPerSecond: this.#getTokensPerSecond(),
319
340
  };
320
341
 
321
- // Get context percentage
322
- const lastAssistantMessage = state.messages
323
- .slice()
324
- .reverse()
325
- .find(m => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined;
326
-
327
- const contextTokens = lastAssistantMessage ? calculatePromptTokens(lastAssistantMessage.usage) : 0;
328
- const contextWindow = state.model?.contextWindow || 0;
342
+ // Context usage — aligned with /context command so both surfaces report the same value
343
+ const breakdown = this.#getCachedContextBreakdown();
344
+ const contextTokens = breakdown.usedTokens;
345
+ const contextWindow = breakdown.contextWindow || state.model?.contextWindow || 0;
329
346
  const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
330
347
 
331
348
  return {
@@ -334,6 +351,7 @@ export class StatusLineComponent implements Component {
334
351
  options: this.#resolveSettings().segmentOptions ?? {},
335
352
  planMode: this.#planModeStatus,
336
353
  loopMode: this.#loopModeStatus,
354
+ goalMode: this.#goalModeStatus,
337
355
  usageStats,
338
356
  contextPercent,
339
357
  contextWindow,
@@ -32,7 +32,6 @@ import {
32
32
  import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
33
33
  import { toolRenderers } from "../../tools/renderers";
34
34
  import { renderStatusLine } from "../../tui";
35
- import { convertToPng } from "../../utils/image-convert";
36
35
  import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
37
36
  import { renderDiff } from "./diff";
38
37
 
@@ -295,13 +294,13 @@ export class ToolExecutionComponent extends Container {
295
294
 
296
295
  // Convert async - catch errors from processing
297
296
  const index = i;
298
- convertToPng(img.data, img.mimeType)
299
- .then(converted => {
300
- if (converted) {
301
- this.#convertedImages.set(index, converted);
302
- this.#updateDisplay();
303
- this.#ui.requestRender();
304
- }
297
+ new Bun.Image(Buffer.from(img.data, "base64"))
298
+ .png()
299
+ .toBase64()
300
+ .then(data => {
301
+ this.#convertedImages.set(index, { data, mimeType: "image/png" });
302
+ this.#updateDisplay();
303
+ this.#ui.requestRender();
305
304
  })
306
305
  .catch(() => {
307
306
  // Ignore conversion failures - display will use original image format
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Shared helpers for /mcp and /ssh command controllers.
3
+ *
4
+ * Captures argument parsing, source grouping, and chat-message rendering that
5
+ * was duplicated between mcp-command-controller and ssh-command-controller.
6
+ * Intentionally kept narrow: subcommand routing, help text, success/error
7
+ * wording, and add-flow logic stay in the per-controller files because they
8
+ * diverge in workflow.
9
+ */
10
+ import { Spacer, Text } from "@oh-my-pi/pi-tui";
11
+ import type { SourceMeta } from "../../capability/types";
12
+ import { shortenPath } from "../../tools/render-utils";
13
+ import { DynamicBorder } from "../components/dynamic-border";
14
+ import { parseCommandArgs } from "../shared";
15
+ import type { InteractiveModeContext } from "../types";
16
+
17
+ export type ScopeValue = "project" | "user";
18
+
19
+ export type ScopeFlagResult = { ok: true; scope: ScopeValue } | { ok: false; error: string };
20
+
21
+ /**
22
+ * Validate the value following a `--scope` flag.
23
+ */
24
+ export function readScopeFlag(value: string | undefined): ScopeFlagResult {
25
+ if (!value || (value !== "project" && value !== "user")) {
26
+ return { ok: false, error: "Invalid --scope value. Use project or user." };
27
+ }
28
+ return { ok: true, scope: value };
29
+ }
30
+
31
+ export type RemoveArgs = { name: string | undefined; scope: ScopeValue };
32
+
33
+ export type ParseRemoveResult = { ok: true; value: RemoveArgs } | { ok: false; error: string };
34
+
35
+ /**
36
+ * Parse the argument tail of `/<cmd> remove <name> [--scope project|user]`.
37
+ *
38
+ * `rest` is the text after the subcommand keyword. The caller is responsible
39
+ * for emitting the command-specific "<entity> name required" usage hint when
40
+ * `value.name` is undefined.
41
+ */
42
+ export function parseRemoveArgs(rest: string): ParseRemoveResult {
43
+ const tokens = parseCommandArgs(rest);
44
+
45
+ let name: string | undefined;
46
+ let scope: ScopeValue = "project";
47
+ let i = 0;
48
+
49
+ if (tokens.length > 0 && !tokens[0].startsWith("-")) {
50
+ name = tokens[0];
51
+ i = 1;
52
+ }
53
+
54
+ while (i < tokens.length) {
55
+ const token = tokens[i];
56
+ if (token === "--scope") {
57
+ const r = readScopeFlag(tokens[i + 1]);
58
+ if (!r.ok) return { ok: false, error: r.error };
59
+ scope = r.scope;
60
+ i += 2;
61
+ continue;
62
+ }
63
+ return { ok: false, error: `Unknown option: ${token}` };
64
+ }
65
+
66
+ return { ok: true, value: { name, scope } };
67
+ }
68
+
69
+ /**
70
+ * Group capability-loaded items by their source provider+path, yielding each
71
+ * group with a display-ready `shortPath`.
72
+ */
73
+ export function* groupBySource<T>(
74
+ items: Iterable<T>,
75
+ getSource: (item: T) => SourceMeta,
76
+ ): Iterable<{ providerName: string; shortPath: string; items: T[] }> {
77
+ const groups = new Map<string, T[]>();
78
+ for (const item of items) {
79
+ const src = getSource(item);
80
+ const key = `${src.providerName}|${src.path}`;
81
+ let group = groups.get(key);
82
+ if (!group) {
83
+ group = [];
84
+ groups.set(key, group);
85
+ }
86
+ group.push(item);
87
+ }
88
+ for (const [key, grouped] of groups) {
89
+ const sepIdx = key.indexOf("|");
90
+ yield {
91
+ providerName: key.slice(0, sepIdx),
92
+ shortPath: shortenPath(key.slice(sepIdx + 1)),
93
+ items: grouped,
94
+ };
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Render a message block (DynamicBorder / Text / DynamicBorder) into the chat
100
+ * container and request a render.
101
+ */
102
+ export function showCommandMessage(ctx: InteractiveModeContext, text: string): void {
103
+ ctx.chatContainer.addChild(new Spacer(1));
104
+ ctx.chatContainer.addChild(new DynamicBorder());
105
+ ctx.chatContainer.addChild(new Text(text, 1, 1));
106
+ ctx.chatContainer.addChild(new DynamicBorder());
107
+ ctx.ui.requestRender();
108
+ }
@@ -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";
@@ -255,12 +256,21 @@ export class CommandController {
255
256
  }
256
257
 
257
258
  #copyLastMessage() {
258
- const text = this.ctx.session.getLastAssistantText();
259
- if (!text) {
260
- this.ctx.showError("No agent messages to copy yet.");
259
+ const assistantText = this.ctx.session.getLastAssistantText();
260
+ if (assistantText) {
261
+ this.#doCopy(assistantText, "Copied last agent message to clipboard");
261
262
  return;
262
263
  }
263
- this.#doCopy(text, "Copied last agent message to clipboard");
264
+
265
+ if (!this.ctx.session.hasCopyCandidateAssistantMessage()) {
266
+ const handoffText = this.ctx.session.getLastVisibleHandoffText();
267
+ if (handoffText) {
268
+ this.#doCopy(handoffText, "Copied handoff context to clipboard");
269
+ return;
270
+ }
271
+ }
272
+
273
+ this.ctx.showError("No agent messages to copy yet.");
264
274
  }
265
275
 
266
276
  #copyCode() {
@@ -1071,16 +1081,16 @@ export class CommandController {
1071
1081
  this.ctx.ui.requestRender();
1072
1082
  }
1073
1083
 
1074
- async handleCompactCommand(customInstructions?: string): Promise<void> {
1084
+ async handleCompactCommand(customInstructions?: string): Promise<CompactionOutcome> {
1075
1085
  const entries = this.ctx.sessionManager.getEntries();
1076
1086
  const messageCount = entries.filter(e => e.type === "message").length;
1077
1087
 
1078
1088
  if (messageCount < 2) {
1079
1089
  this.ctx.showWarning("Nothing to compact (no messages yet)");
1080
- return;
1090
+ return "ok";
1081
1091
  }
1082
1092
 
1083
- await this.executeCompaction(customInstructions, false);
1093
+ return this.executeCompaction(customInstructions, false);
1084
1094
  }
1085
1095
 
1086
1096
  async handleSkillCommand(skillPath: string, args: string): Promise<void> {
@@ -1098,7 +1108,10 @@ export class CommandController {
1098
1108
  }
1099
1109
  }
1100
1110
 
1101
- async executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto = false): Promise<void> {
1111
+ async executeCompaction(
1112
+ customInstructionsOrOptions?: string | CompactOptions,
1113
+ isAuto = false,
1114
+ ): Promise<CompactionOutcome> {
1102
1115
  if (this.ctx.loadingAnimation) {
1103
1116
  this.ctx.loadingAnimation.stop();
1104
1117
  this.ctx.loadingAnimation = undefined;
@@ -1122,6 +1135,7 @@ export class CommandController {
1122
1135
  this.ctx.statusContainer.addChild(compactingLoader);
1123
1136
  this.ctx.ui.requestRender();
1124
1137
 
1138
+ let outcome: CompactionOutcome = "ok";
1125
1139
  try {
1126
1140
  const instructions = typeof customInstructionsOrOptions === "string" ? customInstructionsOrOptions : undefined;
1127
1141
  const options =
@@ -1135,10 +1149,12 @@ export class CommandController {
1135
1149
  this.ctx.statusLine.invalidate();
1136
1150
  this.ctx.updateEditorTopBorder();
1137
1151
  } catch (error) {
1138
- const message = error instanceof Error ? error.message : String(error);
1139
- if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {
1152
+ if (error instanceof CompactionCancelledError) {
1153
+ outcome = "cancelled";
1140
1154
  this.ctx.showError("Compaction cancelled");
1141
1155
  } else {
1156
+ outcome = "failed";
1157
+ const message = error instanceof Error ? error.message : String(error);
1142
1158
  this.ctx.showError(`Compaction failed: ${message}`);
1143
1159
  }
1144
1160
  } finally {
@@ -1147,6 +1163,7 @@ export class CommandController {
1147
1163
  this.ctx.editor.onEscape = originalOnEscape;
1148
1164
  }
1149
1165
  await this.ctx.flushCompactionQueue({ willRetry: false });
1166
+ return outcome;
1150
1167
  }
1151
1168
 
1152
1169
  async handleHandoffCommand(customInstructions?: string): Promise<void> {
@@ -3,15 +3,21 @@ 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";
10
14
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
11
15
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
16
+ import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
12
17
  import type { AgentSessionEvent } from "../../session/agent-session";
13
18
  import { calculatePromptTokens } from "../../session/compaction/compaction";
14
- import type { ExitPlanModeDetails } from "../../tools";
19
+ import { isSilentAbort, readPendingDisplayTag } from "../../session/messages";
20
+ import type { ResolveToolDetails } from "../../tools/resolve";
15
21
 
16
22
  type AgentSessionEventKind = AgentSessionEvent["type"];
17
23
 
@@ -57,6 +63,8 @@ export class EventController {
57
63
  todo_auto_clear: e => this.#handleTodoAutoClear(e),
58
64
  irc_message: e => this.#handleIrcMessage(e),
59
65
  notice: e => this.#handleNotice(e),
66
+ thinking_level_changed: async () => {},
67
+ goal_updated: async () => {},
60
68
  } satisfies AgentSessionEventHandlers;
61
69
  }
62
70
 
@@ -174,6 +182,17 @@ export class EventController {
174
182
  this.#renderedCustomMessages.add(signature);
175
183
  this.#resetReadGroup();
176
184
  this.ctx.addMessageToChat(event.message);
185
+ // Tag-keyed pending-bar refresh: when AgentSession.#handleAgentEvent
186
+ // spliced this dequeued custom message out of #steeringMessages /
187
+ // #followUpMessages (it ran before this emit), the array state is
188
+ // already correct — pendingMessagesContainer just needs to be
189
+ // re-rendered to match. Gated on tag presence so non-queued customs
190
+ // (ttsr-injection, irc:*, async-result, hookMessage) skip the
191
+ // rebuild; their dispatch path never registered a pending chip.
192
+ // Mirrors the user-role refresh at the bottom of this function.
193
+ if (event.message.role === "custom" && readPendingDisplayTag(event.message.details)) {
194
+ this.ctx.updatePendingMessagesDisplay();
195
+ }
177
196
  this.ctx.ui.requestRender();
178
197
  } else if (event.message.role === "user") {
179
198
  const textContent = this.ctx.getUserMessageText(event.message);
@@ -274,16 +293,25 @@ export class EventController {
274
293
  for (const content of this.ctx.streamingMessage.content) {
275
294
  if (content.type !== "toolCall") continue;
276
295
  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);
296
+ if (!readArgsHaveTarget(content.arguments)) {
297
+ // Args still streaming — defer until path is parseable so we can route to the
298
+ // read group (regular files) vs ToolExecutionComponent (internal URLs).
299
+ // Creating either component now would lock the read into the wrong shape.
300
+ continue;
285
301
  }
286
- continue;
302
+ if (!readArgsTargetInternalUrl(content.arguments)) {
303
+ this.#trackReadToolCall(content.id, content.arguments);
304
+ const component = this.ctx.pendingTools.get(content.id);
305
+ if (component) {
306
+ component.updateArgs(content.arguments, content.id);
307
+ } else {
308
+ const group = this.#getReadGroup();
309
+ group.updateArgs(content.arguments, content.id);
310
+ this.ctx.pendingTools.set(content.id, group);
311
+ }
312
+ continue;
313
+ }
314
+ // Internal URL read falls through to ToolExecutionComponent below.
287
315
  }
288
316
 
289
317
  // Preserve the raw partial JSON for renderers that need to surface fields before the JSON object closes.
@@ -351,7 +379,15 @@ export class EventController {
351
379
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
352
380
  this.ctx.streamingMessage = event.message;
353
381
  let errorMessage: string | undefined;
354
- if (this.ctx.streamingMessage.stopReason === "aborted" && !this.ctx.session.isTtsrAbortPending) {
382
+ const aborted = this.ctx.streamingMessage.stopReason === "aborted";
383
+ const silentlyAborted = aborted && isSilentAbort(this.ctx.streamingMessage.errorMessage);
384
+ const ttsrSilenced = aborted && this.ctx.session.isTtsrAbortPending;
385
+ if (aborted && !silentlyAborted && !ttsrSilenced) {
386
+ // Real user-cancel / network / provider abort: surface the standard
387
+ // operator-facing label. AgentSession.#handleAgentEvent already stamped
388
+ // SILENT_ABORT_MARKER for the plan-compact transition before this
389
+ // controller ran, so reaching this branch implies the abort was NOT a
390
+ // silent internal transition.
355
391
  const retryAttempt = this.ctx.session.retryAttempt;
356
392
  errorMessage =
357
393
  retryAttempt > 0
@@ -359,7 +395,10 @@ export class EventController {
359
395
  : "Operation aborted";
360
396
  this.ctx.streamingMessage.errorMessage = errorMessage;
361
397
  }
362
- if (this.ctx.session.isTtsrAbortPending && this.ctx.streamingMessage.stopReason === "aborted") {
398
+ if (silentlyAborted || ttsrSilenced) {
399
+ // Silence the streaming render by downgrading stopReason to "stop" for
400
+ // display only — does NOT mutate the persisted message's stopReason
401
+ // (the marker on errorMessage drives replay-side suppression).
363
402
  const msgWithoutAbort = { ...this.ctx.streamingMessage, stopReason: "stop" as const };
364
403
  this.ctx.streamingComponent.updateContent(msgWithoutAbort);
365
404
  } else {
@@ -384,7 +423,7 @@ export class EventController {
384
423
  async #handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>): Promise<void> {
385
424
  this.#updateWorkingMessageFromIntent(event.intent);
386
425
  if (!this.ctx.pendingTools.has(event.toolCallId)) {
387
- if (event.toolName === "read") {
426
+ if (event.toolName === "read" && readArgsHaveTarget(event.args) && !readArgsTargetInternalUrl(event.args)) {
388
427
  this.#trackReadToolCall(event.toolCallId, event.args);
389
428
  const component = this.ctx.pendingTools.get(event.toolCallId);
390
429
  if (component) {
@@ -509,10 +548,13 @@ export class EventController {
509
548
  `Todo update failed${textContent ? `: ${textContent}` : ". Progress may be stale until todo_write succeeds."}`,
510
549
  );
511
550
  }
512
- if (event.toolName === "exit_plan_mode" && !event.isError) {
513
- const details = event.result.details as ExitPlanModeDetails | undefined;
514
- if (details) {
515
- await this.ctx.handleExitPlanModeTool(details);
551
+ if (event.toolName === "resolve" && !event.isError) {
552
+ const details = event.result.details as ResolveToolDetails | undefined;
553
+ if (details?.sourceToolName === "plan_approval" && details.action === "apply") {
554
+ const planDetails = details.sourceResultDetails as PlanApprovalDetails | undefined;
555
+ if (planDetails) {
556
+ await this.ctx.handlePlanApproval(planDetails);
557
+ }
516
558
  }
517
559
  }
518
560
  }
@@ -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),