fix(coding-agent,tui): stabilize windows shell/path handling

fixes #1775

fixes #1769
This commit is contained in:
badlogic
2026-03-14 05:36:04 +01:00
parent 1a4d153d7a
commit 4f81c3c28d
10 changed files with 213 additions and 76 deletions

View File

@@ -125,6 +125,10 @@ function toPosixPath(p: string): string {
return p.split(sep).join("/");
}
function getHomeDir(): string {
return process.env.HOME || homedir();
}
function prefixIgnorePattern(line: string, prefix: string): string | null {
const trimmed = line.trim();
if (!trimmed) return null;
@@ -512,44 +516,55 @@ function collectResourceFiles(dir: string, resourceType: ResourceType): string[]
}
function matchesAnyPattern(filePath: string, patterns: string[], baseDir: string): boolean {
const rel = relative(baseDir, filePath);
const rel = toPosixPath(relative(baseDir, filePath));
const name = basename(filePath);
const filePathPosix = toPosixPath(filePath);
const isSkillFile = name === "SKILL.md";
const parentDir = isSkillFile ? dirname(filePath) : undefined;
const parentRel = isSkillFile ? relative(baseDir, parentDir!) : undefined;
const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir!)) : undefined;
const parentName = isSkillFile ? basename(parentDir!) : undefined;
const parentDirPosix = isSkillFile ? toPosixPath(parentDir!) : undefined;
return patterns.some((pattern) => {
if (minimatch(rel, pattern) || minimatch(name, pattern) || minimatch(filePath, pattern)) {
const normalizedPattern = toPosixPath(pattern);
if (
minimatch(rel, normalizedPattern) ||
minimatch(name, normalizedPattern) ||
minimatch(filePathPosix, normalizedPattern)
) {
return true;
}
if (!isSkillFile) return false;
return minimatch(parentRel!, pattern) || minimatch(parentName!, pattern) || minimatch(parentDir!, pattern);
return (
minimatch(parentRel!, normalizedPattern) ||
minimatch(parentName!, normalizedPattern) ||
minimatch(parentDirPosix!, normalizedPattern)
);
});
}
function normalizeExactPattern(pattern: string): string {
if (pattern.startsWith("./") || pattern.startsWith(".\\")) {
return pattern.slice(2);
}
return pattern;
const normalized = pattern.startsWith("./") || pattern.startsWith(".\\") ? pattern.slice(2) : pattern;
return toPosixPath(normalized);
}
function matchesAnyExactPattern(filePath: string, patterns: string[], baseDir: string): boolean {
if (patterns.length === 0) return false;
const rel = relative(baseDir, filePath);
const rel = toPosixPath(relative(baseDir, filePath));
const name = basename(filePath);
const filePathPosix = toPosixPath(filePath);
const isSkillFile = name === "SKILL.md";
const parentDir = isSkillFile ? dirname(filePath) : undefined;
const parentRel = isSkillFile ? relative(baseDir, parentDir!) : undefined;
const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir!)) : undefined;
const parentDirPosix = isSkillFile ? toPosixPath(parentDir!) : undefined;
return patterns.some((pattern) => {
const normalized = normalizeExactPattern(pattern);
if (normalized === rel || normalized === filePath) {
if (normalized === rel || normalized === filePathPosix) {
return true;
}
if (!isSkillFile) return false;
return normalized === parentRel || normalized === parentDir;
return normalized === parentRel || normalized === parentDirPosix;
});
}
@@ -1385,17 +1400,17 @@ export class DefaultPackageManager implements PackageManager {
private resolvePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
if (trimmed === "~") return getHomeDir();
if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1));
return resolve(this.cwd, trimmed);
}
private resolvePathFromBase(input: string, baseDir: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
if (trimmed === "~") return getHomeDir();
if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1));
return resolve(baseDir, trimmed);
}
@@ -1632,7 +1647,7 @@ export class DefaultPackageManager implements PackageManager {
prompts: join(projectBaseDir, "prompts"),
themes: join(projectBaseDir, "themes"),
};
const userAgentsSkillsDir = join(homedir(), ".agents", "skills");
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
const projectAgentsSkillDirs = collectAncestorAgentsSkillDirs(this.cwd).filter(
(dir) => resolve(dir) !== resolve(userAgentsSkillsDir),
);

