@oh-my-pi/pi-coding-agent 16.2.8 → 16.2.11

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 (67) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/cli.js +3160 -3096
  3. package/dist/types/config/settings-schema.d.ts +41 -13
  4. package/dist/types/extensibility/skills.d.ts +29 -0
  5. package/dist/types/modes/components/mcp-add-wizard.d.ts +8 -0
  6. package/dist/types/modes/components/todo-reminder.d.ts +3 -1
  7. package/dist/types/modes/controllers/mcp-command-controller.d.ts +9 -0
  8. package/dist/types/modes/controllers/tool-args-reveal.d.ts +5 -0
  9. package/dist/types/modes/interactive-mode.d.ts +0 -1
  10. package/dist/types/modes/skill-command.d.ts +1 -1
  11. package/dist/types/modes/types.d.ts +0 -1
  12. package/dist/types/session/agent-session.d.ts +1 -1
  13. package/dist/types/stt/asr-client.d.ts +7 -3
  14. package/dist/types/stt/index.d.ts +1 -0
  15. package/dist/types/stt/stt-controller.d.ts +2 -0
  16. package/dist/types/stt/submit-trigger.d.ts +30 -0
  17. package/dist/types/task/index.d.ts +1 -1
  18. package/dist/types/task/types.d.ts +6 -6
  19. package/dist/types/tiny/models.d.ts +22 -8
  20. package/package.json +14 -13
  21. package/scripts/bundle-dist.ts +23 -4
  22. package/scripts/generate-docs-index.ts +116 -24
  23. package/src/async/job-manager.ts +27 -3
  24. package/src/cli/grep-cli.ts +1 -1
  25. package/src/commit/agentic/agent.ts +1 -1
  26. package/src/commit/agentic/prompts/system.md +1 -1
  27. package/src/commit/agentic/tools/analyze-file.ts +2 -2
  28. package/src/config/model-discovery.ts +118 -76
  29. package/src/config/settings-schema.ts +15 -1
  30. package/src/debug/profiler.ts +7 -1
  31. package/src/extensibility/skills.ts +77 -0
  32. package/src/internal-urls/docs-index.generated.txt +2 -2
  33. package/src/lsp/config.ts +17 -3
  34. package/src/mcp/oauth-flow.ts +35 -8
  35. package/src/modes/acp/acp-agent.ts +6 -9
  36. package/src/modes/components/mcp-add-wizard.ts +43 -3
  37. package/src/modes/components/model-selector.ts +21 -9
  38. package/src/modes/components/todo-reminder.ts +5 -1
  39. package/src/modes/controllers/event-controller.ts +40 -15
  40. package/src/modes/controllers/mcp-command-controller.ts +84 -3
  41. package/src/modes/controllers/selector-controller.ts +57 -35
  42. package/src/modes/controllers/tool-args-reveal.ts +12 -0
  43. package/src/modes/interactive-mode.ts +5 -10
  44. package/src/modes/rpc/rpc-mode.ts +5 -8
  45. package/src/modes/skill-command.ts +8 -20
  46. package/src/modes/types.ts +0 -1
  47. package/src/prompts/agents/tester.md +107 -0
  48. package/src/prompts/system/orchestrate-notice.md +2 -2
  49. package/src/prompts/system/system-prompt.md +2 -5
  50. package/src/prompts/system/thinking-loop-redirect.md +10 -0
  51. package/src/prompts/system/workflow-notice.md +1 -1
  52. package/src/prompts/tools/task.md +2 -9
  53. package/src/session/agent-session.ts +53 -18
  54. package/src/stt/asr-client.ts +87 -27
  55. package/src/stt/downloader.ts +8 -2
  56. package/src/stt/index.ts +1 -0
  57. package/src/stt/stt-controller.ts +31 -2
  58. package/src/stt/submit-trigger.ts +74 -0
  59. package/src/task/agents.ts +4 -4
  60. package/src/task/executor.ts +2 -4
  61. package/src/task/index.ts +32 -10
  62. package/src/task/types.ts +5 -5
  63. package/src/tiny/models.ts +10 -0
  64. package/src/tools/ast-grep.ts +34 -12
  65. package/src/tools/grep.ts +11 -8
  66. package/src/utils/git.ts +22 -1
  67. package/src/prompts/agents/oracle.md +0 -54
