Merge PR #2992: feat(coding-agent): directory tab completion for /move (@KamijoToma)
This commit is contained in:
@@ -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 <path>` 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 <path>` 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))
|
||||
|
||||
@@ -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<AutocompleteItem[] | null>;
|
||||
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<AutocompleteItem[] | null> {
|
||||
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<BuiltinSlashCommand> = 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);
|
||||
}
|
||||
|
||||
@@ -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/");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user