@oh-my-pi/pi-coding-agent 15.0.0 → 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 (140) hide show
  1. package/CHANGELOG.md +41 -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/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  14. package/src/commit/analysis/conventional.ts +8 -66
  15. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  16. package/src/commit/pipeline.ts +2 -2
  17. package/src/commit/shared-llm.ts +89 -0
  18. package/src/config/config-file.ts +210 -0
  19. package/src/config/model-equivalence.ts +8 -11
  20. package/src/config/model-registry.ts +13 -2
  21. package/src/config/model-resolver.ts +1 -4
  22. package/src/config/settings-schema.ts +71 -1
  23. package/src/config/settings.ts +1 -1
  24. package/src/config.ts +3 -219
  25. package/src/edit/renderer.ts +7 -1
  26. package/src/eval/js/executor.ts +3 -0
  27. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  28. package/src/eval/py/executor.ts +5 -0
  29. package/src/exa/factory.ts +2 -2
  30. package/src/exa/mcp-client.ts +74 -1
  31. package/src/exec/bash-executor.ts +5 -1
  32. package/src/export/html/template.generated.ts +1 -1
  33. package/src/export/html/template.js +0 -11
  34. package/src/extensibility/extensions/runner.ts +1 -1
  35. package/src/extensibility/extensions/types.ts +89 -223
  36. package/src/extensibility/hooks/types.ts +89 -314
  37. package/src/extensibility/shared-events.ts +343 -0
  38. package/src/extensibility/skills.ts +9 -0
  39. package/src/goals/index.ts +3 -0
  40. package/src/goals/runtime.ts +500 -0
  41. package/src/goals/state.ts +37 -0
  42. package/src/goals/tools/goal-tool.ts +237 -0
  43. package/src/hashline/anchors.ts +2 -2
  44. package/src/hindsight/mental-models.ts +1 -1
  45. package/src/internal-urls/agent-protocol.ts +1 -20
  46. package/src/internal-urls/artifact-protocol.ts +1 -19
  47. package/src/internal-urls/docs-index.generated.ts +5 -6
  48. package/src/internal-urls/registry-helpers.ts +25 -0
  49. package/src/main.ts +11 -2
  50. package/src/mcp/oauth-flow.ts +20 -0
  51. package/src/modes/acp/acp-agent.ts +79 -45
  52. package/src/modes/components/assistant-message.ts +14 -8
  53. package/src/modes/components/bash-execution.ts +24 -63
  54. package/src/modes/components/custom-message.ts +14 -40
  55. package/src/modes/components/eval-execution.ts +27 -57
  56. package/src/modes/components/execution-shared.ts +102 -0
  57. package/src/modes/components/hook-message.ts +17 -49
  58. package/src/modes/components/mcp-add-wizard.ts +26 -5
  59. package/src/modes/components/message-frame.ts +88 -0
  60. package/src/modes/components/model-selector.ts +1 -1
  61. package/src/modes/components/session-observer-overlay.ts +6 -2
  62. package/src/modes/components/session-selector.ts +1 -1
  63. package/src/modes/components/status-line/segments.ts +55 -4
  64. package/src/modes/components/status-line/types.ts +4 -0
  65. package/src/modes/components/status-line.ts +28 -10
  66. package/src/modes/components/tool-execution.ts +7 -8
  67. package/src/modes/controllers/command-controller-shared.ts +108 -0
  68. package/src/modes/controllers/command-controller.ts +13 -4
  69. package/src/modes/controllers/event-controller.ts +36 -7
  70. package/src/modes/controllers/input-controller.ts +13 -0
  71. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  72. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  73. package/src/modes/interactive-mode.ts +624 -52
  74. package/src/modes/print-mode.ts +16 -86
  75. package/src/modes/rpc/rpc-mode.ts +14 -87
  76. package/src/modes/runtime-init.ts +115 -0
  77. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  78. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  79. package/src/modes/theme/theme.ts +18 -6
  80. package/src/modes/types.ts +14 -3
  81. package/src/modes/utils/context-usage.ts +13 -13
  82. package/src/modes/utils/ui-helpers.ts +10 -3
  83. package/src/plan-mode/approved-plan.ts +35 -1
  84. package/src/prompts/goals/goal-budget-limit.md +16 -0
  85. package/src/prompts/goals/goal-continuation.md +28 -0
  86. package/src/prompts/goals/goal-mode-active.md +23 -0
  87. package/src/prompts/system/plan-mode-active.md +5 -5
  88. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  89. package/src/prompts/tools/bash.md +6 -0
  90. package/src/prompts/tools/goal.md +13 -0
  91. package/src/prompts/tools/hashline.md +102 -114
  92. package/src/prompts/tools/read.md +1 -0
  93. package/src/prompts/tools/resolve.md +6 -5
  94. package/src/sdk.ts +12 -5
  95. package/src/session/agent-session.ts +428 -106
  96. package/src/session/blob-store.ts +36 -3
  97. package/src/session/messages.ts +67 -2
  98. package/src/session/session-manager.ts +131 -12
  99. package/src/session/session-storage.ts +33 -15
  100. package/src/session/streaming-output.ts +309 -13
  101. package/src/slash-commands/builtin-registry.ts +18 -0
  102. package/src/ssh/ssh-executor.ts +5 -0
  103. package/src/system-prompt.ts +4 -2
  104. package/src/task/executor.ts +17 -7
  105. package/src/task/index.ts +3 -0
  106. package/src/task/render.ts +21 -15
  107. package/src/task/types.ts +4 -0
  108. package/src/tools/ast-edit.ts +21 -120
  109. package/src/tools/ast-grep.ts +21 -119
  110. package/src/tools/bash-interactive.ts +9 -1
  111. package/src/tools/bash.ts +27 -4
  112. package/src/tools/browser/attach.ts +3 -3
  113. package/src/tools/browser/launch.ts +81 -18
  114. package/src/tools/browser/registry.ts +1 -5
  115. package/src/tools/browser/tab-supervisor.ts +51 -14
  116. package/src/tools/conflict-detect.ts +15 -4
  117. package/src/tools/eval.ts +3 -1
  118. package/src/tools/find.ts +20 -38
  119. package/src/tools/gh.ts +7 -6
  120. package/src/tools/index.ts +22 -11
  121. package/src/tools/inspect-image.ts +3 -10
  122. package/src/tools/output-meta.ts +176 -37
  123. package/src/tools/path-utils.ts +125 -2
  124. package/src/tools/read.ts +516 -233
  125. package/src/tools/render-utils.ts +92 -0
  126. package/src/tools/renderers.ts +2 -0
  127. package/src/tools/resolve.ts +72 -44
  128. package/src/tools/search.ts +120 -186
  129. package/src/tools/write.ts +44 -9
  130. package/src/utils/file-mentions.ts +1 -1
  131. package/src/utils/image-loading.ts +7 -3
  132. package/src/utils/image-resize.ts +32 -43
  133. package/src/vim/parser.ts +0 -17
  134. package/src/vim/render.ts +1 -1
  135. package/src/vim/types.ts +1 -1
  136. package/src/web/search/providers/gemini.ts +35 -95
  137. package/src/prompts/tools/exit-plan-mode.md +0 -6
  138. package/src/tools/exit-plan-mode.ts +0 -97
  139. package/src/utils/fuzzy.ts +0 -108
  140. package/src/utils/image-convert.ts +0 -27