package/src/lsp/config.ts CHANGED
@@ -209,13 +209,27 @@ export function hasRootMarkers(cwd: string, markers: string[]): boolean {
209
209
  * Local bin directories to check before $PATH, ordered by priority.
210
210
  * Each entry maps a root marker to the bin directory to check.
211
211
  */
212
+ const PYTHON_ROOT_MARKERS = [
213
+ "pyproject.toml",
214
+ "requirements.txt",
215
+ "setup.py",
216
+ "setup.cfg",
217
+ "Pipfile",
218
+ "pyrightconfig.json",
219
+ "ruff.toml",
220
+ ".ruff.toml",
221
+ ];
222
+
212
223
  const LOCAL_BIN_PATHS: Array<{ markers: string[]; binDir: string }> = [
213
224
  // Node.js - check node_modules/.bin/
214
225
  { markers: ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"], binDir: "node_modules/.bin" },
215
226
  // Python - check virtual environment bin directories
216
- { markers: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], binDir: ".venv/bin" },
217
- { markers: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], binDir: "venv/bin" },
218
- { markers: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], binDir: ".env/bin" },
227
+ { markers: PYTHON_ROOT_MARKERS, binDir: ".venv/bin" },
228
+ { markers: PYTHON_ROOT_MARKERS, binDir: ".venv/Scripts" },
229
+ { markers: PYTHON_ROOT_MARKERS, binDir: "venv/bin" },
230
+ { markers: PYTHON_ROOT_MARKERS, binDir: "venv/Scripts" },
231
+ { markers: PYTHON_ROOT_MARKERS, binDir: ".env/bin" },
232
+ { markers: PYTHON_ROOT_MARKERS, binDir: ".env/Scripts" },
219
233
  // Ruby - check vendor bundle and binstubs
220
234
  { markers: ["Gemfile", "Gemfile.lock"], binDir: "vendor/bundle/bin" },
221
235
  { markers: ["Gemfile", "Gemfile.lock"], binDir: "bin" },
@@ -153,14 +153,44 @@ function resolveCallbackHostname(redirectUri: string | undefined): string | unde
153
153
  return parsed.hostname;
154
154
  }
155
155
 
156
+ /**
157
+ * Resolve the client_id MCPOAuthFlow would use without doing any I/O —
158
+ * either the explicitly configured value or one embedded as a query parameter
159
+ * in the authorization URL. Returns `undefined` when no client_id is known
160
+ * statically, which is the trigger for dynamic client registration in
161
+ * {@link MCPOAuthFlow.#tryRegisterClient}.
162
+ */
163
+ function staticClientIdFromConfig(config: MCPOAuthConfig): string | undefined {
164
+ const fromConfig = config.clientId?.trim();
165
+ if (fromConfig) return fromConfig;
166
+ try {
167
+ return new URL(config.authorizationUrl).searchParams.get("client_id") ?? undefined;
168
+ } catch {
169
+ return undefined;
170
+ }
171
+ }
172
+
156
173
  function resolveCallbackOptions(config: MCPOAuthConfig): OAuthCallbackFlowOptions {
157
174
  const redirectUri = resolveRedirectUri(config.redirectUri);
158
175
  validateRedirectConfig(config, redirectUri);
176
+ // When a client_id is already pinned (config-supplied or embedded in the
177
+ // authorization URL), it was registered against a specific redirect URI.
178
+ // Silently advertising a different port at the authorize endpoint would
179
+ // be rejected by providers like Atlassian (HTTP 500 in the browser, local
180
+ // flow hangs until the 5-minute timeout), so fail fast instead.
181
+ //
182
+ // When no client_id is pinned, MCPOAuthFlow will attempt dynamic client
183
+ // registration on demand with whichever loopback URI we actually bound —
184
+ // the provider issues a client_id tied to *that* URI, so the random-port
185
+ // fallback remains safe for first-install DCR flows whose preferred port
186
+ // happens to be occupied.
187
+ const allowPortFallback = staticClientIdFromConfig(config) === undefined;
159
188
  return {
160
189
  preferredPort: resolveCallbackPort(config.callbackPort, redirectUri),
161
190
  callbackPath: resolveCallbackPath(config.callbackPath, redirectUri),
162
191
  callbackHostname: resolveCallbackHostname(redirectUri),
163
192
  redirectUri,
193
+ allowPortFallback,
164
194
  };
165
195
  }
166
196
 
@@ -400,6 +430,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
400
430
  "Content-Type": "application/x-www-form-urlencoded",
401
431
  },
402
432
  body: params.toString(),
433
+ signal: this.ctrl.signal,
403
434
  });
404
435
 
405
436
  if (!response.ok) {
@@ -453,14 +484,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
453
484
  }
454
485
 
455
486
  #resolveClientId(config: MCPOAuthConfig): string | undefined {
