diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d95c8165d..80482db72 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -29,6 +29,10 @@ - Added an interactive `/move` overlay: typing `/move` with no argument opens a path autocomplete picker (type to filter, ↑↓ to navigate, Tab to accept, Enter to confirm). `/move ` still works for direct invocation. The command now starts a fresh empty session in the target directory instead of relocating the current session file, leaving the previous session resumable via `/resume`. If the target directory does not exist, a confirmation prompt offers to create it. Empty move sessions (no user/assistant messages) are automatically cleaned up on shutdown so they don't accumulate. +### Added + +- Added Tab completion for `/move` destination directories. Typing `/move ` now offers directory suggestions relative to the current working directory, including support for `~`, absolute, and relative paths. + ### Fixed - Fixed `snapcompact` compaction silently falling back to an LLM summary when local preflight rejects the archive; manual and auto snapcompact now fail locally with the blocker instead of making provider calls. ([#3599](https://github.com/can1357/oh-my-pi/issues/3599)) diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 4d4c2c5a0..315586223 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui"; -import { APP_NAME, setProjectDir } from "@oh-my-pi/pi-utils"; +import { APP_NAME, getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils"; import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest"; import { CollabHost } from "../collab/host"; import type { SettingPath, SettingValue } from "../config/settings"; @@ -30,7 +30,7 @@ import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes"; import { resolveResumableSession } from "../session/session-listing"; import { SessionManager } from "../session/session-manager"; import { formatShakeSummary, type ShakeMode } from "../session/shake-types"; -import { resolveToCwd } from "../tools/path-utils"; +import { expandTilde, resolveToCwd } from "../tools/path-utils"; import { urlHyperlinkAlways } from "../tui"; import { getChangelogPath, parseChangelog } from "../utils/changelog"; import { CollabQrCodeComponent } from "./helpers/collab-qrcode"; @@ -61,7 +61,7 @@ export type { BuiltinSlashCommand, SubcommandDef } from "./types"; export type BuiltinSlashCommandRuntime = TuiSlashCommandRuntime; export interface TuiBuiltinSlashCommand extends BuiltinSlashCommand { - getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null; + getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null | Promise; getInlineHint?: (argumentText: string) => string | null; getAutocompleteDescription?: () => string | undefined; } @@ -2308,6 +2308,106 @@ function buildStaticInlineHint(hint: string): (argumentText: string) => string | return (argumentText: string) => (argumentText.trim().length === 0 ? hint : null); } +/** + * Build getArgumentCompletions that suggests directories relative to the + * current project directory. Used by /move so users can Tab-complete the + * destination directory. + */ +function buildDirectoryArgumentCompletions(): (prefix: string) => Promise { + return async (argumentPrefix: string) => { + const prefix = argumentPrefix.trim(); + + const cwd = getProjectDir(); + const expandedPrefix = expandTilde(prefix); + const isAbsolute = path.isAbsolute(expandedPrefix); + + let searchDir: string; + let searchPrefix: string; + if ( + prefix === "" || + prefix === "." || + prefix === "./" || + prefix === ".." || + prefix === "../" || + prefix === "~" || + prefix === "~/" || + prefix === "/" + ) { + searchDir = isAbsolute ? expandedPrefix : path.join(cwd, expandedPrefix); + searchPrefix = ""; + } else if (expandedPrefix.endsWith("/")) { + searchDir = isAbsolute ? expandedPrefix : path.join(cwd, expandedPrefix); + searchPrefix = ""; + } else { + const dir = path.dirname(expandedPrefix); + searchDir = isAbsolute ? dir : path.join(cwd, dir); + searchPrefix = path.basename(expandedPrefix); + } + + try { + const entries = await fs.readdir(searchDir, { withFileTypes: true }); + const suggestions: AutocompleteItem[] = []; + for (const entry of entries) { + if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) continue; + if (entry.name === ".git") continue; + + let isDirectory = entry.isDirectory(); + if (!isDirectory && entry.isSymbolicLink()) { + try { + isDirectory = (await fs.stat(path.join(searchDir, entry.name))).isDirectory(); + } catch { + continue; + } + } + if (!isDirectory) continue; + + const absoluteValue = path.join(searchDir, entry.name); + const displayValue = buildDirectoryCompletionDisplayValue(prefix, absoluteValue, cwd); + suggestions.push({ value: displayValue, label: `${entry.name}/` }); + } + suggestions.sort((a, b) => a.label.localeCompare(b.label)); + return suggestions.length > 0 ? suggestions : null; + } catch { + return null; + } + }; +} +function buildDirectoryCompletionDisplayValue(prefix: string, absoluteValue: string, cwd: string): string { + // Preserve the user's prefix style where possible, but always return a + // value that /move can resolve (absolute or relative) without escaping. + const normalized = path.normalize(absoluteValue); + + if (prefix.startsWith("~/")) { + const home = os.homedir(); + const homeRelative = path.relative(home, normalized); + return `~/${homeRelative.replaceAll("\\", "/")}/`; + } + if (prefix === "~") { + const home = os.homedir(); + const homeRelative = path.relative(home, normalized); + return `~/${homeRelative.replaceAll("\\", "/")}/`; + } + if (prefix.startsWith("/")) { + return `${normalized.replaceAll("\\", "/")}/`; + } + if (prefix.startsWith("./")) { + const relative = path.relative(cwd, normalized); + return `./${relative.replaceAll("\\", "/")}/`; + } + if (prefix.startsWith("../")) { + const relative = path.relative(cwd, normalized); + return `${relative.replaceAll("\\", "/")}/`; + } + if (prefix === "..") { + const relative = path.relative(cwd, normalized); + return `${relative.replaceAll("\\", "/")}/`; + } + + // Default: relative to cwd. + const relative = path.relative(cwd, normalized); + return `${relative.replaceAll("\\", "/")}/`; +} + /** Builtin command metadata used for slash-command autocomplete and help text. */ export const BUILTIN_SLASH_COMMAND_DEFS: ReadonlyArray = BUILTIN_SLASH_COMMAND_REGISTRY.map( command => ({ @@ -2328,6 +2428,9 @@ function materializeTuiBuiltinSlashCommand( if (cmd.subcommands) { materialized.getArgumentCompletions = buildArgumentCompletions(cmd.subcommands); materialized.getInlineHint = buildSubcommandInlineHint(cmd.subcommands); + } else if (cmd.name === "move") { + materialized.getArgumentCompletions = buildDirectoryArgumentCompletions(); + if (cmd.inlineHint) materialized.getInlineHint = buildStaticInlineHint(cmd.inlineHint); } else if (cmd.inlineHint) { materialized.getInlineHint = buildStaticInlineHint(cmd.inlineHint); } diff --git a/packages/coding-agent/test/slash-commands/move-completion.test.ts b/packages/coding-agent/test/slash-commands/move-completion.test.ts new file mode 100644 index 000000000..a9eea4071 --- /dev/null +++ b/packages/coding-agent/test/slash-commands/move-completion.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { BUILTIN_SLASH_COMMANDS } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry"; +import * as piUtils from "@oh-my-pi/pi-utils"; + +describe("/move directory completion", () => { + let tempDir: string; + const move = BUILTIN_SLASH_COMMANDS.find(c => c.name === "move"); + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-move-completion-")); + vi.spyOn(piUtils, "getProjectDir").mockReturnValue(tempDir); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("is wired to the /move command", () => { + expect(move).toBeDefined(); + expect(move!.getArgumentCompletions).toBeDefined(); + }); + + it("lists directories in the current project dir when no prefix is given", async () => { + await fs.mkdir(path.join(tempDir, "src")); + await fs.mkdir(path.join(tempDir, "tests")); + await fs.writeFile(path.join(tempDir, "README.md"), ""); + + const result = await move!.getArgumentCompletions!(""); + expect(result).not.toBeNull(); + const values = result!.map(i => i.value); + expect(values).toContain("src/"); + expect(values).toContain("tests/"); + expect(values).not.toContain("README.md"); + }); + + it("filters directories by prefix", async () => { + await fs.mkdir(path.join(tempDir, "src")); + await fs.mkdir(path.join(tempDir, "scripts")); + await fs.mkdir(path.join(tempDir, "tests")); + + const result = await move!.getArgumentCompletions!("sr"); + expect(result).not.toBeNull(); + const values = result!.map(i => i.value); + expect(values).toContain("src/"); + expect(values).not.toContain("scripts/"); + expect(values).not.toContain("tests/"); + }); + + it("completes inside a subdirectory", async () => { + const subDir = path.join(tempDir, "packages"); + await fs.mkdir(subDir); + await fs.mkdir(path.join(subDir, "coding-agent")); + await fs.mkdir(path.join(subDir, "tui")); + + const result = await move!.getArgumentCompletions!("packages/"); + expect(result).not.toBeNull(); + const values = result!.map(i => i.value); + expect(values).toContain("packages/coding-agent/"); + expect(values).toContain("packages/tui/"); + }); + + it("completes relative paths", async () => { + await fs.mkdir(path.join(tempDir, "src")); + + const result = await move!.getArgumentCompletions!("./sr"); + expect(result).not.toBeNull(); + expect(result!.map(i => i.value)).toContain("./src/"); + }); + + it("completes parent directory paths", async () => { + const parentDir = path.dirname(tempDir); + const siblingName = `omp-move-sibling-${path.basename(tempDir)}`; + const siblingDir = path.join(parentDir, siblingName); + await fs.mkdir(siblingDir); + try { + const result = await move!.getArgumentCompletions!(".."); + expect(result).not.toBeNull(); + expect(result!.map(i => i.value)).toContain(`../${siblingName}/`); + } finally { + await fs.rm(siblingDir, { recursive: true, force: true }); + } + }); + + it("completes directories with spaces in names", async () => { + const spacedDir = path.join(tempDir, "My Project"); + await fs.mkdir(spacedDir); + await fs.mkdir(path.join(spacedDir, "src")); + + const result = await move!.getArgumentCompletions!("My Project/"); + expect(result).not.toBeNull(); + expect(result!.map(i => i.value)).toContain("My Project/src/"); + }); + + it("filters inside a space-containing directory", async () => { + const spacedDir = path.join(tempDir, "My Project"); + await fs.mkdir(spacedDir); + await fs.mkdir(path.join(spacedDir, "src")); + await fs.mkdir(path.join(spacedDir, "tests")); + + const result = await move!.getArgumentCompletions!("My Project/sr"); + expect(result).not.toBeNull(); + const values = result!.map(i => i.value); + expect(values).toContain("My Project/src/"); + expect(values).not.toContain("My Project/tests/"); + }); + + it("returns null for non-matching prefixes", async () => { + await fs.mkdir(path.join(tempDir, "src")); + + const result = await move!.getArgumentCompletions!("xyz"); + expect(result).toBeNull(); + }); + + it("completes home-relative paths", async () => { + const homeDir = path.join(tempDir, "fake-home"); + await fs.mkdir(homeDir); + await fs.mkdir(path.join(homeDir, "project-a")); + await fs.mkdir(path.join(homeDir, "project-b")); + vi.spyOn(os, "homedir").mockReturnValue(homeDir); + + const result = await move!.getArgumentCompletions!("~/"); + expect(result).not.toBeNull(); + const values = result!.map(i => i.value); + expect(values).toContain("~/project-a/"); + expect(values).toContain("~/project-b/"); + }); + + it("completes absolute paths", async () => { + const targetDir = path.join(tempDir, "absolute-target"); + await fs.mkdir(targetDir); + + const result = await move!.getArgumentCompletions!(path.join(tempDir, "absolute-")); + expect(result).not.toBeNull(); + expect(result!.map(i => i.label)).toContain("absolute-target/"); + }); +});