@oh-my-pi/pi-coding-agent 16.3.0 → 16.3.3
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 +91 -0
- package/dist/cli.js +3855 -3798
- package/dist/types/cli/update-cli.d.ts +1 -0
- package/dist/types/cli/update-cli.test.d.ts +1 -0
- package/dist/types/commands/update.d.ts +5 -0
- package/dist/types/config/config-file.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +20 -0
- package/dist/types/dap/client.d.ts +9 -1
- package/dist/types/discovery/helpers.d.ts +2 -0
- package/dist/types/edit/file-snapshot-store.d.ts +9 -1
- package/dist/types/edit/hashline/execute.d.ts +1 -1
- package/dist/types/edit/hashline/params.d.ts +3 -6
- package/dist/types/edit/renderer.d.ts +1 -0
- package/dist/types/extensibility/plugins/parser.d.ts +2 -1
- package/dist/types/hindsight/client.d.ts +15 -11
- package/dist/types/hindsight/client.test.d.ts +1 -0
- package/dist/types/irc/bus.d.ts +5 -4
- package/dist/types/mcp/smithery-registry.d.ts +1 -0
- package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
- package/dist/types/modes/components/advisor-message.d.ts +4 -2
- package/dist/types/modes/components/tool-execution.d.ts +9 -6
- package/dist/types/modes/components/transcript-container.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +13 -13
- package/dist/types/session/indexed-session-storage.d.ts +2 -2
- package/dist/types/session/session-storage.d.ts +31 -3
- package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
- package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
- package/dist/types/ssh/connection-manager.d.ts +19 -0
- package/dist/types/ssh/sshfs-mount.d.ts +10 -1
- package/dist/types/subprocess/worker-client.d.ts +20 -6
- package/dist/types/task/discovery.d.ts +5 -2
- package/dist/types/task/renderer.d.ts +1 -0
- package/dist/types/tools/ast-grep.d.ts +4 -2
- package/dist/types/tools/bash.d.ts +27 -0
- package/dist/types/tools/browser/render.d.ts +2 -0
- package/dist/types/tools/debug.d.ts +1 -0
- package/dist/types/tools/eval-render.d.ts +2 -0
- package/dist/types/tools/glob.d.ts +4 -2
- package/dist/types/tools/grep.d.ts +4 -3
- package/dist/types/tools/path-utils.d.ts +7 -0
- package/dist/types/tools/renderers.d.ts +24 -0
- package/dist/types/tools/ssh.d.ts +4 -1
- package/dist/types/tts/index.d.ts +2 -0
- package/dist/types/tts/speakable.d.ts +47 -0
- package/dist/types/tts/speech-enhancer.d.ts +46 -0
- package/dist/types/tts/streaming-player.d.ts +1 -2
- package/dist/types/tts/tts-client.d.ts +11 -10
- package/dist/types/tts/tts-protocol.d.ts +7 -0
- package/dist/types/tts/vocalizer.d.ts +15 -8
- package/dist/types/utils/fetch-timeout.d.ts +4 -0
- package/dist/types/utils/git.d.ts +2 -0
- package/dist/types/web/search/providers/base.d.ts +1 -0
- package/dist/types/web/search/providers/gemini.d.ts +1 -0
- package/package.json +12 -12
- package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
- package/src/advisor/__tests__/advisor.test.ts +1 -1
- package/src/cli/gallery-fixtures/fs.ts +2 -2
- package/src/cli/gallery-fixtures/search.ts +2 -2
- package/src/cli/models-cli.ts +19 -0
- package/src/cli/update-cli.test.ts +28 -0
- package/src/cli/update-cli.ts +35 -8
- package/src/cli.ts +27 -5
- package/src/commands/update.ts +8 -2
- package/src/config/config-file.ts +6 -6
- package/src/config/model-resolver.ts +31 -10
- package/src/config/settings-schema.ts +21 -0
- package/src/cursor.ts +1 -1
- package/src/dap/client.ts +134 -36
- package/src/discovery/helpers.ts +8 -0
- package/src/edit/file-snapshot-store.ts +12 -1
- package/src/edit/hashline/diff.ts +4 -13
- package/src/edit/hashline/execute.ts +1 -1
- package/src/edit/hashline/params.ts +5 -12
- package/src/edit/renderer.ts +4 -2
- package/src/edit/streaming.ts +15 -5
- package/src/export/html/tool-views.generated.js +34 -34
- package/src/extensibility/custom-tools/loader.ts +3 -3
- package/src/extensibility/extensions/loader.ts +10 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
- package/src/extensibility/plugins/installer.ts +12 -3
- package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
- package/src/extensibility/plugins/loader.ts +30 -1
- package/src/extensibility/plugins/manager.ts +32 -8
- package/src/extensibility/plugins/parser.ts +7 -5
- package/src/extensibility/tool-event-input.ts +1 -1
- package/src/hindsight/client.test.ts +33 -0
- package/src/hindsight/client.ts +42 -22
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/irc/bus.ts +5 -4
- package/src/main.ts +7 -1
- package/src/mcp/oauth-flow.ts +93 -4
- package/src/mcp/smithery-auth.ts +3 -0
- package/src/mcp/smithery-connect.ts +9 -0
- package/src/mcp/smithery-registry.test.ts +51 -0
- package/src/mcp/smithery-registry.ts +27 -4
- package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
- package/src/modes/components/advisor-message.ts +13 -10
- package/src/modes/components/assistant-message.ts +1 -1
- package/src/modes/components/move-overlay.ts +35 -23
- package/src/modes/components/status-line/component.ts +11 -4
- package/src/modes/components/tool-execution.ts +119 -20
- package/src/modes/components/transcript-container.ts +26 -0
- package/src/modes/components/tree-selector.ts +10 -3
- package/src/modes/controllers/event-controller.ts +23 -3
- package/src/modes/controllers/tool-args-reveal.ts +1 -1
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/rpc/rpc-client.ts +29 -16
- package/src/modes/theme/shimmer.ts +49 -15
- package/src/modes/theme/theme.ts +7 -0
- package/src/prompts/goals/goal-mode-context.md +4 -0
- package/src/prompts/goals/goal-todo-context.md +10 -2
- package/src/prompts/system/speech-rewrite.md +15 -0
- package/src/prompts/system/tiny-title-system.md +1 -1
- package/src/prompts/system/title-system-marker.md +2 -1
- package/src/prompts/system/title-system.md +2 -1
- package/src/prompts/tools/ast-grep.md +3 -3
- package/src/prompts/tools/glob.md +1 -1
- package/src/prompts/tools/grep.md +1 -1
- package/src/sdk.ts +8 -5
- package/src/session/agent-session.ts +329 -79
- package/src/session/indexed-session-storage.ts +40 -3
- package/src/session/session-history-format.ts +6 -2
- package/src/session/session-manager.ts +83 -14
- package/src/session/session-storage.ts +112 -26
- package/src/slash-commands/helpers/usage-report.ts +6 -1
- package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
- package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
- package/src/ssh/connection-manager.ts +44 -11
- package/src/ssh/sshfs-mount.ts +27 -4
- package/src/subprocess/worker-client.ts +161 -10
- package/src/task/discovery.ts +25 -2
- package/src/task/executor.ts +24 -4
- package/src/task/render.ts +26 -12
- package/src/task/renderer.ts +1 -0
- package/src/task/worktree.ts +144 -16
- package/src/tiny/text.ts +109 -17
- package/src/tools/ast-grep.ts +20 -17
- package/src/tools/bash.ts +46 -9
- package/src/tools/browser/render.ts +2 -0
- package/src/tools/debug.ts +1 -0
- package/src/tools/eval-render.ts +5 -2
- package/src/tools/gh-renderer.ts +3 -0
- package/src/tools/glob.ts +24 -20
- package/src/tools/grep.ts +39 -37
- package/src/tools/path-utils.ts +55 -10
- package/src/tools/read.ts +23 -6
- package/src/tools/renderers.ts +24 -0
- package/src/tools/ssh.ts +25 -7
- package/src/tts/index.ts +2 -0
- package/src/tts/speakable.ts +382 -0
- package/src/tts/speech-enhancer.ts +204 -0
- package/src/tts/streaming-player.ts +71 -16
- package/src/tts/tts-client.ts +11 -10
- package/src/tts/tts-protocol.ts +14 -5
- package/src/tts/tts-worker.ts +52 -49
- package/src/tts/vocalizer.ts +277 -46
- package/src/utils/clipboard.ts +49 -12
- package/src/utils/fetch-timeout.ts +10 -0
- package/src/utils/git.ts +8 -0
- package/src/web/search/index.ts +8 -0
- package/src/web/search/providers/base.ts +1 -0
- package/src/web/search/providers/gemini.ts +23 -6
package/src/irc/bus.ts
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
* agents receive the message as a non-interrupting aside at the next step
|
|
9
9
|
* boundary (see AgentSession.deliverIrcMessage). Replies are real turns by
|
|
10
10
|
* the recipient, observed via `wait` — with one exception: when the sender
|
|
11
|
-
* awaits a reply and the recipient
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* awaits a reply and the recipient cannot run a real reply turn in time
|
|
12
|
+
* (mid-turn with async execution disabled — possibly blocked in a
|
|
13
|
+
* synchronous task spawn whose batch includes the sender — or idle in plan
|
|
14
|
+
* mode, where autonomous wake turns are suppressed), the recipient session
|
|
15
|
+
* generates an ephemeral side-channel auto-reply.
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { logger, Snowflake } from "@oh-my-pi/pi-utils";
|
package/src/main.ts
CHANGED
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
writeLastChangelogVersion,
|
|
85
85
|
} from "./utils/changelog";
|
|
86
86
|
import { EventBus } from "./utils/event-bus";
|
|
87
|
+
import { withTimeoutSignal } from "./utils/fetch-timeout";
|
|
87
88
|
|
|
88
89
|
type RunAcpMode = (createSession: AcpSessionFactory) => Promise<never>;
|
|
89
90
|
type RunPrintMode = (session: AgentSession, options: PrintModeOptions) => Promise<void>;
|
|
@@ -102,7 +103,9 @@ async function checkForNewVersion(currentVersion: string): Promise<string | unde
|
|
|
102
103
|
return;
|
|
103
104
|
}
|
|
104
105
|
try {
|
|
105
|
-
const response = await fetch("https://registry.npmjs.org/@oh-my-pi/pi-coding-agent/latest"
|
|
106
|
+
const response = await fetch("https://registry.npmjs.org/@oh-my-pi/pi-coding-agent/latest", {
|
|
107
|
+
signal: withTimeoutSignal(5_000),
|
|
108
|
+
});
|
|
106
109
|
if (!response.ok) return undefined;
|
|
107
110
|
|
|
108
111
|
const data = (await response.json()) as { version?: string };
|
|
@@ -1383,6 +1386,9 @@ export async function runRootCommand(
|
|
|
1383
1386
|
}
|
|
1384
1387
|
|
|
1385
1388
|
if (!isInteractive && !session.model) {
|
|
1389
|
+
if (modelRegistryError) {
|
|
1390
|
+
process.stderr.write(`${chalk.red(modelRegistryError.message)}\n\n`);
|
|
1391
|
+
}
|
|
1386
1392
|
if (modelFallbackMessage) {
|
|
1387
1393
|
process.stderr.write(`${chalk.red(modelFallbackMessage)}\n`);
|
|
1388
1394
|
} else {
|
package/src/mcp/oauth-flow.ts
CHANGED
|
@@ -79,6 +79,31 @@ function hasOAuthScope(scopes: string | null | undefined, scope: string): boolea
|
|
|
79
79
|
return !!scopes && scopes.split(/\s+/).includes(scope);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Trim a DCR failure body / thrown error message to a single, short line the
|
|
84
|
+
* caller can splice into an error string. `undefined` when nothing salvageable
|
|
85
|
+
* remains after stripping whitespace.
|
|
86
|
+
*/
|
|
87
|
+
function truncateDetail(raw: string | undefined): string | undefined {
|
|
88
|
+
if (!raw) return undefined;
|
|
89
|
+
const firstLine = raw.split(/\r?\n/, 1)[0]?.trim();
|
|
90
|
+
if (!firstLine) return undefined;
|
|
91
|
+
return firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read the response body of a rejected DCR request as a short diagnostic
|
|
96
|
+
* string. Never throws — the caller is already building an error and cannot
|
|
97
|
+
* afford to trade the actual failure for a "read body" one.
|
|
98
|
+
*/
|
|
99
|
+
async function readRegistrationFailureDetail(response: Response): Promise<string | undefined> {
|
|
100
|
+
try {
|
|
101
|
+
return truncateDetail(await response.text());
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
function isLoopbackHostname(hostname: string): boolean {
|
|
83
108
|
return hostname === "localhost" || hostname === "127.0.0.1";
|
|
84
109
|
}
|
|
@@ -294,6 +319,22 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
294
319
|
#codeVerifier?: string;
|
|
295
320
|
#fetch: FetchImpl;
|
|
296
321
|
#resource?: string;
|
|
322
|
+
/**
|
|
323
|
+
* Details of a rejected dynamic client-registration attempt. Populated by
|
|
324
|
+
* {@link #tryRegisterClient} when the provider advertises a registration
|
|
325
|
+
* endpoint but returns a non-2xx / throws (e.g. Figma's DCR endpoint 403s
|
|
326
|
+
* every request because only catalog-approved clients may connect). Reused
|
|
327
|
+
* by {@link #missingClientIdError} to explain why the fallback probe now
|
|
328
|
+
* requires a manually configured `oauth.clientId`, replacing the opaque
|
|
329
|
+
* "OAuth provider requires client_id" message.
|
|
330
|
+
*/
|
|
331
|
+
#registrationFailure?: {
|
|
332
|
+
endpoint: string;
|
|
333
|
+
/** HTTP status returned by the endpoint; `0` when the request threw. */
|
|
334
|
+
status: number;
|
|
335
|
+
/** First line of the response body (or thrown error message), trimmed. */
|
|
336
|
+
detail?: string;
|
|
337
|
+
};
|
|
297
338
|
|
|
298
339
|
constructor(
|
|
299
340
|
private config: MCPOAuthConfig,
|
|
@@ -507,6 +548,13 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
507
548
|
|
|
508
549
|
/**
|
|
509
550
|
* Try OAuth dynamic client registration when provider requires a client_id.
|
|
551
|
+
*
|
|
552
|
+
* Records rejection details on {@link #registrationFailure} so that when
|
|
553
|
+
* DCR is intentionally closed (Figma's `mcp:connect` endpoint returns 403 to
|
|
554
|
+
* every unlisted client — see https://developers.figma.com/docs/figma-mcp-server/,
|
|
555
|
+
* "Only clients listed in the Figma MCP Catalog can connect"), the fallback
|
|
556
|
+
* probe surfaces a message that names the endpoint and status instead of
|
|
557
|
+
* the historical opaque "OAuth provider requires client_id".
|
|
510
558
|
*/
|
|
511
559
|
async #tryRegisterClient(redirectUri: string): Promise<void> {
|
|
512
560
|
const registrationEndpoint = await this.#resolveRegistrationEndpoint();
|
|
@@ -530,7 +578,14 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
530
578
|
}),
|
|
531
579
|
});
|
|
532
580
|
|
|
533
|
-
if (!response.ok)
|
|
581
|
+
if (!response.ok) {
|
|
582
|
+
this.#registrationFailure = {
|
|
583
|
+
endpoint: registrationEndpoint,
|
|
584
|
+
status: response.status,
|
|
585
|
+
detail: await readRegistrationFailureDetail(response),
|
|
586
|
+
};
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
534
589
|
|
|
535
590
|
const data = (await response.json()) as {
|
|
536
591
|
client_id?: string;
|
|
@@ -543,8 +598,14 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
543
598
|
if (data.client_secret && data.client_secret.trim() !== "") {
|
|
544
599
|
this.#registeredClientSecret = data.client_secret;
|
|
545
600
|
}
|
|
546
|
-
} catch {
|
|
547
|
-
//
|
|
601
|
+
} catch (error) {
|
|
602
|
+
// Distinguish real transport/parse failures from a benign no-DCR
|
|
603
|
+
// response so #missingClientIdError can surface what went wrong.
|
|
604
|
+
this.#registrationFailure = {
|
|
605
|
+
endpoint: registrationEndpoint,
|
|
606
|
+
status: 0,
|
|
607
|
+
detail: error instanceof Error ? truncateDetail(error.message) : undefined,
|
|
608
|
+
};
|
|
548
609
|
}
|
|
549
610
|
}
|
|
550
611
|
|
|
@@ -609,7 +670,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
609
670
|
if (response.status < 400) return;
|
|
610
671
|
const body = await response.text();
|
|
611
672
|
if (/client[_-]?id/i.test(body) && /(required|missing|invalid)/i.test(body)) {
|
|
612
|
-
throw
|
|
673
|
+
throw this.#missingClientIdError();
|
|
613
674
|
}
|
|
614
675
|
} catch (error) {
|
|
615
676
|
if (error instanceof Error && /client[_-]?id/i.test(error.message)) {
|
|
@@ -618,6 +679,34 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
618
679
|
// Ignore network/probe failures to avoid blocking flows that still work.
|
|
619
680
|
}
|
|
620
681
|
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Build the error thrown when the authorize probe confirms the provider
|
|
685
|
+
* demands a `client_id`. When dynamic client registration was attempted and
|
|
686
|
+
* rejected (e.g. Figma's 403 for unlisted clients), fold the endpoint + HTTP
|
|
687
|
+
* status into the message and point the user at the manual `oauth.clientId`
|
|
688
|
+
* workaround. Refs issue #4307.
|
|
689
|
+
*/
|
|
690
|
+
#missingClientIdError(): Error {
|
|
691
|
+
const failure = this.#registrationFailure;
|
|
692
|
+
const manualHint =
|
|
693
|
+
"Configure `oauth.clientId` (and `oauth.clientSecret` if the flow needs one) on the MCP server entry in mcp.json.";
|
|
694
|
+
if (!failure) {
|
|
695
|
+
return new Error(
|
|
696
|
+
`OAuth provider requires client_id, and no dynamic-client-registration endpoint was advertised. ${manualHint}`,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
const outcome =
|
|
700
|
+
failure.status > 0
|
|
701
|
+
? `HTTP ${failure.status}${failure.detail ? ` — ${failure.detail}` : ""}`
|
|
702
|
+
: failure.detail
|
|
703
|
+
? `network error — ${failure.detail}`
|
|
704
|
+
: "network error";
|
|
705
|
+
return new Error(
|
|
706
|
+
`OAuth provider requires client_id, and dynamic client registration was rejected ` +
|
|
707
|
+
`(POST ${failure.endpoint} → ${outcome}). The server likely restricts registration to pre-approved clients. ${manualHint}`,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
621
710
|
}
|
|
622
711
|
|
|
623
712
|
/**
|
package/src/mcp/smithery-auth.ts
CHANGED
|
@@ -2,9 +2,11 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
4
4
|
import { getAgentDir } from "@oh-my-pi/pi-utils/dirs";
|
|
5
|
+
import { withTimeoutSignal } from "../utils/fetch-timeout";
|
|
5
6
|
|
|
6
7
|
const SMITHERY_AUTH_FILENAME = "smithery.json";
|
|
7
8
|
const SMITHERY_URL = process.env.SMITHERY_URL || "https://smithery.ai";
|
|
9
|
+
const SMITHERY_AUTH_TIMEOUT_MS = 10_000;
|
|
8
10
|
|
|
9
11
|
type SmitheryCliAuthSession = {
|
|
10
12
|
sessionId: string;
|
|
@@ -38,6 +40,7 @@ export function getSmitheryLoginUrl(): string {
|
|
|
38
40
|
export async function createSmitheryCliAuthSession(): Promise<SmitheryCliAuthSession> {
|
|
39
41
|
const response = await fetch(`${SMITHERY_URL}/api/auth/cli/session`, {
|
|
40
42
|
method: "POST",
|
|
43
|
+
signal: withTimeoutSignal(SMITHERY_AUTH_TIMEOUT_MS),
|
|
41
44
|
});
|
|
42
45
|
if (!response.ok) {
|
|
43
46
|
throw new Error(`Failed to create Smithery auth session: ${response.status} ${response.statusText}`);
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { withTimeoutSignal } from "../utils/fetch-timeout";
|
|
2
|
+
|
|
1
3
|
const SMITHERY_API_BASE_URL = (process.env.SMITHERY_API_URL || "https://api.smithery.ai").replace(/\/+$/, "");
|
|
4
|
+
const SMITHERY_CONNECT_TIMEOUT_MS = 10_000;
|
|
2
5
|
|
|
3
6
|
export class SmitheryConnectError extends Error {
|
|
4
7
|
status: number;
|
|
@@ -62,6 +65,7 @@ export function getSmitheryApiBaseUrl(): string {
|
|
|
62
65
|
export async function listSmitheryNamespaces(apiKey: string): Promise<SmitheryNamespace[]> {
|
|
63
66
|
const response = await fetch(toApiUrl("/namespaces"), {
|
|
64
67
|
headers: buildAuthHeaders(apiKey),
|
|
68
|
+
signal: withTimeoutSignal(SMITHERY_CONNECT_TIMEOUT_MS),
|
|
65
69
|
});
|
|
66
70
|
await expectOk(response, "Failed to list Smithery namespaces");
|
|
67
71
|
const payload = (await response.json()) as SmitheryNamespacesResponse;
|
|
@@ -72,6 +76,7 @@ export async function createSmitheryNamespace(apiKey: string): Promise<SmitheryN
|
|
|
72
76
|
const response = await fetch(toApiUrl("/namespaces"), {
|
|
73
77
|
method: "POST",
|
|
74
78
|
headers: buildAuthHeaders(apiKey),
|
|
79
|
+
signal: withTimeoutSignal(SMITHERY_CONNECT_TIMEOUT_MS),
|
|
75
80
|
});
|
|
76
81
|
await expectOk(response, "Failed to create Smithery namespace");
|
|
77
82
|
return (await response.json()) as SmitheryNamespace;
|
|
@@ -95,6 +100,7 @@ export async function listSmitheryConnectionsByUrl(
|
|
|
95
100
|
endpoint.searchParams.set("mcpUrl", mcpUrl);
|
|
96
101
|
const response = await fetch(endpoint.toString(), {
|
|
97
102
|
headers: buildAuthHeaders(apiKey),
|
|
103
|
+
signal: withTimeoutSignal(SMITHERY_CONNECT_TIMEOUT_MS),
|
|
98
104
|
});
|
|
99
105
|
await expectOk(response, "Failed to list Smithery connections");
|
|
100
106
|
const payload = (await response.json()) as SmitheryConnectionsResponse;
|
|
@@ -109,6 +115,7 @@ export async function createSmitheryConnection(
|
|
|
109
115
|
const response = await fetch(toApiUrl(`/connect/${encodeURIComponent(namespace)}`), {
|
|
110
116
|
method: "POST",
|
|
111
117
|
headers: buildAuthHeaders(apiKey),
|
|
118
|
+
signal: withTimeoutSignal(SMITHERY_CONNECT_TIMEOUT_MS),
|
|
112
119
|
body: JSON.stringify({
|
|
113
120
|
mcpUrl: params.mcpUrl,
|
|
114
121
|
name: params.name,
|
|
@@ -127,6 +134,7 @@ export async function getSmitheryConnection(
|
|
|
127
134
|
toApiUrl(`/connect/${encodeURIComponent(namespace)}/${encodeURIComponent(connectionId)}`),
|
|
128
135
|
{
|
|
129
136
|
headers: buildAuthHeaders(apiKey),
|
|
137
|
+
signal: withTimeoutSignal(SMITHERY_CONNECT_TIMEOUT_MS),
|
|
130
138
|
},
|
|
131
139
|
);
|
|
132
140
|
await expectOk(response, "Failed to get Smithery connection");
|
|
@@ -139,6 +147,7 @@ export async function deleteSmitheryConnection(apiKey: string, namespace: string
|
|
|
139
147
|
{
|
|
140
148
|
method: "DELETE",
|
|
141
149
|
headers: buildAuthHeaders(apiKey),
|
|
150
|
+
signal: withTimeoutSignal(SMITHERY_CONNECT_TIMEOUT_MS),
|
|
142
151
|
},
|
|
143
152
|
);
|
|
144
153
|
await expectOk(response, "Failed to delete Smithery connection");
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
2
|
+
import { searchSmitheryRegistry } from "./smithery-registry";
|
|
3
|
+
|
|
4
|
+
type FetchInput = string | URL | Request;
|
|
5
|
+
type FetchInit = RequestInit | BunFetchRequestInit;
|
|
6
|
+
|
|
7
|
+
describe("searchSmitheryRegistry fetch cancellation", () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("adds timeout signals to search and detail requests", async () => {
|
|
13
|
+
const signals: AbortSignal[] = [];
|
|
14
|
+
const fetchStub = Object.assign(
|
|
15
|
+
async (input: FetchInput, init?: FetchInit) => {
|
|
16
|
+
if (init?.signal instanceof AbortSignal) signals.push(init.signal);
|
|
17
|
+
const url = String(input);
|
|
18
|
+
if (url.includes("?")) {
|
|
19
|
+
return Response.json({
|
|
20
|
+
servers: [
|
|
21
|
+
{
|
|
22
|
+
id: "srv_1",
|
|
23
|
+
namespace: "smithery-ai",
|
|
24
|
+
slug: "filesystem",
|
|
25
|
+
qualifiedName: "@smithery-ai/filesystem",
|
|
26
|
+
displayName: "Filesystem",
|
|
27
|
+
description: "File access",
|
|
28
|
+
useCount: 1,
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return Response.json({
|
|
34
|
+
qualifiedName: "@smithery-ai/filesystem",
|
|
35
|
+
displayName: "Filesystem",
|
|
36
|
+
description: "File access",
|
|
37
|
+
connections: [{ type: "http", deploymentUrl: "https://mcp.example" }],
|
|
38
|
+
tools: [],
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
{ preconnect: globalThis.fetch.preconnect },
|
|
42
|
+
);
|
|
43
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(fetchStub);
|
|
44
|
+
|
|
45
|
+
const results = await searchSmitheryRegistry("filesystem", { limit: 1 });
|
|
46
|
+
|
|
47
|
+
expect(results[0]?.name).toBe("smithery-ai/filesystem");
|
|
48
|
+
expect(signals).toHaveLength(2);
|
|
49
|
+
expect(signals.every(signal => signal instanceof AbortSignal)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import { isTimeoutError, withTimeoutSignal } from "../utils/fetch-timeout";
|
|
2
3
|
import type { MCPServerConfig } from "./types";
|
|
3
4
|
|
|
4
5
|
const SMITHERY_REGISTRY_BASE_URL = "https://registry.smithery.ai";
|
|
6
|
+
const SMITHERY_REGISTRY_TIMEOUT_MS = 10_000;
|
|
5
7
|
|
|
6
8
|
type SmitherySearchEntry = {
|
|
7
9
|
id?: string;
|
|
@@ -106,6 +108,7 @@ export interface SmitherySearchOptions {
|
|
|
106
108
|
limit?: number;
|
|
107
109
|
apiKey?: string;
|
|
108
110
|
includeSemantic?: boolean;
|
|
111
|
+
signal?: AbortSignal;
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
export class SmitheryRegistryError extends Error {
|
|
@@ -307,13 +310,17 @@ function createConfig(
|
|
|
307
310
|
};
|
|
308
311
|
}
|
|
309
312
|
|
|
310
|
-
async function fetchServerDetails(
|
|
313
|
+
async function fetchServerDetails(
|
|
314
|
+
path: string,
|
|
315
|
+
options?: { apiKey?: string; signal?: AbortSignal },
|
|
316
|
+
): Promise<SmitheryServerDetails | null> {
|
|
311
317
|
const headers = new Headers();
|
|
312
318
|
if (options?.apiKey) {
|
|
313
319
|
headers.set("Authorization", `Bearer ${options.apiKey}`);
|
|
314
320
|
}
|
|
315
321
|
const response = await fetch(`${SMITHERY_REGISTRY_BASE_URL}/servers/${path}`, {
|
|
316
322
|
headers,
|
|
323
|
+
signal: withTimeoutSignal(SMITHERY_REGISTRY_TIMEOUT_MS, options?.signal),
|
|
317
324
|
});
|
|
318
325
|
if (!response.ok) return null;
|
|
319
326
|
return (await response.json()) as SmitheryServerDetails;
|
|
@@ -321,7 +328,7 @@ async function fetchServerDetails(path: string, options?: { apiKey?: string }):
|
|
|
321
328
|
|
|
322
329
|
async function fetchServerDetailsFromEntry(
|
|
323
330
|
entry: SmitherySearchEntry,
|
|
324
|
-
options?: { apiKey?: string },
|
|
331
|
+
options?: { apiKey?: string; signal?: AbortSignal },
|
|
325
332
|
): Promise<SmitheryServerDetails | null> {
|
|
326
333
|
const candidates = resolveDetailPathCandidates(entry);
|
|
327
334
|
for (const candidate of candidates) {
|
|
@@ -329,6 +336,7 @@ async function fetchServerDetailsFromEntry(
|
|
|
329
336
|
const details = await fetchServerDetails(candidate, options);
|
|
330
337
|
if (details) return details;
|
|
331
338
|
} catch (error) {
|
|
339
|
+
if (options?.signal?.aborted) throw error;
|
|
332
340
|
logger.debug("Smithery detail fetch candidate failed", { candidate, error: String(error) });
|
|
333
341
|
}
|
|
334
342
|
}
|
|
@@ -411,7 +419,18 @@ export async function searchSmitheryRegistry(
|
|
|
411
419
|
url.searchParams.set("q", query);
|
|
412
420
|
url.searchParams.set("pageSize", String(pageSize));
|
|
413
421
|
if (page > 1) url.searchParams.set("page", String(page));
|
|
414
|
-
|
|
422
|
+
let response: Response;
|
|
423
|
+
try {
|
|
424
|
+
response = await fetch(url.toString(), {
|
|
425
|
+
headers,
|
|
426
|
+
signal: withTimeoutSignal(SMITHERY_REGISTRY_TIMEOUT_MS, options?.signal),
|
|
427
|
+
});
|
|
428
|
+
} catch (err) {
|
|
429
|
+
if (isTimeoutError(err)) {
|
|
430
|
+
throw new SmitheryRegistryError("Smithery search timed out after 10s", 0);
|
|
431
|
+
}
|
|
432
|
+
throw err;
|
|
433
|
+
}
|
|
415
434
|
if (!response.ok) {
|
|
416
435
|
throw new SmitheryRegistryError(`Smithery search failed with status ${response.status}`, response.status);
|
|
417
436
|
}
|
|
@@ -448,10 +467,14 @@ export async function searchSmitheryRegistry(
|
|
|
448
467
|
const results = await Promise.all(
|
|
449
468
|
uniqueEntries.map(async entry => {
|
|
450
469
|
try {
|
|
451
|
-
const details = await fetchServerDetailsFromEntry(entry, {
|
|
470
|
+
const details = await fetchServerDetailsFromEntry(entry, {
|
|
471
|
+
apiKey: options?.apiKey,
|
|
472
|
+
signal: options?.signal,
|
|
473
|
+
});
|
|
452
474
|
if (!details) return null;
|
|
453
475
|
return toSearchResult(entry, details);
|
|
454
476
|
} catch (error) {
|
|
477
|
+
if (options?.signal?.aborted) throw error;
|
|
455
478
|
detailFailures.push({
|
|
456
479
|
identity: getEntryIdentityKey(entry) ?? entry.id ?? "unknown",
|
|
457
480
|
error: String(error),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
|
1
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as fsp from "node:fs/promises";
|
|
4
4
|
import * as os from "node:os";
|
|
@@ -163,4 +163,75 @@ describe("MoveOverlay", () => {
|
|
|
163
163
|
expect(result).toBeDefined();
|
|
164
164
|
expect(result!.directory).toBe(path.join(cwd, "alpha"));
|
|
165
165
|
});
|
|
166
|
+
|
|
167
|
+
it("does not stat every directory entry per keystroke", async () => {
|
|
168
|
+
// Regression: `readDirCached` used to cache names and re-`statSync` every
|
|
169
|
+
// entry per keystroke. Populate with mostly files so the old code path
|
|
170
|
+
// would have stat'd all of them; the fix classifies via `Dirent` and only
|
|
171
|
+
// falls back to `statSync` on symlink entries.
|
|
172
|
+
const bulk = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-move-overlay-bulk-"));
|
|
173
|
+
try {
|
|
174
|
+
for (let i = 0; i < 60; i++) {
|
|
175
|
+
fs.writeFileSync(path.join(bulk, `f_${String(i).padStart(3, "0")}.txt`), "x");
|
|
176
|
+
}
|
|
177
|
+
for (let i = 0; i < 10; i++) fs.mkdirSync(path.join(bulk, `sub_${i}`));
|
|
178
|
+
|
|
179
|
+
const statSpy = spyOn(fs, "statSync");
|
|
180
|
+
try {
|
|
181
|
+
const overlay = new MoveOverlay(bulk, () => {});
|
|
182
|
+
statSpy.mockClear();
|
|
183
|
+
overlay.handleInput("s");
|
|
184
|
+
overlay.handleInput("u");
|
|
185
|
+
overlay.handleInput("b");
|
|
186
|
+
// Each keystroke may `statSync` at most once — the
|
|
187
|
+
// `resolveExistingDirectory` probe on the typed prefix. Entries
|
|
188
|
+
// are classified via `Dirent`, not one stat apiece.
|
|
189
|
+
expect(statSpy.mock.calls.length).toBeLessThanOrEqual(3);
|
|
190
|
+
} finally {
|
|
191
|
+
statSpy.mockRestore();
|
|
192
|
+
}
|
|
193
|
+
} finally {
|
|
194
|
+
await fsp.rm(bulk, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("classifies unknown-type Dirents via statSync fallback", async () => {
|
|
199
|
+
// Regression: filesystems that report UV_DIRENT_UNKNOWN return Dirents
|
|
200
|
+
// whose isDirectory()/isFile()/isSymbolicLink() all report false. Those
|
|
201
|
+
// entries must fall back to statSync so /move still lists real
|
|
202
|
+
// directories on NFS/FUSE/older SMB.
|
|
203
|
+
const unknownFs = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-move-overlay-unknown-"));
|
|
204
|
+
try {
|
|
205
|
+
const realDir = path.join(unknownFs, "real-dir");
|
|
206
|
+
const realFile = path.join(unknownFs, "real-file.txt");
|
|
207
|
+
fs.mkdirSync(realDir);
|
|
208
|
+
fs.writeFileSync(realFile, "x");
|
|
209
|
+
|
|
210
|
+
const fakeDirent = (name: string): fs.Dirent =>
|
|
211
|
+
({
|
|
212
|
+
name,
|
|
213
|
+
isDirectory: () => false,
|
|
214
|
+
isFile: () => false,
|
|
215
|
+
isSymbolicLink: () => false,
|
|
216
|
+
isBlockDevice: () => false,
|
|
217
|
+
isCharacterDevice: () => false,
|
|
218
|
+
isFIFO: () => false,
|
|
219
|
+
isSocket: () => false,
|
|
220
|
+
}) as fs.Dirent;
|
|
221
|
+
const readdirSpy = spyOn(fs, "readdirSync").mockReturnValue([
|
|
222
|
+
fakeDirent("real-dir"),
|
|
223
|
+
fakeDirent("real-file.txt"),
|
|
224
|
+
] as never);
|
|
225
|
+
try {
|
|
226
|
+
const overlay = new MoveOverlay(unknownFs, () => {});
|
|
227
|
+
const text = strip(overlay.render(80));
|
|
228
|
+
expect(text).toContain("real-dir/");
|
|
229
|
+
expect(text).not.toContain("real-file.txt");
|
|
230
|
+
} finally {
|
|
231
|
+
readdirSpy.mockRestore();
|
|
232
|
+
}
|
|
233
|
+
} finally {
|
|
234
|
+
await fsp.rm(unknownFs, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
166
237
|
});
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type ToolUIColor,
|
|
8
8
|
wrapTextWithAnsi,
|
|
9
9
|
} from "../../tools/render-utils";
|
|
10
|
-
import { Ellipsis,
|
|
10
|
+
import { Ellipsis, truncateToWidth } from "../../tui";
|
|
11
11
|
import type { Theme } from "../theme/theme";
|
|
12
12
|
|
|
13
13
|
const COLLAPSED_NOTES = 3;
|
|
@@ -42,8 +42,10 @@ function severityColor(severity: AdvisorSeverity | undefined): ToolUIColor {
|
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Display-only transcript card for advisor notes injected into the primary
|
|
45
|
-
* session.
|
|
46
|
-
*
|
|
45
|
+
* session. Styled as a distinct voice so notes never blend into thinking
|
|
46
|
+
* output (whose `thinkingText` color equals `toolOutput` in most themes):
|
|
47
|
+
* a bold `customMessageLabel` header tag (skill-card convention), a heavy
|
|
48
|
+
* rail tinted per-note severity, and the note body on the default text color.
|
|
47
49
|
*/
|
|
48
50
|
export function createAdvisorMessageCard(
|
|
49
51
|
details: AdvisorMessageDetails | undefined,
|
|
@@ -58,9 +60,9 @@ export function createAdvisorMessageCard(
|
|
|
58
60
|
return createCachedComponent(
|
|
59
61
|
getExpanded,
|
|
60
62
|
(width, expanded) => {
|
|
61
|
-
const
|
|
62
|
-
const lines = [
|
|
63
|
-
const
|
|
63
|
+
const tag = uiTheme.fg("customMessageLabel", uiTheme.bold(`${uiTheme.status.info} Advisor`));
|
|
64
|
+
const lines = [`${tag} ${uiTheme.fg("dim", meta.join(uiTheme.sep.dot))}`];
|
|
65
|
+
const railGlyph = uiTheme.symbol("advisor.rail");
|
|
64
66
|
const shown = expanded ? notes : notes.slice(0, COLLAPSED_NOTES);
|
|
65
67
|
for (const entry of shown) {
|
|
66
68
|
const badge = entry.severity
|
|
@@ -72,8 +74,8 @@ export function createAdvisorMessageCard(
|
|
|
72
74
|
entry.advisor && entry.advisor !== "default"
|
|
73
75
|
? `${uiTheme.fg("dim", `[${replaceTabs(entry.advisor)}]`)} `
|
|
74
76
|
: "";
|
|
75
|
-
const
|
|
76
|
-
const quoteWidth = visibleWidth(
|
|
77
|
+
const rail = uiTheme.fg(severityColor(entry.severity), railGlyph);
|
|
78
|
+
const quoteWidth = visibleWidth(` ${railGlyph} `);
|
|
77
79
|
const badgeWidth = visibleWidth(badge);
|
|
78
80
|
const whoWidth = visibleWidth(who);
|
|
79
81
|
const w1 = Math.max(10, Math.min(NOTE_LINE_WIDTH, width) - quoteWidth - badgeWidth - whoWidth);
|
|
@@ -92,12 +94,13 @@ export function createAdvisorMessageCard(
|
|
|
92
94
|
|
|
93
95
|
bodyLines.forEach((line, index) => {
|
|
94
96
|
const prefix = index === 0 ? `${badge}${who}` : "";
|
|
95
|
-
lines.push(` ${
|
|
97
|
+
lines.push(` ${rail} ${prefix}${uiTheme.fg("customMessageText", replaceTabs(line))}`);
|
|
96
98
|
});
|
|
97
99
|
}
|
|
98
100
|
const hidden = notes.length - shown.length;
|
|
99
101
|
if (hidden > 0) {
|
|
100
|
-
|
|
102
|
+
const rail = uiTheme.fg("dim", railGlyph);
|
|
103
|
+
lines.push(` ${rail} ${uiTheme.fg("dim", `… +${hidden} more ${hidden === 1 ? "note" : "notes"}`)}`);
|
|
101
104
|
}
|
|
102
105
|
return lines.map(line => truncateToWidth(line, width, Ellipsis.Unicode));
|
|
103
106
|
},
|
|
@@ -708,7 +708,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
708
708
|
);
|
|
709
709
|
|
|
710
710
|
// Fast path: reuse Markdown children when shape is stable during streaming
|
|
711
|
-
if (this.#tryFastPathUpdate(message)) return;
|
|
711
|
+
if (this.#tryFastPathUpdate(message, opts)) return;
|
|
712
712
|
|
|
713
713
|
// Clear content container
|
|
714
714
|
this.#contentContainer.clear();
|
|
@@ -29,14 +29,14 @@ const OVERLAY_WIDTH = 68;
|
|
|
29
29
|
|
|
30
30
|
/** TTL for the directory listing cache (ms). */
|
|
31
31
|
const DIR_CACHE_TTL = 500;
|
|
32
|
-
const dirCache = new Map<string, { time: number; entries:
|
|
32
|
+
const dirCache = new Map<string, { time: number; entries: fs.Dirent[] }>();
|
|
33
33
|
|
|
34
|
-
function readDirCached(dir: string):
|
|
34
|
+
function readDirCached(dir: string): fs.Dirent[] {
|
|
35
35
|
const now = Date.now();
|
|
36
36
|
const cached = dirCache.get(dir);
|
|
37
37
|
if (cached && now - cached.time < DIR_CACHE_TTL) return cached.entries;
|
|
38
38
|
try {
|
|
39
|
-
const entries = fs.readdirSync(dir);
|
|
39
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
40
40
|
dirCache.set(dir, { time: now, entries });
|
|
41
41
|
return entries;
|
|
42
42
|
} catch {
|
|
@@ -44,6 +44,27 @@ function readDirCached(dir: string): string[] {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* `Dirent.isDirectory()` reports the entry type, not the link target, so a
|
|
49
|
+
* `statSync` fallback is still needed for symlinks that point at a directory.
|
|
50
|
+
* Some filesystems (NFS, FUSE, older SMB) report `UV_DIRENT_UNKNOWN` — every
|
|
51
|
+
* `isX()` returns false — so those entries also fall back to `statSync` rather
|
|
52
|
+
* than being silently dropped from the results.
|
|
53
|
+
*/
|
|
54
|
+
function entryIsDirectory(dir: string, entry: fs.Dirent): boolean {
|
|
55
|
+
if (entry.isDirectory()) return true;
|
|
56
|
+
// Fast reject only for entry types we can confidently identify as non-directory.
|
|
57
|
+
if (entry.isFile() || entry.isBlockDevice() || entry.isCharacterDevice() || entry.isFIFO() || entry.isSocket()) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
// Symlink (need target type) or unknown (filesystem didn't provide a type) — stat to find out.
|
|
61
|
+
try {
|
|
62
|
+
return fs.statSync(path.join(dir, entry.name)).isDirectory();
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
function printableInput(data: string): string {
|
|
48
69
|
const withoutPasteEnvelope = data.replaceAll("\x1b[200~", "").replaceAll("\x1b[201~", "");
|
|
49
70
|
if (withoutPasteEnvelope.includes("\x1b")) return "";
|
|
@@ -76,17 +97,13 @@ export function resolveExistingDirectory(input: string, cwd: string): string | n
|
|
|
76
97
|
|
|
77
98
|
function listChildDirectories(dirPath: string, max: number, includeHidden = false): DirEntry[] {
|
|
78
99
|
const results: DirEntry[] = [];
|
|
79
|
-
const
|
|
80
|
-
for (const
|
|
100
|
+
const entries = readDirCached(dirPath);
|
|
101
|
+
for (const entry of entries) {
|
|
81
102
|
if (results.length >= max) break;
|
|
103
|
+
const { name } = entry;
|
|
82
104
|
if (!includeHidden && name.startsWith(".")) continue;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (!fs.statSync(full).isDirectory()) continue;
|
|
86
|
-
} catch {
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
results.push({ value: full, label: `${name}/` });
|
|
105
|
+
if (!entryIsDirectory(dirPath, entry)) continue;
|
|
106
|
+
results.push({ value: path.join(dirPath, name), label: `${name}/` });
|
|
90
107
|
}
|
|
91
108
|
results.sort((a, b) => a.label.localeCompare(b.label));
|
|
92
109
|
return results;
|
|
@@ -118,19 +135,14 @@ function searchDirectories(prefix: string, cwd: string, max: number): DirEntry[]
|
|
|
118
135
|
|
|
119
136
|
const lower = query.toLowerCase();
|
|
120
137
|
const results: DirEntry[] = [];
|
|
121
|
-
const
|
|
122
|
-
for (const
|
|
138
|
+
const entries = readDirCached(baseDir);
|
|
139
|
+
for (const entry of entries) {
|
|
123
140
|
if (results.length >= max) break;
|
|
141
|
+
const { name } = entry;
|
|
124
142
|
if (!includeHidden && name.startsWith(".")) continue;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
} catch {
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
if (!query || name.toLowerCase().includes(lower)) {
|
|
132
|
-
results.push({ value: full, label: `${name}/` });
|
|
133
|
-
}
|
|
143
|
+
if (query && !name.toLowerCase().includes(lower)) continue;
|
|
144
|
+
if (!entryIsDirectory(baseDir, entry)) continue;
|
|
145
|
+
results.push({ value: path.join(baseDir, name), label: `${name}/` });
|
|
134
146
|
}
|
|
135
147
|
return results;
|
|
136
148
|
}
|