@@ -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
+ }
@@ -256,12 +256,21 @@ export class CommandController {
256
256
  }
257
257
 
258
258
  #copyLastMessage() {
259
- const text = this.ctx.session.getLastAssistantText();
260
- if (!text) {
261
- 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");
262
262
  return;
263
263
  }
264
- 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.");
265
274
  }
266
275
 
267
276
  #copyCode() {
@@ -13,9 +13,11 @@ import { ToolExecutionComponent } from "../../modes/components/tool-execution";
13
13
  import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification";
14
14
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
15
15
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
16
+ import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
16
17
  import type { AgentSessionEvent } from "../../session/agent-session";
17
18
  import { calculatePromptTokens } from "../../session/compaction/compaction";
18
- import type { ExitPlanModeDetails } from "../../tools";
19
+ import { isSilentAbort, readPendingDisplayTag } from "../../session/messages";
20
+ import type { ResolveToolDetails } from "../../tools/resolve";
19
21
 
20
22
  type AgentSessionEventKind = AgentSessionEvent["type"];
21
23
 
@@ -61,6 +63,8 @@ export class EventController {
61
63
  todo_auto_clear: e => this.#handleTodoAutoClear(e),
62
64
  irc_message: e => this.#handleIrcMessage(e),
63
65
  notice: e => this.#handleNotice(e),
66
+ thinking_level_changed: async () => {},
67
+ goal_updated: async () => {},
64
68
  } satisfies AgentSessionEventHandlers;
