Merge PR #2992: feat(coding-agent): directory tab completion for /move (@KamijoToma)

This commit is contained in:
can1357
2026-06-27 02:04:52 +02:00
3 changed files with 250 additions and 3 deletions
+4
View File
@@ -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/");
});
});