456
- const fromConfig = config.clientId?.trim();
457
- if (fromConfig) return fromConfig;
458
-
459
- try {
460
- return new URL(config.authorizationUrl).searchParams.get("client_id") ?? undefined;
461
- } catch {
462
- return undefined;
463
- }
487
+ return staticClientIdFromConfig(config);
464
488
  }
465
489
  #resourceFromAuthorizationUrl(authorizationUrl: string): string | undefined {
466
490
  try {
@@ -495,6 +519,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
495
519
  "Content-Type": "application/json",
496
520
  Accept: "application/json",
497
521
  },
522
+ signal: this.ctrl.signal,
498
523
  body: JSON.stringify({
499
524
  client_name: "oh-my-pi",
500
525
  redirect_uris: [redirectUri],
@@ -560,6 +585,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
560
585
  const response = await this.#fetch(wellKnownUrl, {
561
586
  method: "GET",
562
587
  headers: { Accept: "application/json" },
588
+ signal: this.ctrl.signal,
563
589
  });
564
590
  if (!response.ok) return null;
565
591
  const metadata = (await response.json()) as { registration_endpoint?: string };
@@ -578,6 +604,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
578
604
  method: "GET",
579
605
  redirect: "manual",
580
606
  headers: { Accept: "text/plain,text/html,application/json" },
607
+ signal: this.ctrl.signal,
581
608
  });
582
609
  if (response.status < 400) return;
583
610
  const body = await response.text();
@@ -53,7 +53,7 @@ import {
53
53
  } from "../../extensibility/extensions";
54
54
  import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
55
55
  import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
56
- import { buildSkillPromptMessage } from "../../extensibility/skills";
56
+ import { buildSkillPromptMessage, parseSkillInvocation } from "../../extensibility/skills";
57
57
  import { loadSlashCommands } from "../../extensibility/slash-commands";
58
58
  import { resolveLocalUrlToPath } from "../../internal-urls";
59
59
  import { MCPManager } from "../../mcp/manager";
@@ -831,21 +831,18 @@ export class AcpAgent implements Agent {
831
831
  }
832
832
 
833
833
  async #tryRunSkillCommand(record: ManagedSessionRecord, text: string): Promise<boolean> {
