@oh-my-pi/pi-coding-agent 16.1.19 → 16.1.21
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 +25 -0
- package/dist/cli.js +3795 -3760
- package/dist/types/advisor/advise-tool.d.ts +3 -0
- package/dist/types/cli/gallery-cli.d.ts +6 -0
- package/dist/types/commands/gallery.d.ts +1 -1
- package/dist/types/config/service-tier.d.ts +34 -0
- package/dist/types/config/settings-schema.d.ts +36 -33
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
- package/dist/types/mcp/oauth-flow.d.ts +42 -5
- package/dist/types/modes/components/custom-editor.d.ts +32 -0
- package/dist/types/modes/components/tool-execution.d.ts +5 -5
- package/dist/types/modes/controllers/event-controller.d.ts +10 -0
- package/dist/types/modes/controllers/input-controller.d.ts +2 -1
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/task/executor.d.ts +9 -1
- package/dist/types/task/parallel.d.ts +17 -1
- package/dist/types/tools/index.d.ts +3 -1
- package/dist/types/utils/clipboard.d.ts +10 -0
- package/package.json +13 -13
- package/scripts/generate-legacy-pi-bundled-registry.ts +10 -0
- package/src/advisor/__tests__/advisor.test.ts +44 -0
- package/src/advisor/advise-tool.ts +33 -0
- package/src/autolearn/controller.ts +17 -2
- package/src/cli/gallery-cli.ts +31 -2
- package/src/cli/gallery-fixtures/agentic.ts +13 -4
- package/src/commands/gallery.ts +11 -3
- package/src/config/service-tier.ts +87 -0
- package/src/config/settings-schema.ts +48 -23
- package/src/eval/agent-bridge.ts +2 -0
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +1 -1
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +4 -4
- package/src/extensibility/plugins/legacy-pi-compat.ts +193 -5
- package/src/main.ts +1 -0
- package/src/mcp/manager.ts +12 -3
- package/src/mcp/oauth-flow.ts +121 -7
- package/src/mcp/transports/stdio.ts +5 -0
- package/src/modes/components/chat-transcript-builder.ts +31 -0
- package/src/modes/components/custom-editor.test.ts +80 -0
- package/src/modes/components/custom-editor.ts +86 -6
- package/src/modes/components/tool-execution.ts +50 -25
- package/src/modes/controllers/event-controller.ts +57 -8
- package/src/modes/controllers/input-controller.ts +213 -93
- package/src/modes/controllers/mcp-command-controller.ts +18 -2
- package/src/modes/utils/ui-helpers.ts +40 -0
- package/src/prompts/system/autolearn-nudge-autocontinue.md +5 -0
- package/src/prompts/system/autolearn-nudge.md +4 -2
- package/src/prompts/tools/todo.md +1 -1
- package/src/sdk.ts +1 -0
- package/src/session/agent-session.ts +76 -15
- package/src/session/session-history-format.ts +21 -4
- package/src/task/executor.ts +79 -2
- package/src/task/index.ts +11 -6
- package/src/task/parallel.ts +59 -7
- package/src/tools/index.ts +3 -1
- package/src/tools/irc.ts +16 -2
- package/src/tools/todo.ts +20 -10
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/shell-snapshot-fn-env.sh +60 -0
- package/src/utils/shell-snapshot.ts +77 -20
package/src/mcp/manager.ts
CHANGED
|
@@ -1229,8 +1229,15 @@ export class MCPManager {
|
|
|
1229
1229
|
const tokenUrl = material?.tokenUrl;
|
|
1230
1230
|
const clientId = material?.clientId;
|
|
1231
1231
|
const clientSecret = material?.clientSecret;
|
|
1232
|
-
|
|
1233
|
-
|
|
1232
|
+
// `authorizationUrl` only lives on the embedded credential form;
|
|
1233
|
+
// legacy `MCPAuthConfig` rows never carried it. Required to filter
|
|
1234
|
+
// same-origin resource indicators on refresh when the authorize and
|
|
1235
|
+
// token endpoints sit on different origins (issue #3502 review
|
|
1236
|
+
// follow-up).
|
|
1237
|
+
const authorizationUrl = material && "authorizationUrl" in material ? material.authorizationUrl : undefined;
|
|
1238
|
+
const resourceIsFallback =
|
|
1239
|
+
!material?.resource && (config.type === "http" || config.type === "sse") && Boolean(config.url);
|
|
1240
|
+
const resource = material?.resource ?? (resourceIsFallback ? config.url : undefined);
|
|
1234
1241
|
// Proactive refresh: 5-minute buffer before expiry
|
|
1235
1242
|
// Force refresh: on 401/403 auth errors (revoked tokens, clock skew, missing expires)
|
|
1236
1243
|
const REFRESH_BUFFER_MS = 5 * 60_000;
|
|
@@ -1244,6 +1251,7 @@ export class MCPManager {
|
|
|
1244
1251
|
clientId,
|
|
1245
1252
|
clientSecret,
|
|
1246
1253
|
resource,
|
|
1254
|
+
{ authorizationUrl, stripSameOriginResource: resourceIsFallback },
|
|
1247
1255
|
);
|
|
1248
1256
|
// Spread the old credential first so embedded refresh material survives rotation.
|
|
1249
1257
|
const refreshedCredential: MCPStoredOAuthCredential = {
|
|
@@ -1252,7 +1260,8 @@ export class MCPManager {
|
|
|
1252
1260
|
tokenUrl,
|
|
1253
1261
|
clientId,
|
|
1254
1262
|
clientSecret,
|
|
1255
|
-
resource,
|
|
1263
|
+
resource: resourceIsFallback ? undefined : resource,
|
|
1264
|
+
authorizationUrl,
|
|
1256
1265
|
};
|
|
1257
1266
|
await this.#authStorage.set(credentialId, refreshedCredential);
|
|
1258
1267
|
credential = refreshedCredential;
|
package/src/mcp/oauth-flow.ts
CHANGED
|
@@ -62,6 +62,14 @@ export interface MCPStoredOAuthCredential extends OAuthCredential {
|
|
|
62
62
|
clientId?: string;
|
|
63
63
|
clientSecret?: string;
|
|
64
64
|
resource?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Authorization-server URL (the issuer the grant was minted against). Used
|
|
67
|
+
* to filter same-origin resource indicators on refresh: RFC 8414 lets the
|
|
68
|
+
* authorize and token endpoints sit on different origins, so refresh
|
|
69
|
+
* cannot infer the original auth-server origin from `tokenUrl` alone.
|
|
70
|
+
* Unset on legacy credentials minted before issue #3502's fix.
|
|
71
|
+
*/
|
|
72
|
+
authorizationUrl?: string;
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
const DEFAULT_PORT = 3000;
|
|
@@ -169,6 +177,42 @@ function resolveResourceUri(resource: string | undefined): string | undefined {
|
|
|
169
177
|
return trimmed;
|
|
170
178
|
}
|
|
171
179
|
|
|
180
|
+
interface ResourceIndicatorFilterOptions {
|
|
181
|
+
/** Strip any resource URL on the same origin as the authorization server. */
|
|
182
|
+
stripSameOriginResource?: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Drop a redundant fallback resource indicator relative to {@link serverUrl}.
|
|
187
|
+
*
|
|
188
|
+
* Provider-advertised resource indicators are authoritative even when they are
|
|
189
|
+
* origin-only (`https://gateway.example.com`) or path-scoped same-origin
|
|
190
|
+
* (`https://gateway.example.com/my-service/mcp`): servers can use either form
|
|
191
|
+
* as the audience they require for the grant.
|
|
192
|
+
*
|
|
193
|
+
* Plane is stricter for OMP-synthesized fallback resources (e.g. using the
|
|
194
|
+
* configured server URL `https://mcp.plane.so/http/mcp` as `resource`), so
|
|
195
|
+
* fallback callers opt into `stripSameOriginResource`. Provider-advertised
|
|
196
|
+
* `oauth.resource` values and authorization-URL `?resource=` values keep the
|
|
197
|
+
* preserving default.
|
|
198
|
+
*/
|
|
199
|
+
function filterResourceIndicator(
|
|
200
|
+
resource: string | undefined,
|
|
201
|
+
serverUrl: string,
|
|
202
|
+
options: ResourceIndicatorFilterOptions = {},
|
|
203
|
+
): string | undefined {
|
|
204
|
+
if (!resource) return undefined;
|
|
205
|
+
try {
|
|
206
|
+
const origin = new URL(serverUrl).origin;
|
|
207
|
+
const parsedResource = new URL(resource);
|
|
208
|
+
if (parsedResource.origin !== origin) return resource;
|
|
209
|
+
if (options.stripSameOriginResource) return undefined;
|
|
210
|
+
} catch {
|
|
211
|
+
// Malformed serverUrl will fail elsewhere; fall through.
|
|
212
|
+
}
|
|
213
|
+
return resource;
|
|
214
|
+
}
|
|
215
|
+
|
|
172
216
|
export interface MCPOAuthConfig {
|
|
173
217
|
/** Authorization endpoint URL */
|
|
174
218
|
authorizationUrl: string;
|
|
@@ -197,6 +241,13 @@ export interface MCPOAuthConfig {
|
|
|
197
241
|
callbackPath?: string;
|
|
198
242
|
/** MCP resource URI for RFC 8707 resource indicators */
|
|
199
243
|
resource?: string;
|
|
244
|
+
/**
|
|
245
|
+
* True when `resource` was synthesized from the server URL fallback rather
|
|
246
|
+
* than advertised by OAuth/protected-resource metadata. Fallback resources
|
|
247
|
+
* are stripped when same-origin with the authorization server; advertised
|
|
248
|
+
* path-scoped resources are preserved.
|
|
249
|
+
*/
|
|
250
|
+
stripSameOriginResource?: boolean;
|
|
200
251
|
/** Fetch implementation for token exchange and discovery requests. */
|
|
201
252
|
fetch?: FetchImpl;
|
|
202
253
|
}
|
|
@@ -219,8 +270,8 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
219
270
|
super(ctrl, resolveCallbackOptions(config));
|
|
220
271
|
this.#resolvedClientId = this.#resolveClientId(config);
|
|
221
272
|
this.#fetch = config.fetch ?? ctrl.fetch ?? fetch;
|
|
222
|
-
this.#resource =
|
|
223
|
-
config.resource ?? this.#resourceFromAuthorizationUrl(config.authorizationUrl),
|
|
273
|
+
this.#resource = this.#filterResourceIndicator(
|
|
274
|
+
resolveResourceUri(config.resource ?? this.#resourceFromAuthorizationUrl(config.authorizationUrl)),
|
|
224
275
|
);
|
|
225
276
|
}
|
|
226
277
|
|
|
@@ -246,6 +297,15 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
246
297
|
get resource(): string | undefined {
|
|
247
298
|
return this.#resource;
|
|
248
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Authorization-server URL the flow used. Persist alongside the credential
|
|
302
|
+
* so refresh can filter same-origin resource indicators against the issuer's
|
|
303
|
+
* origin even when `tokenUrl` lives on a different origin (RFC 8414 permits
|
|
304
|
+
* the split).
|
|
305
|
+
*/
|
|
306
|
+
get authorizationUrl(): string {
|
|
307
|
+
return this.config.authorizationUrl;
|
|
308
|
+
}
|
|
249
309
|
|
|
250
310
|
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
|
|
251
311
|
if (!this.#resolvedClientId) {
|
|
@@ -271,7 +331,20 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
271
331
|
}
|
|
272
332
|
const existingResource = params.get("resource")?.trim();
|
|
273
333
|
if (existingResource) {
|
|
274
|
-
|
|
334
|
+
// A resource already embedded in the provider's authorization URL is
|
|
335
|
+
// provider-authored, not OMP's server-URL fallback. Preserve same-host
|
|
336
|
+
// values here even when the caller marked its separate
|
|
337
|
+
// `config.resource` as fallback; gateway-hosted MCP servers can use
|
|
338
|
+
// origin-only or path-scoped values as the token audience.
|
|
339
|
+
const filtered = filterResourceIndicator(resolveResourceUri(existingResource), this.config.authorizationUrl);
|
|
340
|
+
if (filtered) {
|
|
341
|
+
this.#resource = filtered;
|
|
342
|
+
} else {
|
|
343
|
+
// Defensive path for future policy additions: when filtering says
|
|
344
|
+
// "omit", drop it from both authorize and token requests.
|
|
345
|
+
params.delete("resource");
|
|
346
|
+
this.#resource = undefined;
|
|
347
|
+
}
|
|
275
348
|
} else if (this.#resource) {
|
|
276
349
|
params.set("resource", this.#resource);
|
|
277
350
|
}
|
|
@@ -395,6 +468,17 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
395
468
|
}
|
|
396
469
|
}
|
|
397
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Drop redundant resource indicators for this authorization server.
|
|
473
|
+
* Provider-advertised path-scoped values are preserved; fallback server-URL
|
|
474
|
+
* values opt into same-origin stripping via `stripSameOriginResource`.
|
|
475
|
+
*/
|
|
476
|
+
#filterResourceIndicator(resource: string | undefined): string | undefined {
|
|
477
|
+
return filterResourceIndicator(resource, this.config.authorizationUrl, {
|
|
478
|
+
stripSameOriginResource: this.config.stripSameOriginResource,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
398
482
|
/**
|
|
399
483
|
* Try OAuth dynamic client registration when provider requires a client_id.
|
|
400
484
|
*/
|
|
@@ -507,6 +591,26 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
507
591
|
}
|
|
508
592
|
}
|
|
509
593
|
|
|
594
|
+
/**
|
|
595
|
+
* Options for {@link refreshMCPOAuthToken}. Carried via the trailing object
|
|
596
|
+
* so positional callers keep working.
|
|
597
|
+
*/
|
|
598
|
+
export interface RefreshMCPOAuthTokenOptions {
|
|
599
|
+
fetch?: FetchImpl;
|
|
600
|
+
/**
|
|
601
|
+
* Authorization-server URL the original grant was minted against. Used to
|
|
602
|
+
* filter same-origin resource indicators on refresh. Defaults to `tokenUrl`'s
|
|
603
|
+
* origin when omitted for legacy credentials.
|
|
604
|
+
*/
|
|
605
|
+
authorizationUrl?: string;
|
|
606
|
+
/**
|
|
607
|
+
* True when the refresh `resource` was synthesized from the server URL
|
|
608
|
+
* fallback because the credential/auth material carried no resource.
|
|
609
|
+
* Preserved advertised resources leave this false/undefined.
|
|
610
|
+
*/
|
|
611
|
+
stripSameOriginResource?: boolean;
|
|
612
|
+
}
|
|
613
|
+
|
|
510
614
|
/**
|
|
511
615
|
* Refresh an MCP OAuth token using the standard refresh_token grant.
|
|
512
616
|
* Returns updated credentials; preserves the old refresh token if the server doesn't rotate it.
|
|
@@ -516,17 +620,27 @@ export async function refreshMCPOAuthToken(
|
|
|
516
620
|
refreshToken: string,
|
|
517
621
|
clientId?: string,
|
|
518
622
|
clientSecret?: string,
|
|
519
|
-
resourceOrOpts?: string |
|
|
520
|
-
opts?:
|
|
623
|
+
resourceOrOpts?: string | RefreshMCPOAuthTokenOptions,
|
|
624
|
+
opts?: RefreshMCPOAuthTokenOptions,
|
|
521
625
|
): Promise<OAuthCredentials> {
|
|
522
|
-
const
|
|
626
|
+
const optsFromTrailing = typeof resourceOrOpts === "string" ? opts : resourceOrOpts;
|
|
627
|
+
const fetchImpl: FetchImpl = optsFromTrailing?.fetch ?? fetch;
|
|
523
628
|
const resource = typeof resourceOrOpts === "string" ? resourceOrOpts : undefined;
|
|
629
|
+
// Filter against the authorization-server origin when known (RFC 8414
|
|
630
|
+
// permits authorize/token endpoints on separate origins). Fall back to
|
|
631
|
+
// `tokenUrl` for legacy credentials minted before the issuer was persisted
|
|
632
|
+
// — same-origin servers (the common case) still match correctly.
|
|
633
|
+
const filterAnchor = optsFromTrailing?.authorizationUrl ?? tokenUrl;
|
|
524
634
|
const params = new URLSearchParams({
|
|
525
635
|
grant_type: "refresh_token",
|
|
526
636
|
refresh_token: refreshToken,
|
|
527
637
|
});
|
|
528
638
|
if (clientId) params.set("client_id", clientId);
|
|
529
|
-
|
|
639
|
+
// Drop redundant indicators so refresh stays consistent with the initial
|
|
640
|
+
// grant; see {@link filterResourceIndicator} for context.
|
|
641
|
+
const resolvedResource = filterResourceIndicator(resolveResourceUri(resource), filterAnchor, {
|
|
642
|
+
stripSameOriginResource: optsFromTrailing?.stripSameOriginResource,
|
|
643
|
+
});
|
|
530
644
|
if (resolvedResource) params.set("resource", resolvedResource);
|
|
531
645
|
if (clientSecret) params.set("client_secret", clientSecret);
|
|
532
646
|
|
|
@@ -340,6 +340,10 @@ export class StdioTransport implements MCPTransport {
|
|
|
340
340
|
platform: process.platform,
|
|
341
341
|
});
|
|
342
342
|
|
|
343
|
+
// Spawn in a new session (detached → setsid) so the MCP process tree has
|
|
344
|
+
// no controlling terminal. Otherwise terminal job-control signals (Ctrl+Z
|
|
345
|
+
// SIGTSTP, background-read SIGTTIN) can stop stdio servers such as
|
|
346
|
+
// chrome-devtools-mcp and leave our read loop blocked on silent pipes.
|
|
343
347
|
this.#process = spawn({
|
|
344
348
|
cmd: spawnCommand.cmd,
|
|
345
349
|
cwd,
|
|
@@ -348,6 +352,7 @@ export class StdioTransport implements MCPTransport {
|
|
|
348
352
|
stdout: "pipe",
|
|
349
353
|
stderr: "pipe",
|
|
350
354
|
windowsHide: spawnCommand.windowsHide,
|
|
355
|
+
detached: true,
|
|
351
356
|
});
|
|
352
357
|
|
|
353
358
|
this.#connected = true;
|
|
@@ -83,6 +83,7 @@ export class ChatTranscriptBuilder {
|
|
|
83
83
|
#pendingUsage: Usage | undefined;
|
|
84
84
|
#lastAssistantUsage: Usage | undefined;
|
|
85
85
|
#waitingPoll: ToolExecutionComponent | null = null;
|
|
86
|
+
#todoSnapshot: ToolExecutionComponent | null = null;
|
|
86
87
|
#expandables: Array<{ setExpanded(expanded: boolean): void }> = [];
|
|
87
88
|
#expanded = false;
|
|
88
89
|
|
|
@@ -128,6 +129,7 @@ export class ChatTranscriptBuilder {
|
|
|
128
129
|
this.#pendingUsage = undefined;
|
|
129
130
|
this.#lastAssistantUsage = undefined;
|
|
130
131
|
this.#waitingPoll = null;
|
|
132
|
+
this.#todoSnapshot = null;
|
|
131
133
|
this.#expandables = [];
|
|
132
134
|
this.container.dispose();
|
|
133
135
|
this.container.clear();
|
|
@@ -153,6 +155,24 @@ export class ChatTranscriptBuilder {
|
|
|
153
155
|
previous.seal();
|
|
154
156
|
}
|
|
155
157
|
|
|
158
|
+
#resolveTodoSnapshot(nextToolName?: string): void {
|
|
159
|
+
const previous = this.#todoSnapshot;
|
|
160
|
+
if (!previous) return;
|
|
161
|
+
if (!previous.isDisplaceableBlock()) {
|
|
162
|
+
this.#todoSnapshot = null;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (previous.canBeDisplacedBy(nextToolName)) {
|
|
166
|
+
this.#todoSnapshot = null;
|
|
167
|
+
this.container.removeChild(previous);
|
|
168
|
+
previous.seal();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (nextToolName !== undefined) return;
|
|
172
|
+
this.#todoSnapshot = null;
|
|
173
|
+
previous.seal();
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
#ensureReadGroup(): ReadToolGroupComponent {
|
|
157
177
|
if (!this.#readGroup) {
|
|
158
178
|
this.#readGroup = new ReadToolGroupComponent({
|
|
@@ -189,6 +209,7 @@ export class ChatTranscriptBuilder {
|
|
|
189
209
|
case "developer": {
|
|
190
210
|
// A user prompt closes the poll-displacement window, same as the live path.
|
|
191
211
|
if (message.role === "user") this.#resolveWaitingPoll();
|
|
212
|
+
if (message.role === "user") this.#resolveTodoSnapshot();
|
|
192
213
|
const textContent = message.role === "user" ? userMessageText(message) : "";
|
|
193
214
|
if (textContent) {
|
|
194
215
|
const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
|
|
@@ -345,6 +366,16 @@ export class ChatTranscriptBuilder {
|
|
|
345
366
|
this.#pendingTools.delete(message.toolCallId);
|
|
346
367
|
if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) {
|
|
347
368
|
this.#waitingPoll = pending;
|
|
369
|
+
} else if (
|
|
370
|
+
message.toolName === "todo" &&
|
|
371
|
+
pending instanceof ToolExecutionComponent &&
|
|
372
|
+
pending.canBeDisplacedBy("todo")
|
|
373
|
+
) {
|
|
374
|
+
// A successful todo result supersedes the prior live snapshot. Failed
|
|
375
|
+
// follow-ups return false from canBeDisplacedBy("todo"), so the
|
|
376
|
+
// last-good panel stays on screen.
|
|
377
|
+
this.#resolveTodoSnapshot("todo");
|
|
378
|
+
this.#todoSnapshot = pending;
|
|
348
379
|
}
|
|
349
380
|
}
|
|
350
381
|
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
CustomEditor,
|
|
6
6
|
extractBracketedImagePastePaths,
|
|
7
7
|
extractBracketedPastePaths,
|
|
8
|
+
extractImagePathFromText,
|
|
9
|
+
extractPastePathsFromText,
|
|
8
10
|
SPACE_HOLD_MECHANICAL_RUN,
|
|
9
11
|
SPACE_HOLD_RELEASE_MS,
|
|
10
12
|
SPACE_REPEAT_MAX_GAP_MS,
|
|
@@ -88,6 +90,22 @@ describe("CustomEditor bracketed path paste", () => {
|
|
|
88
90
|
]);
|
|
89
91
|
});
|
|
90
92
|
|
|
93
|
+
it("strips `file://` URLs to the local filesystem path before loading the image", () => {
|
|
94
|
+
// macOS / Ghostty / iTerm2 sometimes forward the pasteboard's
|
|
95
|
+
// `public.file-url` representation when the user does Finder→Copy
|
|
96
|
+
// then Cmd+V. Without decoding, `loadImageInput` would try to read a
|
|
97
|
+
// literal `file:///…` path and fail.
|
|
98
|
+
expect(extractBracketedImagePastePaths(bracketedPaste("file:///Users/me/Pictures/photo.png"))).toEqual([
|
|
99
|
+
"/Users/me/Pictures/photo.png",
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("percent-decodes spaces inside `file://` URLs", () => {
|
|
104
|
+
expect(extractBracketedImagePastePaths(bracketedPaste("file:///Users/me/My%20Pictures/photo.png"))).toEqual([
|
|
105
|
+
"/Users/me/My Pictures/photo.png",
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
91
109
|
it("extracts explicit non-image paths without classifying them as image paths", () => {
|
|
92
110
|
expect(extractBracketedPastePaths(bracketedPaste("/tmp/report.csv"))).toEqual(["/tmp/report.csv"]);
|
|
93
111
|
expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/report.csv"))).toBeUndefined();
|
|
@@ -107,6 +125,68 @@ describe("CustomEditor bracketed path paste", () => {
|
|
|
107
125
|
});
|
|
108
126
|
});
|
|
109
127
|
|
|
128
|
+
describe("extractImagePathFromText (issue #3506)", () => {
|
|
129
|
+
it("returns the path when the text is a single image file path", () => {
|
|
130
|
+
expect(extractImagePathFromText("/tmp/screenshot.png")).toBe("/tmp/screenshot.png");
|
|
131
|
+
expect(extractImagePathFromText("/Users/me/Pictures/photo.jpeg")).toBe("/Users/me/Pictures/photo.jpeg");
|
|
132
|
+
expect(extractImagePathFromText("C:\\Users\\me\\img.gif")).toBe("C:\\Users\\me\\img.gif");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("ignores surrounding whitespace from a clipboard read", () => {
|
|
136
|
+
expect(extractImagePathFromText(" /tmp/photo.webp\n")).toBe("/tmp/photo.webp");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("returns undefined for a bare filename (no explicit directory)", () => {
|
|
140
|
+
// Mirrors the bracketed-paste contract: a bare `.png` filename is
|
|
141
|
+
// almost always a project-relative reference the user wants as text,
|
|
142
|
+
// not a clipboard-anchored attachment.
|
|
143
|
+
expect(extractImagePathFromText("icon.png")).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns undefined for non-image extensions", () => {
|
|
147
|
+
expect(extractImagePathFromText("/tmp/report.csv")).toBeUndefined();
|
|
148
|
+
expect(extractImagePathFromText("/tmp/notes.txt")).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns undefined when the text contains anything beyond a single path", () => {
|
|
152
|
+
expect(extractImagePathFromText("see /tmp/screenshot.png")).toBeUndefined();
|
|
153
|
+
expect(extractImagePathFromText("/tmp/a.png /tmp/b.png")).toBeUndefined();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns undefined for empty/whitespace-only input", () => {
|
|
157
|
+
expect(extractImagePathFromText("")).toBeUndefined();
|
|
158
|
+
expect(extractImagePathFromText(" ")).toBeUndefined();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("decodes a `file://` URL to its filesystem path", () => {
|
|
162
|
+
expect(extractImagePathFromText("file:///Users/me/Pictures/photo.png")).toBe("/Users/me/Pictures/photo.png");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("recovers a single anchored image path containing unescaped spaces (macOS screenshot name)", () => {
|
|
166
|
+
const macScreenshot = "/Users/me/Desktop/Screenshot 2026-06-25 at 1.23.45 PM.png";
|
|
167
|
+
expect(extractImagePathFromText(macScreenshot)).toBe(macScreenshot);
|
|
168
|
+
expect(extractImagePathFromText("~/Pictures/Cleanshot 2026-06-25 at 12.00.png")).toBe(
|
|
169
|
+
"~/Pictures/Cleanshot 2026-06-25 at 12.00.png",
|
|
170
|
+
);
|
|
171
|
+
expect(extractImagePathFromText("C:\\Users\\me\\My Pictures\\img with space.jpg")).toBe(
|
|
172
|
+
"C:\\Users\\me\\My Pictures\\img with space.jpg",
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("does not hijack prose that happens to contain a path-shaped fragment", () => {
|
|
177
|
+
// The whole-text branch is gated on ABSOLUTE_PATH_PREFIX_REGEX, so a
|
|
178
|
+
// non-anchored prefix ("see ...") never triggers it.
|
|
179
|
+
expect(extractImagePathFromText("see /Users/me/Desktop/Screenshot 1.png")).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("extractPastePathsFromText", () => {
|
|
184
|
+
it("delegates to the same logic the bracketed variant uses for path detection", () => {
|
|
185
|
+
expect(extractPastePathsFromText("/tmp/a.png /tmp/b.png")).toEqual(["/tmp/a.png", "/tmp/b.png"]);
|
|
186
|
+
expect(extractPastePathsFromText("just text")).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
110
190
|
describe("CustomEditor space-hold push-to-talk", () => {
|
|
111
191
|
beforeAll(async () => {
|
|
112
192
|
await initTheme();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
1
2
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
2
3
|
import { addKeyAliases, canonicalKeyId, Editor, type KeyId, parseKey, parseKittySequence } from "@oh-my-pi/pi-tui";
|
|
3
4
|
import type { AppKeybinding } from "../../config/keybindings";
|
|
@@ -66,6 +67,14 @@ const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
|
|
|
66
67
|
const URI_SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:/i;
|
|
67
68
|
const FILE_URI_REGEX = /^file:\/\//i;
|
|
68
69
|
const WINDOWS_DRIVE_PATH_REGEX = /^[a-z]:[\\/]/i;
|
|
70
|
+
/**
|
|
71
|
+
* Whole-string anchor for paths that are unambiguously absolute. Restricts the
|
|
72
|
+
* "treat the entire clipboard text as one path" branch of
|
|
73
|
+
* {@link extractImagePathFromText} to inputs that start with a clearly-anchored
|
|
74
|
+
* filesystem prefix, so prose containing a path-shaped fragment (e.g.
|
|
75
|
+
* "see /tmp/x.png") never hijacks the smart fallback.
|
|
76
|
+
*/
|
|
77
|
+
const ABSOLUTE_PATH_PREFIX_REGEX = /^(?:\/|~\/|file:\/\/|\\\\|[A-Za-z]:[\\/])/;
|
|
69
78
|
|
|
70
79
|
/** Max gap (ms) between two spaces for the later one to count as OS key auto-repeat rather than a
|
|
71
80
|
* deliberate press. OS auto-repeat is fast; a deliberate tap (even a fast one) is slower. */
|
|
@@ -105,6 +114,20 @@ function normalizePastedPath(path: string): string {
|
|
|
105
114
|
const last = trimmed[trimmed.length - 1];
|
|
106
115
|
const unquoted =
|
|
107
116
|
trimmed.length > 1 && (first === '"' || first === "'") && last === first ? trimmed.slice(1, -1) : trimmed;
|
|
117
|
+
// `file://` URL → local filesystem path. Mirrors Codex's
|
|
118
|
+
// `normalize_pasted_path` (codex-rs/tui/src/clipboard_paste.rs) so a
|
|
119
|
+
// pasteboard whose text representation is a `file:///Users/…/img.png`
|
|
120
|
+
// URL — common when terminals forward the macOS pasteboard's
|
|
121
|
+
// `public.file-url` representation — loads as the file itself rather
|
|
122
|
+
// than failing in `loadImageInput` with a literal-`file://` path.
|
|
123
|
+
if (FILE_URI_REGEX.test(unquoted)) {
|
|
124
|
+
try {
|
|
125
|
+
return fileURLToPath(unquoted);
|
|
126
|
+
} catch {
|
|
127
|
+
// Malformed file URL: drop through to the shell-unescape branch
|
|
128
|
+
// so the caller can still reject it as a non-explicit path.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
108
131
|
return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
|
|
109
132
|
}
|
|
110
133
|
|
|
@@ -161,12 +184,15 @@ function splitPastedPathSegments(payload: string): string[] | undefined {
|
|
|
161
184
|
return segments.length > 0 ? segments : undefined;
|
|
162
185
|
}
|
|
163
186
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Extract whitespace/quoted-separated path-like segments from `payload`.
|
|
189
|
+
* Shared backend of {@link extractBracketedPastePaths} and {@link extractPastePathsFromText}.
|
|
190
|
+
* Returns the segments only when EVERY segment looks like an explicit path
|
|
191
|
+
* (`/`, `\`, drive letter, or `file://`); otherwise undefined so the caller
|
|
192
|
+
* falls back to a plain text paste.
|
|
193
|
+
*/
|
|
194
|
+
function extractExplicitPathSegments(payload: string): string[] | undefined {
|
|
195
|
+
const pasted = payload.trim();
|
|
170
196
|
if (!pasted) return undefined;
|
|
171
197
|
|
|
172
198
|
const segments = splitPastedPathSegments(pasted);
|
|
@@ -181,6 +207,23 @@ export function extractBracketedPastePaths(data: string): string[] | undefined {
|
|
|
181
207
|
return paths;
|
|
182
208
|
}
|
|
183
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Extract image-or-other file paths from plain (un-bracketed) clipboard text.
|
|
212
|
+
* Mirrors {@link extractBracketedPastePaths} for terminals/handlers that
|
|
213
|
+
* already stripped the `\x1b[200~`…`\x1b[201~` markers (e.g. clipboard text
|
|
214
|
+
* read directly via `pbpaste`/PowerShell).
|
|
215
|
+
*/
|
|
216
|
+
export function extractPastePathsFromText(text: string): string[] | undefined {
|
|
217
|
+
return extractExplicitPathSegments(text);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function extractBracketedPastePaths(data: string): string[] | undefined {
|
|
221
|
+
if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
|
|
222
|
+
const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
|
|
223
|
+
if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
|
|
224
|
+
return extractExplicitPathSegments(data.slice(BRACKETED_PASTE_START.length, endIndex));
|
|
225
|
+
}
|
|
226
|
+
|
|
184
227
|
export function extractBracketedImagePastePaths(data: string): string[] | undefined {
|
|
185
228
|
const paths = extractBracketedPastePaths(data);
|
|
186
229
|
return paths?.every(isImagePath) ? paths : undefined;
|
|
@@ -191,6 +234,43 @@ export function extractBracketedImagePastePath(data: string): string | undefined
|
|
|
191
234
|
return paths?.length === 1 ? paths[0] : undefined;
|
|
192
235
|
}
|
|
193
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Return a single image file path when `text` is exactly one explicit path
|
|
239
|
+
* pointing at a supported image extension (`.png`, `.jpg`/`.jpeg`, `.gif`,
|
|
240
|
+
* `.webp`). Used by the keybind-driven clipboard image paste path so a
|
|
241
|
+
* clipboard whose only payload is an image file (e.g. Finder `Cmd+C` on
|
|
242
|
+
* macOS) attaches the image instead of pasting the path as literal text.
|
|
243
|
+
*
|
|
244
|
+
* Two-stage detection:
|
|
245
|
+
*
|
|
246
|
+
* 1. Splitter pass (shared with the bracketed-paste handler) — handles
|
|
247
|
+
* quoted paths, shell-escaped spaces, and unambiguous single tokens.
|
|
248
|
+
* Returns the single image path when it parses cleanly; explicitly
|
|
249
|
+
* returns `undefined` when the splitter found multiple segments (so
|
|
250
|
+
* ambiguous multi-path clipboard text like `/tmp/a.png /tmp/b.png`
|
|
251
|
+
* still falls through to the text fallback instead of being mis-loaded
|
|
252
|
+
* as one giant path).
|
|
253
|
+
* 2. Whole-text-as-path pass — only reached when the splitter failed
|
|
254
|
+
* (every segment must look like an explicit path; an unescaped space in
|
|
255
|
+
* a real path breaks that). Restricted to inputs anchored by
|
|
256
|
+
* {@link ABSOLUTE_PATH_PREFIX_REGEX} so prose containing a path-shaped
|
|
257
|
+
* fragment ("see /tmp/x.png") never hijacks the smart fallback. This
|
|
258
|
+
* is what recovers macOS screenshot filenames like
|
|
259
|
+
* `/Users/me/Desktop/Screenshot 2026-06-25 at 1.23.45 PM.png`.
|
|
260
|
+
*/
|
|
261
|
+
export function extractImagePathFromText(text: string): string | undefined {
|
|
262
|
+
const paths = extractPastePathsFromText(text);
|
|
263
|
+
if (paths?.length === 1 && isImagePath(paths[0])) return paths[0];
|
|
264
|
+
if (paths !== undefined) return undefined;
|
|
265
|
+
const trimmed = text.trim();
|
|
266
|
+
if (!trimmed || /[\r\n]/.test(trimmed) || !ABSOLUTE_PATH_PREFIX_REGEX.test(trimmed)) return undefined;
|
|
267
|
+
const wholePath = normalizePastedPath(trimmed);
|
|
268
|
+
if (wholePath && isExplicitPastedPath(wholePath) && isImagePath(wholePath)) {
|
|
269
|
+
return wholePath;
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
194
274
|
/**
|
|
195
275
|
* Custom editor that handles configurable app-level shortcuts for coding-agent.
|
|
196
276
|
*/
|