@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.
- package/CHANGELOG.md +59 -0
- package/dist/cli.js +3160 -3096
- package/dist/types/config/settings-schema.d.ts +41 -13
- package/dist/types/extensibility/skills.d.ts +29 -0
- package/dist/types/modes/components/mcp-add-wizard.d.ts +8 -0
- package/dist/types/modes/components/todo-reminder.d.ts +3 -1
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +9 -0
- package/dist/types/modes/controllers/tool-args-reveal.d.ts +5 -0
- package/dist/types/modes/interactive-mode.d.ts +0 -1
- package/dist/types/modes/skill-command.d.ts +1 -1
- package/dist/types/modes/types.d.ts +0 -1
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/stt/asr-client.d.ts +7 -3
- package/dist/types/stt/index.d.ts +1 -0
- package/dist/types/stt/stt-controller.d.ts +2 -0
- package/dist/types/stt/submit-trigger.d.ts +30 -0
- package/dist/types/task/index.d.ts +1 -1
- package/dist/types/task/types.d.ts +6 -6
- package/dist/types/tiny/models.d.ts +22 -8
- package/package.json +14 -13
- package/scripts/bundle-dist.ts +23 -4
- package/scripts/generate-docs-index.ts +116 -24
- package/src/async/job-manager.ts +27 -3
- package/src/cli/grep-cli.ts +1 -1
- package/src/commit/agentic/agent.ts +1 -1
- package/src/commit/agentic/prompts/system.md +1 -1
- package/src/commit/agentic/tools/analyze-file.ts +2 -2
- package/src/config/model-discovery.ts +118 -76
- package/src/config/settings-schema.ts +15 -1
- package/src/debug/profiler.ts +7 -1
- package/src/extensibility/skills.ts +77 -0
- package/src/internal-urls/docs-index.generated.txt +2 -2
- package/src/lsp/config.ts +17 -3
- package/src/mcp/oauth-flow.ts +35 -8
- package/src/modes/acp/acp-agent.ts +6 -9
- package/src/modes/components/mcp-add-wizard.ts +43 -3
- package/src/modes/components/model-selector.ts +21 -9
- package/src/modes/components/todo-reminder.ts +5 -1
- package/src/modes/controllers/event-controller.ts +40 -15
- package/src/modes/controllers/mcp-command-controller.ts +84 -3
- package/src/modes/controllers/selector-controller.ts +57 -35
- package/src/modes/controllers/tool-args-reveal.ts +12 -0
- package/src/modes/interactive-mode.ts +5 -10
- package/src/modes/rpc/rpc-mode.ts +5 -8
- package/src/modes/skill-command.ts +8 -20
- package/src/modes/types.ts +0 -1
- package/src/prompts/agents/tester.md +107 -0
- package/src/prompts/system/orchestrate-notice.md +2 -2
- package/src/prompts/system/system-prompt.md +2 -5
- package/src/prompts/system/thinking-loop-redirect.md +10 -0
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/task.md +2 -9
- package/src/session/agent-session.ts +53 -18
- package/src/stt/asr-client.ts +87 -27
- package/src/stt/downloader.ts +8 -2
- package/src/stt/index.ts +1 -0
- package/src/stt/stt-controller.ts +31 -2
- package/src/stt/submit-trigger.ts +74 -0
- package/src/task/agents.ts +4 -4
- package/src/task/executor.ts +2 -4
- package/src/task/index.ts +32 -10
- package/src/task/types.ts +5 -5
- package/src/tiny/models.ts +10 -0
- package/src/tools/ast-grep.ts +34 -12
- package/src/tools/grep.ts +11 -8
- package/src/utils/git.ts +22 -1
- 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:
|
|
217
|
-
{ markers:
|
|
218
|
-
{ markers:
|
|
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" },
|
package/src/mcp/oauth-flow.ts
CHANGED
|
@@ -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
|
-
|
|
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 (!
|
|
834
|
+
if (!record.session.skillsSettings?.enableSkillCommands) {
|
|
835
835
|
return false;
|
|
836
836
|
}
|
|
837
|
-
|
|
837
|
+
const parsed = parseSkillInvocation(text);
|
|
838
|
+
if (!parsed) {
|
|
838
839
|
return false;
|
|
839
840
|
}
|
|
840
|
-
const
|
|
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
|
-
//
|
|
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(
|
|
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 (
|
|
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 {
|
|
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(
|
|
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(
|
|
683
|
+
this.#toolArgsReveal.finish(pendingKey);
|
|
653
684
|
renderArgs = content.arguments;
|
|
654
685
|
}
|
|
655
|
-
if (!this.ctx.pendingTools.has(
|
|
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(
|
|
675
|
-
this.#toolArgsReveal.bind(
|
|
705
|
+
this.ctx.pendingTools.set(pendingKey, component);
|
|
706
|
+
this.#toolArgsReveal.bind(pendingKey, component);
|
|
676
707
|
} else {
|
|
677
|
-
const component = this.ctx.pendingTools.get(
|
|
708
|
+
const component = this.ctx.pendingTools.get(pendingKey);
|
|
678
709
|
if (component) {
|
|
679
710
|
component.updateArgs(renderArgs, content.id);
|
|
680
|
-
this.#toolArgsReveal.bind(
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
}
|