834
- if (!text.startsWith("/skill:")) {
834
+ if (!record.session.skillsSettings?.enableSkillCommands) {
835
835
  return false;
836
836
  }
837
- if (!record.session.skillsSettings?.enableSkillCommands) {
837
+ const parsed = parseSkillInvocation(text);
838
+ if (!parsed) {
838
839
  return false;
839
840
  }
840
- const spaceIndex = text.indexOf(" ");
841
- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
842
- const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
843
- const skillName = commandName.slice("skill:".length);
844
- const skill = record.session.skills.find(candidate => candidate.name === skillName);
841
+ const skill = record.session.skills.find(candidate => candidate.name === parsed.name);
845
842
  if (!skill) {
846
843
  return false;
847
844
  }
848
- const built = await buildSkillPromptMessage(skill, args);
845
+ const built = await buildSkillPromptMessage(skill, parsed.args);
849
846
  await record.session.promptCustomMessage({
850
847
  customType: SKILL_PROMPT_MESSAGE_TYPE,
851
848
  content: built.message,
@@ -63,6 +63,14 @@ export interface MCPAddWizardOAuthResult {
63
63
  interface MCPAddWizardOAuthOptions {
64
64
  serverUrl?: string;
65
65
  resource?: string;
66
+ /**
67
+ * External cancellation source. Aborting it tears down the in-flight OAuth
68
+ * flow and surfaces a neutral cancellation error. The wizard wires its own
69
+ * controller here so Esc cancels the OAuth wait instead of stepping back
70
+ * through the form (the wizard is focused, so the editor's Esc hook does
71
+ * not fire).
72
+ */
73
+ abortSignal?: AbortSignal;
66
74
  }
67
75
 
68
76
  interface WizardState {
@@ -135,6 +143,12 @@ export class MCPAddWizard extends Container {
135
143
  | null = null;
136
144
  #onTestConnectionCallback: ((config: MCPServerConfig) => Promise<void>) | null = null;
137
145
  #onRenderCallback: (() => void) | null = null;
146
+ /**
147
+ * Set while the OAuth callback is in flight; populated by
148
+ * {@link #launchOAuthFlow} and consumed by {@link handleInput} so Esc
149
+ * cancels the OAuth wait instead of stepping back through the form.
150
+ */
151
+ #oauthAbort: AbortController | null = null;
138
152
 
139
153
  constructor(
140
154
  onComplete: (name: string, config: MCPServerConfig, scope: Scope) => void,
@@ -473,6 +487,15 @@ export class MCPAddWizard extends Container {
473
487
  }
474
488
 
475
489
  handleInput(keyData: string): void {
490
+ // While an OAuth callback is being awaited, Esc/Ctrl+C aborts the flow
491
+ // rather than stepping back through the form: the wizard advertises
492
+ // "(Press Esc to cancel)" during the wait, and stepping back would
493
+ // leave the OAuth login orphaned.
494
+ if (this.#oauthAbort && (keyData === "\x03" || matchesAppInterrupt(keyData))) {
495
+ this.#oauthAbort.abort("MCP OAuth flow cancelled by user");
496
+ return;
497
+ }
498
+
476
499
  // Handle Ctrl+C to cancel wizard immediately
477
500
  if (keyData === "\x03") {
478
501
  // Ctrl+C pressed - cancel wizard
@@ -1153,6 +1176,7 @@ export class MCPAddWizard extends Container {
1153
1176
  this.#contentContainer.addChild(new Text(theme.fg("muted", "(Press Esc to cancel)"), 0, 0));
1154
1177
  this.#requestRender();
1155
1178
 
1179
+ this.#oauthAbort = new AbortController();
1156
1180
  try {
1157
1181
  // Call OAuth handler
1158
1182
  const oauthResource = this.#state.oauthResource || (this.#state.transport === "stdio" ? "" : this.#state.url);
@@ -1165,6 +1189,7 @@ export class MCPAddWizard extends Container {
1165
1189
  {
1166
1190
  serverUrl: this.#state.url || undefined,
1167
1191
  resource: oauthResource || undefined,
1192
+ abortSignal: this.#oauthAbort.signal,
1168
1193
  },
1169
1194
  );
1170
1195
 
@@ -1237,16 +1262,29 @@ export class MCPAddWizard extends Container {
1237
1262
  healthPassed ? 1000 : 2000,
1238
1263
  );
1239
1264
  } catch (error) {
1240
- // Show error with options to retry or go back
1265
+ // User cancellation has its own neutral heading + tip; everything else
1266
+ // keeps the "OAuth authentication failed" framing so the existing tips
1267
+ // stay meaningful. Name-matching avoids importing controller types.
1268
+ const cancelled = error instanceof Error && error.name === "MCPOAuthCancelledError";
1241
1269
  const errorMsg = sanitize(error instanceof Error ? error.message : String(error));
1242
1270
  this.#contentContainer.clear();
1243
- this.#contentContainer.addChild(new Text(theme.fg("error", "✗ OAuth authentication failed"), 0, 0));
1271
+ this.#contentContainer.addChild(
1272
+ new Text(
1273
+ cancelled ? theme.fg("muted", "○ OAuth cancelled") : theme.fg("error", "✗ OAuth authentication failed"),
1274
+ 0,
1275
+ 0,
1276
+ ),
1277
+ );
1244
1278
  this.#contentContainer.addChild(new Spacer(1));
1245
1279
  this.#contentContainer.addChild(new Text(errorMsg, 0, 0));
1246
1280
  this.#contentContainer.addChild(new Spacer(1));
1247
1281
 
1248
1282
  // Provide helpful tips based on error type
1249
- if (errorMsg.includes("timeout") || errorMsg.includes("timed out")) {
1283
+ if (cancelled) {
1284
+ this.#contentContainer.addChild(
1285
+ new Text(theme.fg("muted", "Tip: Choose Retry to launch the browser again."), 0, 0),
1286
+ );
1287
+ } else if (errorMsg.includes("timeout") || errorMsg.includes("timed out")) {
1250
1288
  this.#contentContainer.addChild(
1251
1289
  new Text(theme.fg("muted", "Tip: Complete authorization faster next time"), 0, 0),
1252
1290
  );
@@ -1272,6 +1310,8 @@ export class MCPAddWizard extends Container {
1272
1310
  // Set up as a selector step
1273
1311
  this.#selectedIndex = 0;
1274
1312
  this.#currentStep = "oauth-error";
1313
+ } finally {
1314
+ this.#oauthAbort = null;
1275
1315
  }
1276
1316
  }
1277
1317
 
@@ -24,7 +24,12 @@ import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS, MODEL_ROLES } from "../..
24
24
  import type { Settings } from "../../config/settings";
25
25
  import { type ThemeColor, theme } from "../../modes/theme/theme";
26
26
  import { matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
27
- import { AUTO_THINKING, type ConfiguredThinkingLevel, getConfiguredThinkingLevelMetadata } from "../../thinking";
27
+ import {
28
+ AUTO_THINKING,
29
+ type ConfiguredThinkingLevel,
30
+ getConfiguredThinkingLevelMetadata,
31
+ parseConfiguredThinkingLevel,
32
+ } from "../../thinking";
28
33
  import { getTabBarTheme } from "../shared";
29
34
  import { DynamicBorder } from "./dynamic-border";
30
35
 
@@ -342,10 +347,7 @@ export class ModelSelectorComponent extends Container {
342
347
  if (resolved.model) {
343
348
  nextRoles[role] = {
344
349
  model: resolved.model,
345
- thinkingLevel:
346
- resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined
347
- ? resolved.thinkingLevel
348
- : ThinkingLevel.Inherit,
350
+ thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved),
349
351
  autoSelected: false,
350
352
  };
351
353
  }
@@ -363,10 +365,7 @@ export class ModelSelectorComponent extends Container {
363
365
  if (!resolved.model) continue;
364
366
  nextRoles[role] = {
365
367
  model: resolved.model,
366
- thinkingLevel:
367
- resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined
368
- ? resolved.thinkingLevel
369
- : ThinkingLevel.Inherit,
368
+ thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved),
370
369
  autoSelected: true,
371
370
  };
372
371
  }
@@ -1059,6 +1058,19 @@ export class ModelSelectorComponent extends Container {
1059
1058
  );
1060
1059
  }
1061
1060
  }
1061
+ #getResolvedRoleThinkingLevel(
1062
+ role: string,
1063
+ resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ThinkingLevel },
1064
+ ): ConfiguredThinkingLevel {
1065
+ if (resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined) {
1066
+ return resolved.thinkingLevel;
1067
+ }
1068
+ if (role === "default") {
1069
+ return parseConfiguredThinkingLevel(this.#settings.get("defaultThinkingLevel")) ?? ThinkingLevel.Inherit;
1070
+ }
1071
+ return ThinkingLevel.Inherit;
1072
+ }
1073
+
1062
1074
  #getThinkingLevelsForModel(model: Model): ReadonlyArray<ConfiguredThinkingLevel> {
1063
1075
  return [ThinkingLevel.Inherit, ThinkingLevel.Off, AUTO_THINKING, ...getSupportedEfforts(model)];
1064
1076
  }
@@ -3,7 +3,9 @@ import { theme } from "../../modes/theme/theme";
3
3
  import type { TodoItem } from "../../tools/todo";
4
4
 
5
5
  /**
6
- * Component that renders a todo completion reminder notification.
6
+ * Component that renders a todo completion reminder notification, committed into
7
+ * the transcript like a TTSR notification so it stays anchored in history rather
8
+ * than floating above the editor.
7
9
  * Shows when the agent stops with incomplete todos.
8
10
  */
9
11
  export class TodoReminderComponent extends Container {
@@ -16,6 +18,8 @@ export class TodoReminderComponent extends Container {
16
18
  ) {
17
19
  super();
18
20
 
21
+ this.addChild(new Spacer(1));
22
+
19
23
  this.#box = new Box(1, 1, t => theme.inverse(theme.fg("warning", t)));
20
24
  this.#box.setIgnoreTight(true);
21
25
  this.addChild(this.#box);
@@ -72,6 +72,11 @@ export class EventController {
72
72
  // emits one read per completion — does not break it, so a run of consecutive
73
73
  // reads collapses into one group even across completion boundaries.
74
74
  #lastVisibleBlockCount = 0;
75
+ // Bumped on each assistant message_start. Scopes the placeholder key for a
76
+ // not-yet-id'd streamed tool block (see the toolCall loop) so a sealed,
77
+ // undeleted pending entry from an aborted message can never be reused by the
78
+ // next message's same content index.
79
+ #streamTurnNonce = 0;
75
80
  #renderedCustomMessages = new Set<string>();
76
81
  #lastIntent: string | undefined = undefined;
77
82
  #backgroundToolCallIds = new Set<string>();
@@ -401,6 +406,7 @@ export class EventController {
401
406
  this.ctx.ui.requestRender();
402
407
  } else if (event.message.role === "assistant") {
403
408
  this.#lastVisibleBlockCount = 0;
409
+ this.#streamTurnNonce++;
404
410
  this.ctx.streamingComponent = createAssistantMessageComponent(this.ctx);
405
411
  this.ctx.streamingMessage = event.message;
406
412
  this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
@@ -607,9 +613,14 @@ export class EventController {
607
613
  if (this.ctx.streamingMessage.content.some(content => content.type === "toolCall")) {
608
614
  this.ctx.streamingComponent.markTranscriptBlockFinalized();
609
615
  }
610
- for (const content of this.ctx.streamingMessage.content) {
616
+ for (const [contentIndex, content] of this.ctx.streamingMessage.content.entries()) {
611
617
  if (content.type !== "toolCall") continue;
612
618
  if (content.name === "read") {
619
+ // Read groups key by the real id (one group spans several reads),
620
+ // and the owned-dialect parser delivers id + args together when
621
+ // the call closes, so a parseable target without an id never
622
+ // occurs here. Defer if it somehow does.
623
+ if (!content.id) continue;
613
624
  if (!readArgsHaveTarget(content.arguments)) {
614
625
  // Args still streaming — defer until path is parseable so we can route to the
615
626
  // read group (regular files) vs ToolExecutionComponent (internal URLs).
@@ -632,6 +643,26 @@ export class EventController {
632
643
  // Internal URL read falls through to ToolExecutionComponent below.
633
644
  }
634
645
 
646
+ // The owned-dialect tool parser (text-based tool calls for OAuth
647
+ // Anthropic / OpenAI) appends a tool block when it detects the call
648
+ // opening and only fills `id` + arguments once the call's text
649
+ // closes — so the live preview must stream while `content.id` is "".
650
+ // Key the preview by stable content position until the id lands,
651
+ // then migrate the pending entry + reveal state onto the real id
652
+ // (so `tool_execution_*`, which always carries the real id, matches
653
+ // the same component instead of orphaning a blank card). Native
654
+ // structured tool calls — every Gemini call, and Anthropic/OpenAI
655
+ // function calls — carry the id from the first frame, so
656
+ // `pendingKey === content.id` throughout and the migration no-ops.
657
+ const streamKey = `\u0000stream:${this.#streamTurnNonce}:${contentIndex}`;
658
+ const pendingKey = content.id || streamKey;
659
+ if (content.id && this.ctx.pendingTools.has(streamKey)) {
660
+ const migrated = this.ctx.pendingTools.get(streamKey);
661
+ this.ctx.pendingTools.delete(streamKey);
662
+ if (migrated) this.ctx.pendingTools.set(pendingKey, migrated);
663
+ this.#toolArgsReveal.rekey(streamKey, pendingKey);
664
+ }
665
+
635
666
  // Preserve the raw partial JSON only for renderers that need to surface fields before the JSON object closes.
636
667
  // Bash uses this to show inline env assignments during streaming instead of popping them in at completion.
637
668
  // While the JSON is still open, ToolArgsRevealController paces the
@@ -643,16 +674,16 @@ export class EventController {
643
674
  const rawInput = content.customWireName !== undefined;
644
675
  const tool = this.ctx.viewSession.getToolByName(content.name);
645
676
  if (partialJson) {
646
- renderArgs = this.#toolArgsReveal.setTarget(content.id, partialJson, {
677
+ renderArgs = this.#toolArgsReveal.setTarget(pendingKey, partialJson, {
647
678
  rawInput,
648
679
  exposeRawPartialJson: exposesRawPartialJson(content.name, rawInput, tool),
649
680
  fullArgs: content.arguments,
650
681
  });
651
682
  } else {
652
- this.#toolArgsReveal.finish(content.id);
683
+ this.#toolArgsReveal.finish(pendingKey);
653
684
  renderArgs = content.arguments;
654
685
  }
655
- if (!this.ctx.pendingTools.has(content.id)) {
686
+ if (!this.ctx.pendingTools.has(pendingKey)) {
656
687
  this.#resolveDisplaceablePoll(content.name);
657
688
  this.#resetReadGroup();
658
689
  const component = new ToolExecutionComponent(
@@ -671,13 +702,13 @@ export class EventController {
671
702
  );
672
703
  component.setExpanded(this.ctx.toolOutputExpanded);
673
704
  this.ctx.chatContainer.addChild(component);
674
- this.ctx.pendingTools.set(content.id, component);
675
- this.#toolArgsReveal.bind(content.id, component);
705
+ this.ctx.pendingTools.set(pendingKey, component);
706
+ this.#toolArgsReveal.bind(pendingKey, component);
676
707
  } else {
677
- const component = this.ctx.pendingTools.get(content.id);
708
+ const component = this.ctx.pendingTools.get(pendingKey);
678
709
  if (component) {
679
710
  component.updateArgs(renderArgs, content.id);
680
- this.#toolArgsReveal.bind(content.id, component);
711
+ this.#toolArgsReveal.bind(pendingKey, component);
681
712
  }
682
713
  }
683
714
  }
@@ -969,13 +1000,9 @@ export class EventController {
969
1000
  }
970
1001
  // Update todo display when todo tool completes
971
1002
  if (event.toolName === "todo" && !event.isError) {
972
- const hadTodoReminder = (this.ctx.todoReminderContainer?.children.length ?? 0) > 0;
973
- this.ctx.todoReminderContainer?.clear();
974
1003
  const details = event.result.details as { phases?: TodoPhase[] } | undefined;
975
1004
  if (details?.phases) {
976
1005
  this.ctx.setTodos(details.phases);
977
- } else if (hadTodoReminder) {
978
- this.ctx.ui.requestRender();
979
1006
  }
980
1007
  } else if (event.toolName === "todo" && event.isError) {
981
1008
  const textContent = event.result.content.find(
@@ -1272,9 +1299,7 @@ export class EventController {
1272
1299
 
1273
1300
  async #handleTodoReminder(event: Extract<AgentSessionEvent, { type: "todo_reminder" }>): Promise<void> {
1274
1301
  const component = new TodoReminderComponent(event.todos, event.attempt, event.maxAttempts);
1275
- this.ctx.todoReminderContainer.clear();
1276
- this.ctx.todoReminderContainer.addChild(component);
1277
- this.ctx.ui.requestRender();
1302
+ this.ctx.present(component);
1278
1303
  }
1279
1304
 
1280
1305
  async #handleTodoAutoClear(_event: Extract<AgentSessionEvent, { type: "todo_auto_clear" }>): Promise<void> {
@@ -62,6 +62,16 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string,
62
62
  }, timeoutMs);
63
63
  return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
64
64
  }
65
+ function raceAbortSignal<T>(promise: Promise<T>, signal: AbortSignal, createError: () => Error): Promise<T> {
66
+ if (signal.aborted) return Promise.reject(createError());
67
+
68
+ const aborted = Promise.withResolvers<never>();
69
+ const onAbort = (): void => aborted.reject(createError());
70
+ signal.addEventListener("abort", onAbort, { once: true });
71
+ return Promise.race([promise, aborted.promise]).finally(() => {
72
+ signal.removeEventListener("abort", onAbort);
73
+ });
74
+ }
65
75
 
66
76
  /** Renders the MCP OAuth fallback URL without hard-wrapping the copy target. */
67
77
  export class MCPAuthorizationLinkPrompt implements Component {
@@ -135,6 +145,22 @@ interface OAuthFlowResult {
135
145
  resource?: string;
136
146
  }
137
147
 
148
+ /**
149
+ * Thrown by {@link MCPCommandController}'s OAuth handler when the user (or a
150
+ * caller-supplied {@link AbortSignal}) cancels the in-flight flow. Distinct
151
+ * from network/timeout failures so callers can surface a neutral
152
+ * "cancelled" status instead of an error banner.
153
+ */
154
+ export class MCPOAuthCancelledError extends Error {
155
+ constructor(message = "OAuth flow cancelled") {
156
+ super(message);
157
+ this.name = "MCPOAuthCancelledError";
158
+ }
159
+ }
160
+
161
+ /** Reason recorded on the OAuth flow's AbortController when the user hits Esc. */
162
+ const MCP_OAUTH_USER_CANCEL_REASON = "MCP OAuth flow cancelled by user";
163
+
138
164
  type MCPAddScope = "user" | "project";
139
165
  type MCPAddTransport = "http" | "sse";
140
166
 
@@ -521,6 +547,10 @@ export class MCPCommandController {
521
547
  userClientSecret: finalConfig.oauth?.clientSecret,
522
548
  });
523
549
  } catch (oauthError) {
550
+ if (oauthError instanceof MCPOAuthCancelledError) {
551
+ this.ctx.showStatus(`Add cancelled for "${parsed.initialName}"`);
552
+ return;
553
+ }
524
554
  this.ctx.showError(
525
555
  `OAuth flow failed for "${parsed.initialName}": ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`,
526
556
  );
@@ -587,6 +617,14 @@ export class MCPCommandController {
587
617
  serverUrl?: string;
588
618
  resource?: string;
589
619
  stripSameOriginResource?: boolean;
620
+ /**
621
+ * External cancellation source: when this signal aborts, the in-flight
622
+ * OAuth flow is torn down and {@link MCPOAuthCancelledError} is thrown.
623
+ * Wizards (which own focus and absorb Esc themselves) pass their own
624
+ * controller here; editor-focused callers rely on the Esc hook
625
+ * installed below instead.
626
+ */
627
+ abortSignal?: AbortSignal;
590
628
  },
591
629
  ): Promise<OAuthFlowResult> {
592
630
  const authStorage = this.ctx.session.modelRegistry.authStorage;
@@ -614,6 +652,26 @@ export class MCPCommandController {
614
652
  }
615
653
  let manualInputClaim: { promise: Promise<string>; clear: (reason?: string) => void } | undefined;
616
654
  const oauthTimeout = new AbortController();
655
+ // User Esc and external aborts route through here; the timeout path sets
656
+ // its own reason and leaves this flag false so the catch can distinguish
657
+ // "user cancelled" (status) from "deadline elapsed" (error).
658
+ let userCancelled = false;
659
+ const requestUserCancel = (reason: string): void => {
660
+ userCancelled = true;
661
+ if (!oauthTimeout.signal.aborted) oauthTimeout.abort(reason);
662
+ };
663
+ const originalOnEscape = this.ctx.editor.onEscape;
664
+ this.ctx.editor.onEscape = () => requestUserCancel(MCP_OAUTH_USER_CANCEL_REASON);
665
+ const externalSignal = opts?.abortSignal;
666
+ const onExternalAbort = (): void => {
667
+ const reason = externalSignal?.reason;
668
+ requestUserCancel(typeof reason === "string" ? reason : MCP_OAUTH_USER_CANCEL_REASON);
669
+ };
670
+ if (externalSignal?.aborted) {
671
+ onExternalAbort();
672
+ } else {
673
+ externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
674
+ }
617
675
  try {
618
676
  // Create OAuth flow
619
677
  const flow = new MCPOAuthFlow(
@@ -641,7 +699,7 @@ export class MCPCommandController {
641
699
  block.addChild(new Spacer(1));
642
700
  block.addChild(
643
701
  new Text(
644
- theme.fg("muted", "Waiting for authorization... (Press Ctrl+C to cancel, 5 minute timeout)"),
702
+ theme.fg("muted", "Waiting for authorization... (Press Esc to cancel, 5 minute timeout)"),
645
703
  1,
646
704
  0,
647
705
  ),
@@ -687,9 +745,18 @@ export class MCPCommandController {
687
745
  },
688
746
  );
689
747
 
690
- // Execute OAuth flow with 5 minute timeout
748
+ const createAbortError = (): Error => {
749
+ const reason = String(oauthTimeout.signal.reason ?? "MCP OAuth flow aborted");
750
+ return userCancelled ? new MCPOAuthCancelledError() : new Error(reason);
751
+ };
752
+ if (oauthTimeout.signal.aborted) throw createAbortError();
753
+
754
+ // Execute OAuth flow with 5 minute timeout. Race the login itself
755
+ // against the abort signal because Esc/external abort may fire before
756
+ // MCPOAuthFlow reaches OAuthCallbackFlow.#waitForCallback, where the
757
+ // underlying callback server normally observes the signal.
691
758
  const credentials = await withTimeout(
692
- flow.login(),
759
+ raceAbortSignal(flow.login(), oauthTimeout.signal, createAbortError),
693
760
  5 * 60 * 1000,
694
761
  "OAuth flow timed out after 5 minutes",
695
762
  () => oauthTimeout.abort("MCP OAuth flow timed out"),
@@ -727,6 +794,14 @@ export class MCPCommandController {
727
794
  resource: flow.resource,
728
795
  };
729
796
  } catch (error) {
797
+ // User-initiated cancel (Esc or external signal) → neutral status, not
798
+ // a failure. Check the flag we set in `requestUserCancel`, not the
799
+ // abort reason: the timeout path also aborts but with a different
800
+ // reason, and we want it to surface as a timeout error below.
801
+ if (userCancelled) {
802
+ throw new MCPOAuthCancelledError();
803
+ }
804
+
730
805
  const errorMsg = error instanceof Error ? error.message : String(error);
731
806
 
732
807
  // Provide helpful error messages based on failure type
@@ -742,6 +817,8 @@ export class MCPCommandController {
742
817
  throw new Error(`OAuth authentication failed: ${errorMsg}`);
743
818
  }
744
819
  } finally {
820
+ this.ctx.editor.onEscape = originalOnEscape;
821
+ externalSignal?.removeEventListener("abort", onExternalAbort);
745
822
  manualInputClaim?.clear("Manual MCP OAuth input cleared");
746
823
  }
747
824
  }
@@ -1629,6 +1706,10 @@ export class MCPCommandController {
1629
1706
  ];
1630
1707
  this.#showMessage(lines.join("\n"));
1631
1708
  } catch (error) {
1709
+ if (error instanceof MCPOAuthCancelledError) {
1710
+ this.ctx.showStatus(`Reauthorization cancelled for "${name}"`);
1711
+ return;
1712
+ }
1632
1713
  this.ctx.showError(`Failed to reauthorize server: ${error instanceof Error ? error.message : String(error)}`);
1633
1714
  }
1634
1715
  }