65
69
  }
66
70
 
@@ -178,6 +182,17 @@ export class EventController {
178
182
  this.#renderedCustomMessages.add(signature);
179
183
  this.#resetReadGroup();
180
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
+ }
181
196
  this.ctx.ui.requestRender();
182
197
  } else if (event.message.role === "user") {
183
198
  const textContent = this.ctx.getUserMessageText(event.message);
@@ -364,7 +379,15 @@ export class EventController {
364
379
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
365
380
  this.ctx.streamingMessage = event.message;
366
381
  let errorMessage: string | undefined;
367
- 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.
368
391
  const retryAttempt = this.ctx.session.retryAttempt;
369
392
  errorMessage =
370
393
  retryAttempt > 0
@@ -372,7 +395,10 @@ export class EventController {
372
395
  : "Operation aborted";
373
396
  this.ctx.streamingMessage.errorMessage = errorMessage;
374
397
  }
375
- 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).
376
402
  const msgWithoutAbort = { ...this.ctx.streamingMessage, stopReason: "stop" as const };
377
403
  this.ctx.streamingComponent.updateContent(msgWithoutAbort);
378
404
  } else {
@@ -522,10 +548,13 @@ export class EventController {
522
548
  `Todo update failed${textContent ? `: ${textContent}` : ". Progress may be stale until todo_write succeeds."}`,
523
549
  );
524
550
  }
525
- if (event.toolName === "exit_plan_mode" && !event.isError) {
526
- const details = event.result.details as ExitPlanModeDetails | undefined;
527
- if (details) {
528
- 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
+ }
529
558
  }
530
559
  }
531
560
  }
@@ -443,6 +443,15 @@ export class InputController {
443
443
  args: args || undefined,
444
444
  lineCount: body ? body.split("\n").length : 0,
445
445
  };
446
+ // When the agent is streaming, register the compact slash-form text as
447
+ // the pending-display twin BEFORE dispatching the CustomMessage. The
448
+ // returned tag is embedded in details so AgentSession.#handleAgentEvent
449
+ // can remove the matching display entry when the agent consumes this
450
+ // message (mirrors the user-message dequeue path).
451
+ if (this.ctx.session.isStreaming) {
452
+ const tag = this.ctx.session.enqueueCustomMessageDisplay(text, streamingBehavior);
453
+ details.__pendingDisplayTag = tag;
454
+ }
446
455
  await this.ctx.session.promptCustomMessage(
447
456
  {
448
457
  customType: SKILL_PROMPT_MESSAGE_TYPE,
@@ -453,6 +462,10 @@ export class InputController {
453
462
  },
454
463
  { streamingBehavior },
455
464
  );
465
+ if (this.ctx.session.isStreaming) {
466
+ this.ctx.updatePendingMessagesDisplay();
467
+ this.ctx.ui.requestRender();
468
+ }
456
469
  } catch (err) {
457
470
  this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
458
471
  }
@@ -37,11 +37,11 @@ import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../
37
37
  import type { OAuthCredential } from "../../session/auth-storage";
38
38
  import { shortenPath } from "../../tools/render-utils";
39
39
  import { openPath } from "../../utils/open";
40
- import { DynamicBorder } from "../components/dynamic-border";
41
40
  import { MCPAddWizard } from "../components/mcp-add-wizard";
42
41
  import { parseCommandArgs } from "../shared";
43
42
  import { theme } from "../theme/theme";
44
43
  import type { InteractiveModeContext } from "../types";
44
+ import { groupBySource, parseRemoveArgs, readScopeFlag, showCommandMessage } from "./command-controller-shared";
45
45
 
46
46
  function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
47
47
  const { promise: timeoutPromise, reject } = Promise.withResolvers<T>();
@@ -49,6 +49,22 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string)
49
49
  return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
50
50
  }
51
51
 
52
+ /**
53
+ * Outcome of {@link MCPCommandController}'s OAuth handler.
54
+ *
55
+ * `clientId`/`clientSecret` are populated when the OAuth provider required (or
56
+ * accepted) dynamic client registration; callers MUST persist them alongside
57
+ * `credentialId` so subsequent token refreshes and reauthorizations can reuse
58
+ * the same registered client. Both are also set when the caller pre-supplied a
59
+ * client id via the wizard or `oauth.clientId` in `mcp.json`, in which case the
60
+ * write-back is a no-op.
61
+ */
62
+ interface OAuthFlowResult {
63
+ credentialId: string;
64
+ clientId?: string;
65
+ clientSecret?: string;
66
+ }
67
+
52
68
  type MCPAddScope = "user" | "project";
