diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 76286f5e..78b4f12d 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -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), ); diff --git a/packages/coding-agent/src/core/resolve-config-value.ts b/packages/coding-agent/src/core/resolve-config-value.ts index da127869..2f89accd 100644 --- a/packages/coding-agent/src/core/resolve-config-value.ts +++ b/packages/coding-agent/src/core/resolve-config-value.ts @@ -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(); @@ -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; diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index 50004bff..f70b2f99 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -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; diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index 09e66de5..eaac129d 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -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 }, }); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index ae900447..38398a54 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -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(); }); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 5d1f040f..010e9fd2 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -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) { 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), }); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 97ac0e5b..f0410d03 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -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(); }); diff --git a/packages/coding-agent/test/path-utils.test.ts b/packages/coding-agent/test/path-utils.test.ts index 19cc0db1..36b3d1ba 100644 --- a/packages/coding-agent/test/path-utils.test.ts +++ b/packages/coding-agent/test/path-utils.test.ts @@ -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")); }); }); diff --git a/packages/coding-agent/test/session-info-modified-timestamp.test.ts b/packages/coding-agent/test/session-info-modified-timestamp.test.ts index 7089cdb2..1dd1cb41 100644 --- a/packages/coding-agent/test/session-info-modified-timestamp.test.ts +++ b/packages/coding-agent/test/session-info-modified-timestamp.test.ts @@ -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); diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 3fb1b670..1e5937d5 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -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,