View File

@@ -3,7 +3,8 @@
* Used by auth-storage.ts and model-registry.ts.
*/
import { execSync } from "child_process";
import { execSync, spawnSync } from "child_process";
import { getShellConfig } from "../utils/shell.js";
// Cache for shell command results (persists for process lifetime)
const commandResultCache = new Map<string, string | undefined>();
@@ -21,23 +22,62 @@ export function resolveConfigValue(config: string): string | undefined {
return envValue || config;
}
function executeCommand(commandConfig: string): string | undefined {
if (commandResultCache.has(commandConfig)) {
return commandResultCache.get(commandConfig);
}
function executeWithConfiguredShell(command: string): { executed: boolean; value: string | undefined } {
try {
const { shell, args } = getShellConfig();
const result = spawnSync(shell, [...args, command], {
encoding: "utf-8",
timeout: 10000,
stdio: ["ignore", "pipe", "ignore"],
shell: false,
windowsHide: true,
});
const command = commandConfig.slice(1);
let result: string | undefined;
if (result.error) {
const error = result.error as NodeJS.ErrnoException;
if (error.code === "ENOENT") {
return { executed: false, value: undefined };
}
return { executed: true, value: undefined };
}
if (result.status !== 0) {
return { executed: true, value: undefined };
}
const value = (result.stdout ?? "").trim();
return { executed: true, value: value || undefined };
} catch {
return { executed: false, value: undefined };
}
}
function executeWithDefaultShell(command: string): string | undefined {
try {
const output = execSync(command, {
encoding: "utf-8",
timeout: 10000,
stdio: ["ignore", "pipe", "ignore"],
});
result = output.trim() || undefined;
return output.trim() || undefined;
} catch {
result = undefined;
return undefined;
}
}
function executeCommand(commandConfig: string): string | undefined {
if (commandResultCache.has(commandConfig)) {
return commandResultCache.get(commandConfig);
}
const command = commandConfig.slice(1);
const result =
process.platform === "win32"
? (() => {
const configuredResult = executeWithConfiguredShell(command);
return configuredResult.executed ? configuredResult.value : executeWithDefaultShell(command);
})()
: executeWithDefaultShell(command);
commandResultCache.set(commandConfig, result);
return result;

View File

@@ -8,6 +8,10 @@ import { ensureTool } from "../../utils/tools-manager.js";
import { resolveToCwd } from "./path-utils.js";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
function toPosixPath(value: string): string {
return value.split(path.sep).join("/");
}
const findSchema = Type.Object({
pattern: Type.String({
description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'",
@@ -102,9 +106,9 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
// Relativize paths
const relativized = results.map((p) => {
if (p.startsWith(searchPath)) {
return p.slice(searchPath.length + 1);
return toPosixPath(p.slice(searchPath.length + 1));
}
return path.relative(searchPath, p);
return toPosixPath(path.relative(searchPath, p));
});
const resultLimitReached = relativized.length >= effectiveLimit;
@@ -228,7 +232,7 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
relativePath += "/";
}
relativized.push(relativePath);
relativized.push(toPosixPath(relativePath));
}
const resultLimitReached = relativized.length >= effectiveLimit;

View File

@@ -30,6 +30,10 @@ describe("AuthStorage", () => {
writeFileSync(authJsonPath, JSON.stringify(data));
}
function toShPath(value: string): string {
return value.replace(/\\/g, "/").replace(/"/g, '\\"');
}
describe("API key resolution", () => {
test("literal API key is returned directly", async () => {
writeAuthJson({
@@ -161,7 +165,8 @@ describe("AuthStorage", () => {
const counterFile = join(tempDir, "counter");
writeFileSync(counterFile, "0");
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`;
const counterPath = toShPath(counterFile);
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
writeAuthJson({
anthropic: { type: "api_key", key: command },
});
@@ -182,7 +187,8 @@ describe("AuthStorage", () => {
const counterFile = join(tempDir, "counter");
writeFileSync(counterFile, "0");
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`;
const counterPath = toShPath(counterFile);
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
writeAuthJson({
anthropic: { type: "api_key", key: command },
});
@@ -203,7 +209,8 @@ describe("AuthStorage", () => {
const counterFile = join(tempDir, "counter");
writeFileSync(counterFile, "0");
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`;
const counterPath = toShPath(counterFile);
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
writeAuthJson({
anthropic: { type: "api_key", key: command },
});
@@ -239,7 +246,8 @@ describe("AuthStorage", () => {
const counterFile = join(tempDir, "counter");
writeFileSync(counterFile, "0");
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; exit 1'`;
const counterPath = toShPath(counterFile);
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; exit 1'`;
writeAuthJson({
anthropic: { type: "api_key", key: command },
});

View File

@@ -127,9 +127,12 @@ describe("ExtensionRunner", () => {
});
it("warns but allows when extension uses non-reserved built-in shortcut", async () => {
const pasteImageKey = Array.isArray(DEFAULT_KEYBINDINGS.pasteImage)
? (DEFAULT_KEYBINDINGS.pasteImage[0] ?? "")
: DEFAULT_KEYBINDINGS.pasteImage;
const extCode = `
export default function(pi) {
pi.registerShortcut("ctrl+v", {
pi.registerShortcut("${pasteImageKey}", {
description: "Overrides non-reserved",
handler: async () => {},
});
@@ -144,7 +147,7 @@ describe("ExtensionRunner", () => {
const shortcuts = runner.getShortcuts(DEFAULT_KEYBINDINGS);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("built-in shortcut for pasteImage"));
expect(shortcuts.has("ctrl+v")).toBe(true);
expect(shortcuts.has(pasteImageKey as KeyId)).toBe(true);
warnSpy.mockRestore();
});

View File

@@ -57,6 +57,10 @@ describe("ModelRegistry", () => {
return registry.getAll().filter((m) => m.provider === provider);
}
function toShPath(value: string): string {
return value.replace(/\\/g, "/").replace(/"/g, '\\"');
}
/** Create a baseUrl-only override (no custom models) */
function overrideConfig(baseUrl: string, headers?: Record<string, string>) {
return { baseUrl, ...(headers && { headers }) };
@@ -897,7 +901,8 @@ describe("ModelRegistry", () => {
const counterFile = join(tempDir, "counter");
writeFileSync(counterFile, "0");
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`;
const counterPath = toShPath(counterFile);
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
writeRawModelsJson({
"custom-provider": providerWithApiKey(command),
});
@@ -918,7 +923,8 @@ describe("ModelRegistry", () => {
const counterFile = join(tempDir, "counter");
writeFileSync(counterFile, "0");
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`;
const counterPath = toShPath(counterFile);
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
writeRawModelsJson({
"custom-provider": providerWithApiKey(command),
});
@@ -939,7 +945,8 @@ describe("ModelRegistry", () => {
const counterFile = join(tempDir, "counter");
writeFileSync(counterFile, "0");
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`;
const counterPath = toShPath(counterFile);
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
writeRawModelsJson({
"custom-provider": providerWithApiKey(command),
});
@@ -975,7 +982,8 @@ describe("ModelRegistry", () => {
const counterFile = join(tempDir, "counter");
writeFileSync(counterFile, "0");
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; exit 1'`;
const counterPath = toShPath(counterFile);
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; exit 1'`;
writeRawModelsJson({
"custom-provider": providerWithApiKey(command),
});

View File

@@ -5,12 +5,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DefaultPackageManager, type ProgressEvent, type ResolvedResource } from "../src/core/package-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
// Helper to check if a resource is enabled
const isEnabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") =>
matchFn === "endsWith" ? r.path.endsWith(pathMatch) && r.enabled : r.path.includes(pathMatch) && r.enabled;
function normalizeForMatch(value: string): string {
return value.replace(/\\/g, "/");
}
const isDisabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") =>
matchFn === "endsWith" ? r.path.endsWith(pathMatch) && !r.enabled : r.path.includes(pathMatch) && !r.enabled;
function pathEndsWith(actualPath: string, suffix: string): boolean {
return normalizeForMatch(actualPath).endsWith(normalizeForMatch(suffix));
}
// Helper to check if a resource is enabled
const isEnabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") => {
const normalizedPath = normalizeForMatch(r.path);
const normalizedMatch = normalizeForMatch(pathMatch);
return matchFn === "endsWith"
? normalizedPath.endsWith(normalizedMatch) && r.enabled
: normalizedPath.includes(normalizedMatch) && r.enabled;
};
const isDisabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") => {
const normalizedPath = normalizeForMatch(r.path);
const normalizedMatch = normalizeForMatch(pathMatch);
return matchFn === "endsWith"
? normalizedPath.endsWith(normalizedMatch) && !r.enabled
: normalizedPath.includes(normalizedMatch) && !r.enabled;
};
describe("DefaultPackageManager", () => {
let tempDir: string;
@@ -155,7 +173,7 @@ Content`,
);
// Should NOT find helper.ts (not declared in manifest)
expect(result.extensions.some((r) => r.path.endsWith("helper.ts"))).toBe(false);
expect(result.extensions.some((r) => pathEndsWith(r.path, "helper.ts"))).toBe(false);
});
});
@@ -331,8 +349,8 @@ Content`,
writeFileSync(join(pkgDir, "themes", "dark.json"), "{}");
const result = await packageManager.resolveExtensionSources([pkgDir]);
expect(result.extensions.some((r) => r.path.endsWith("main.ts") && r.enabled)).toBe(true);
expect(result.themes.some((r) => r.path.endsWith("dark.json") && r.enabled)).toBe(true);
expect(result.extensions.some((r) => pathEndsWith(r.path, "main.ts") && r.enabled)).toBe(true);
expect(result.themes.some((r) => pathEndsWith(r.path, "dark.json") && r.enabled)).toBe(true);
});
});
@@ -731,7 +749,7 @@ Content`,
const result = await packageManager.resolveExtensionSources([pkgDir]);
expect(result.extensions.some((r) => isEnabled(r, "local.ts"))).toBe(true);
expect(result.extensions.some((r) => isEnabled(r, "remote.ts"))).toBe(true);
expect(result.extensions.some((r) => r.path.endsWith("skip.ts"))).toBe(false);
expect(result.extensions.some((r) => pathEndsWith(r.path, "skip.ts"))).toBe(false);
});
it("should support glob patterns in manifest skills", async () => {
@@ -798,7 +816,7 @@ Content`,
// bar.ts should be excluded (by user)
expect(result.extensions.some((r) => isDisabled(r, "bar.ts"))).toBe(true);
// baz.ts should be excluded (by manifest)
expect(result.extensions.some((r) => r.path.endsWith("baz.ts"))).toBe(false);
expect(result.extensions.some((r) => pathEndsWith(r.path, "baz.ts"))).toBe(false);
});
it("should exclude extensions from package with ! pattern", async () => {
@@ -1194,11 +1212,11 @@ export default function(api) { api.registerTool({ name: "test", description: "te
const result = await packageManager.resolveExtensionSources([pkgDir]);
// Should find the index.ts and standalone.ts
expect(result.extensions.some((r) => r.path.endsWith("subagent/index.ts") && r.enabled)).toBe(true);
expect(result.extensions.some((r) => r.path.endsWith("standalone.ts") && r.enabled)).toBe(true);
expect(result.extensions.some((r) => pathEndsWith(r.path, "subagent/index.ts") && r.enabled)).toBe(true);
expect(result.extensions.some((r) => pathEndsWith(r.path, "standalone.ts") && r.enabled)).toBe(true);
// Should NOT find agents.ts as a standalone extension
expect(result.extensions.some((r) => r.path.endsWith("agents.ts"))).toBe(false);
expect(result.extensions.some((r) => pathEndsWith(r.path, "agents.ts"))).toBe(false);
});
it("should respect package.json pi.extensions manifest in subdirectories", async () => {
@@ -1220,10 +1238,10 @@ export default function(api) { api.registerTool({ name: "test", description: "te
const result = await packageManager.resolveExtensionSources([pkgDir]);
// Should find main.ts declared in manifest
expect(result.extensions.some((r) => r.path.endsWith("custom/main.ts") && r.enabled)).toBe(true);
expect(result.extensions.some((r) => pathEndsWith(r.path, "custom/main.ts") && r.enabled)).toBe(true);
// Should NOT find utils.ts (not declared in manifest)
expect(result.extensions.some((r) => r.path.endsWith("utils.ts"))).toBe(false);
expect(result.extensions.some((r) => pathEndsWith(r.path, "utils.ts"))).toBe(false);
});
it("should handle mixed top-level files and subdirectories", async () => {
@@ -1244,12 +1262,12 @@ export default function(api) { api.registerTool({ name: "test", description: "te
const result = await packageManager.resolveExtensionSources([pkgDir]);
// Should find simple.ts and complex/index.ts
expect(result.extensions.some((r) => r.path.endsWith("simple.ts") && r.enabled)).toBe(true);
expect(result.extensions.some((r) => r.path.endsWith("complex/index.ts") && r.enabled)).toBe(true);
expect(result.extensions.some((r) => pathEndsWith(r.path, "simple.ts") && r.enabled)).toBe(true);
expect(result.extensions.some((r) => pathEndsWith(r.path, "complex/index.ts") && r.enabled)).toBe(true);
// Should NOT find helper modules
expect(result.extensions.some((r) => r.path.endsWith("complex/a.ts"))).toBe(false);
expect(result.extensions.some((r) => r.path.endsWith("complex/b.ts"))).toBe(false);
expect(result.extensions.some((r) => pathEndsWith(r.path, "complex/a.ts"))).toBe(false);
expect(result.extensions.some((r) => pathEndsWith(r.path, "complex/b.ts"))).toBe(false);
// Total should be exactly 2
expect(result.extensions.filter((r) => r.enabled).length).toBe(2);
@@ -1269,7 +1287,7 @@ export default function(api) { api.registerTool({ name: "test", description: "te
const result = await packageManager.resolveExtensionSources([pkgDir]);
// Should only find the valid top-level extension
expect(result.extensions.some((r) => r.path.endsWith("valid.ts") && r.enabled)).toBe(true);
expect(result.extensions.some((r) => pathEndsWith(r.path, "valid.ts") && r.enabled)).toBe(true);
expect(result.extensions.filter((r) => r.enabled).length).toBe(1);
});
});
@@ -1299,7 +1317,7 @@ export default function(api) { api.registerTool({ name: "test", description: "te
const refreshTemporaryGitSourceSpy = vi.spyOn(packageManager as any, "refreshTemporaryGitSource");
const result = await packageManager.resolveExtensionSources([gitSource], { temporary: true });
expect(result.extensions.some((r) => r.path.endsWith("extensions/index.ts") && r.enabled)).toBe(true);
expect(result.extensions.some((r) => pathEndsWith(r.path, "extensions/index.ts") && r.enabled)).toBe(true);
expect(refreshTemporaryGitSourceSpy).not.toHaveBeenCalled();
});

View File

@@ -1,6 +1,6 @@
import { mkdtempSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { expandPath, resolveReadPath, resolveToCwd } from "../src/core/tools/path-utils.js";
@@ -32,7 +32,7 @@ describe("path-utils", () => {
it("should resolve relative paths against cwd", () => {
const result = resolveToCwd("relative/file.txt", "/some/cwd");
expect(result).toBe("/some/cwd/relative/file.txt");
expect(result).toBe(resolve("/some/cwd", "relative/file.txt"));
});
});

View File

@@ -1,7 +1,7 @@
import { writeFileSync } from "node:fs";
import { stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { SessionHeader } from "../src/core/session-manager.js";
import { SessionManager } from "../src/core/session-manager.js";
@@ -74,7 +74,7 @@ describe("SessionInfo.modified", () => {
timestamp: msgTime,
});
const sessions = await SessionManager.list("/tmp", filePath.replace(/\/[^/]+$/, ""));
const sessions = await SessionManager.list("/tmp", dirname(filePath));
const s = sessions.find((x) => x.path === filePath);
expect(s).toBeDefined();
expect(s!.modified.getTime()).toBe(msgTime);

View File

@@ -6,6 +6,42 @@ import { fuzzyFilter } from "./fuzzy.js";
const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
function toDisplayPath(value: string): string {
return value.replace(/\\/g, "/");
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function buildFdPathQuery(query: string): string {
const normalized = toDisplayPath(query);
if (!normalized.includes("/")) {
return normalized;
}
const hasTrailingSeparator = normalized.endsWith("/");
const trimmed = normalized.replace(/^\/+|\/+$/g, "");
if (!trimmed) {
return normalized;
}
const separatorPattern = "[\\\\/]";
const segments = trimmed
.split("/")
.filter(Boolean)
.map((segment) => escapeRegex(segment));
if (segments.length === 0) {
return normalized;
}
let pattern = segments.join(separatorPattern);
if (hasTrailingSeparator) {
pattern += separatorPattern;
}
return pattern;
}
function findLastDelimiter(text: string): number {
for (let i = text.length - 1; i >= 0; i -= 1) {
if (PATH_DELIMITERS.has(text[i] ?? "")) {
@@ -112,7 +148,7 @@ function walkDirectoryWithFd(
// Add query as pattern if provided
if (query) {
args.push(query);
args.push(buildFdPathQuery(query));
}
const result = spawnSync(fdPath, args, {
@@ -129,15 +165,17 @@ function walkDirectoryWithFd(
const results: Array<{ path: string; isDirectory: boolean }> = [];
for (const line of lines) {
const normalizedPath = line.endsWith("/") ? line.slice(0, -1) : line;
const displayLine = toDisplayPath(line);
const hasTrailingSeparator = displayLine.endsWith("/");
const normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine;
if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) {
continue;
}
// fd outputs directories with trailing /
const isDirectory = line.endsWith("/");
const isDirectory = hasTrailingSeparator;
results.push({
path: line,
path: displayLine,
isDirectory,
});
}
@@ -448,13 +486,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
}
private resolveScopedFuzzyQuery(rawQuery: string): { baseDir: string; query: string; displayBase: string } | null {
const slashIndex = rawQuery.lastIndexOf("/");
const normalizedQuery = toDisplayPath(rawQuery);
const slashIndex = normalizedQuery.lastIndexOf("/");
if (slashIndex === -1) {
return null;
}
const displayBase = rawQuery.slice(0, slashIndex + 1);
const query = rawQuery.slice(slashIndex + 1);
const displayBase = normalizedQuery.slice(0, slashIndex + 1);
const query = normalizedQuery.slice(slashIndex + 1);
let baseDir: string;
if (displayBase.startsWith("~/")) {
@@ -477,10 +516,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
}
private scopedPathForDisplay(displayBase: string, relativePath: string): string {
const normalizedRelativePath = toDisplayPath(relativePath);
if (displayBase === "/") {
return `/${relativePath}`;
return `/${normalizedRelativePath}`;
}
return `${displayBase}${relativePath}`;
return `${toDisplayPath(displayBase)}${normalizedRelativePath}`;
}
// Get file/directory suggestions for a given path prefix
@@ -559,7 +599,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
if (displayPrefix.endsWith("/")) {
// If prefix ends with /, append entry to the prefix
relativePath = displayPrefix + name;
} else if (displayPrefix.includes("/")) {
} else if (displayPrefix.includes("/") || displayPrefix.includes("\\")) {
// Preserve ~/ format for home directory paths
if (displayPrefix.startsWith("~/")) {
const homeRelativeDir = displayPrefix.slice(2); // Remove ~/
@@ -589,6 +629,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
}
}
relativePath = toDisplayPath(relativePath);
const pathValue = isDirectory ? `${relativePath}/` : relativePath;
const value = buildCompletionValue(pathValue, {
isDirectory,