53
69
  type MCPAddTransport = "http" | "sse";
54
70
 
@@ -207,11 +223,11 @@ export class MCPCommandController {
207
223
  break;
208
224
  }
209
225
  if (argToken === "--scope") {
210
- const value = tokens[i + 1];
211
- if (!value || (value !== "project" && value !== "user")) {
212
- return { scope, error: "Invalid --scope value. Use project or user." };
226
+ const r = readScopeFlag(tokens[i + 1]);
227
+ if (!r.ok) {
228
+ return { scope, error: r.error };
213
229
  }
214
- scope = value;
230
+ scope = r.scope;
215
231
  i += 2;
216
232
  continue;
217
233
  }
@@ -406,7 +422,7 @@ export class MCPCommandController {
406
422
 
407
423
  try {
408
424
  const oauthClientSecret = finalConfig.oauth?.clientSecret ?? "";
409
- const credentialId = await this.#handleOAuthFlow(
425
+ const oauthResult = await this.#handleOAuthFlow(
410
426
  oauth.authorizationUrl,
411
427
  oauth.tokenUrl,
412
428
  oauth.clientId ?? finalConfig.oauth?.clientId ?? "",
@@ -416,14 +432,21 @@ export class MCPCommandController {
416
432
  finalConfig.oauth?.callbackPath,
417
433
  finalConfig.oauth?.redirectUri,
418
434
  );
435
+ const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? finalConfig.oauth?.clientId;
436
+ const persistedClientSecret = oauthResult.clientSecret ?? finalConfig.oauth?.clientSecret;
419
437
  finalConfig = {
420
438
  ...finalConfig,
421
439
  auth: {
422
440
  type: "oauth",
423
- credentialId,
441
+ credentialId: oauthResult.credentialId,
424
442
  tokenUrl: oauth.tokenUrl,
425
- clientId: oauth.clientId ?? finalConfig.oauth?.clientId,
426
- clientSecret: finalConfig.oauth?.clientSecret,
443
+ clientId: persistedClientId,
444
+ clientSecret: persistedClientSecret,
445
+ },
446
+ oauth: {
447
+ ...finalConfig.oauth,
448
+ clientId: persistedClientId ?? finalConfig.oauth?.clientId,
449
+ clientSecret: persistedClientSecret ?? finalConfig.oauth?.clientSecret,
427
450
  },
428
451
  };
429
452
  } catch (oauthError) {
@@ -488,7 +511,7 @@ export class MCPCommandController {
488
511
  callbackPort?: number,
489
512
  callbackPath?: string,
490
513
  redirectUri?: string,
491
- ): Promise<string> {
514
+ ): Promise<OAuthFlowResult> {
492
515
  const authStorage = this.ctx.session.modelRegistry.authStorage;
493
516
  let parsedAuthUrl: URL;
494
517
 
@@ -600,7 +623,11 @@ export class MCPCommandController {
600
623
  // Store under a synthetic provider name
601
624
  await authStorage.set(credentialId, oauthCredential);
602
625
 
603
- return credentialId;
626
+ return {
627
+ credentialId,
628
+ clientId: flow.resolvedClientId,
629
+ clientSecret: flow.registeredClientSecret,
630
+ };
604
631
  } catch (error) {
605
632
  const errorMsg = error instanceof Error ? error.message : String(error);
606
633
 
@@ -984,23 +1011,7 @@ export class MCPCommandController {
984
1011
 
985
1012
  // Show discovered servers (from .claude.json, .cursor/mcp.json, .vscode/mcp.json, etc.)
986
1013
  if (discoveredServers.length > 0) {
987
- // Group by source display name + path
988
- const bySource = new Map<string, typeof discoveredServers>();
989
- for (const entry of discoveredServers) {
990
- const key = `${entry.source.providerName}|${entry.source.path}`;
991
- let group = bySource.get(key);
992
- if (!group) {
993
- group = [];
994
- bySource.set(key, group);
995
- }
996
- group.push(entry);
997
- }
998
-
999
- for (const [key, entries] of bySource) {
1000
- const sepIdx = key.indexOf("|");
1001
- const providerName = key.slice(0, sepIdx);
1002
- const sourcePath = key.slice(sepIdx + 1);
1003
- const shortPath = shortenPath(sourcePath);
1014
+ for (const { providerName, shortPath, items: entries } of groupBySource(discoveredServers, e => e.source)) {
1004
1015
  lines.push(theme.fg("accent", providerName) + theme.fg("muted", ` (${shortPath}):`));
1005
1016
  for (const { name } of entries) {
1006
1017
  const state = this.ctx.mcpManager!.getConnectionStatus(name);
@@ -1037,32 +1048,12 @@ export class MCPCommandController {
1037
1048
  async #handleRemove(text: string): Promise<void> {
1038
1049
  const match = text.match(/^\/mcp\s+(?:remove|rm)\b\s*(.*)$/i);
1039
1050
  const rest = match?.[1]?.trim() ?? "";
1040
- const tokens = parseCommandArgs(rest);
1041
-
1042
- let name: string | undefined;
1043
- let scope: "project" | "user" = "project";
1044
- let i = 0;
1045
-
1046
- if (tokens.length > 0 && !tokens[0].startsWith("-")) {
1047
- name = tokens[0];
1048
- i = 1;
1049
- }
1050
-
1051
- while (i < tokens.length) {
1052
- const token = tokens[i];
1053
- if (token === "--scope") {
1054
- const value = tokens[i + 1];
1055
- if (!value || (value !== "project" && value !== "user")) {
1056
- this.ctx.showError("Invalid --scope value. Use project or user.");
1057
- return;
1058
- }
1059
- scope = value;
1060
- i += 2;
1061
- continue;
1062
- }
1063
- this.ctx.showError(`Unknown option: ${token}`);
1051
+ const parsed = parseRemoveArgs(rest);
1052
+ if (!parsed.ok) {
1053
+ this.ctx.showError(parsed.error);
1064
1054
  return;
1065
1055
  }
1056
+ const { name, scope } = parsed.value;
1066
1057
 
1067
1058
  if (!name) {
1068
1059
  this.ctx.showError("Server name required. Usage: /mcp remove <name> [--scope project|user]");
@@ -1348,7 +1339,7 @@ export class MCPCommandController {
1348
1339
 
1349
1340
  this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
1350
1341
 
1351
- const credentialId = await this.#handleOAuthFlow(
1342
+ const oauthResult = await this.#handleOAuthFlow(
1352
1343
  oauth.authorizationUrl,
1353
1344
  oauth.tokenUrl,
1354
1345
  oauth.clientId ?? found.config.oauth?.clientId ?? "",
@@ -1359,14 +1350,22 @@ export class MCPCommandController {
1359
1350
  found.config.oauth?.redirectUri,
1360
1351
  );
1361
1352
 
1353
+ const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? found.config.oauth?.clientId;
1354
+ const persistedClientSecret = oauthResult.clientSecret ?? (oauthClientSecret || undefined);
1355
+
1362
1356
  const updated: MCPServerConfig = {
1363
1357
  ...baseConfig,
1364
1358
  auth: {
1365
1359
  type: "oauth",
1366
- credentialId,
1360
+ credentialId: oauthResult.credentialId,
1367
1361
  tokenUrl: oauth.tokenUrl,
1368
- clientId: oauth.clientId ?? found.config.oauth?.clientId,
1369
- clientSecret: oauthClientSecret || undefined,
1362
+ clientId: persistedClientId,
1363
+ clientSecret: persistedClientSecret,
1364
+ },
1365
+ oauth: {
1366
+ ...found.config.oauth,
1367
+ clientId: persistedClientId ?? found.config.oauth?.clientId,
1368
+ clientSecret: persistedClientSecret ?? found.config.oauth?.clientSecret,
1370
1369
  },
1371
1370
  };
1372
1371
  await updateMCPServer(found.filePath, name, updated);
@@ -1929,10 +1928,6 @@ export class MCPCommandController {
1929
1928
  * Show a message in the chat
1930
1929
  */
1931
1930
  #showMessage(text: string): void {
1932
- this.ctx.chatContainer.addChild(new Spacer(1));
1933
- this.ctx.chatContainer.addChild(new DynamicBorder());
1934
- this.ctx.chatContainer.addChild(new Text(text, 1, 1));
1935
- this.ctx.chatContainer.addChild(new DynamicBorder());
1936
- this.ctx.ui.requestRender();
1931
+ showCommandMessage(this.ctx, text);
1937
1932
  }
1938
1933
  }