Merge PR #3401: feat(move): interactive /move overlay with fresh session (@oldschoola)
This commit is contained in:
@@ -24,6 +24,10 @@
|
||||
|
||||
- Added `ssh://host/path` support to `read`, `search`, and `write` for single text files on pre-configured SSH hosts (or `~/.ssh/config` aliases); POSIX remotes only (Linux/macOS/BSD; Windows hosts are rejected — use the `ssh` tool), UTF-8 text only, up to 1 MiB. Requires the same approval as the `ssh` tool, rejects argument-injecting hosts/usernames (leading `-`), validates the entire file as UTF-8, peels read selectors so `write` targets the same file `read` does, and replaces (rather than writes through) a non-directory symlinked write destination via a uniquely named remote temp. `read` also lists an `ssh://` directory one level deep (dotfiles included, directories first; `ssh://host/` lists the remote root), while `search` refuses an `ssh://` directory and `write` refuses to overwrite one. Bare `ssh://` lists the configured hosts, which are also offered as autocompletions after typing `ssh://`.
|
||||
|
||||
### Added
|
||||
|
||||
- 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.
|
||||
|
||||
### 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))
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as fsp from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Settings } from "../../../config/settings";
|
||||
import { getThemeByName, setThemeInstance, type Theme } from "../../theme/theme";
|
||||
import { MoveOverlay, type MoveOverlayResult, resolveExistingDirectory, resolveMovePath } from "../move-overlay";
|
||||
|
||||
// Strip SGR colors so assertions see visible text only.
|
||||
const strip = (lines: readonly string[]): string => lines.join("\n").replace(/\x1b\[[0-9;]*m/g, "");
|
||||
|
||||
describe("resolveMovePath", () => {
|
||||
it("expands ~ to homedir", () => {
|
||||
expect(resolveMovePath("~", "/anywhere")).toBe(os.homedir());
|
||||
});
|
||||
it("expands ~/sub to homedir/sub", () => {
|
||||
expect(resolveMovePath("~/foo", "/anywhere")).toBe(path.join(os.homedir(), "foo"));
|
||||
});
|
||||
it("resolves relative paths against cwd", () => {
|
||||
expect(resolveMovePath("foo/bar", "/parent")).toBe(path.resolve("/parent", "foo/bar"));
|
||||
});
|
||||
it("passes absolute paths through (normalized)", () => {
|
||||
expect(resolveMovePath("/abs/path", "/anywhere")).toBe(path.normalize("/abs/path"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveExistingDirectory", () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-move-resolve-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns the resolved path for an existing directory", () => {
|
||||
const sub = path.join(tmp, "sub");
|
||||
fs.mkdirSync(sub);
|
||||
expect(resolveExistingDirectory(sub, "/anywhere")).toBe(path.resolve(sub));
|
||||
});
|
||||
it("returns null for a non-existent path", () => {
|
||||
expect(resolveExistingDirectory(path.join(tmp, "nope"), "/anywhere")).toBeNull();
|
||||
});
|
||||
it("returns null for a file (not a directory)", () => {
|
||||
const file = path.join(tmp, "file.txt");
|
||||
fs.writeFileSync(file, "x");
|
||||
expect(resolveExistingDirectory(file, "/anywhere")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoveOverlay", () => {
|
||||
let tmp: string;
|
||||
let cwd: string;
|
||||
let uiTheme: Theme;
|
||||
|
||||
beforeAll(async () => {
|
||||
await Settings.init({ inMemory: true });
|
||||
const loaded = await getThemeByName("dark");
|
||||
if (!loaded) throw new Error("theme unavailable");
|
||||
uiTheme = loaded;
|
||||
setThemeInstance(uiTheme);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-move-overlay-"));
|
||||
cwd = tmp;
|
||||
fs.mkdirSync(path.join(tmp, "alpha"));
|
||||
fs.mkdirSync(path.join(tmp, "beta"));
|
||||
fs.mkdirSync(path.join(tmp, ".hidden"));
|
||||
fs.writeFileSync(path.join(tmp, "file.txt"), "x");
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("renders a box with a title and input prompt", () => {
|
||||
const overlay = new MoveOverlay(cwd, () => {});
|
||||
const text = strip(overlay.render(80));
|
||||
expect(text).toContain("Move to directory");
|
||||
expect(text).toContain("Path:");
|
||||
});
|
||||
|
||||
it("lists child directories (excluding hidden and files) on empty input", () => {
|
||||
const overlay = new MoveOverlay(cwd, () => {});
|
||||
const text = strip(overlay.render(80));
|
||||
expect(text).toContain("alpha/");
|
||||
expect(text).toContain("beta/");
|
||||
expect(text).not.toContain(".hidden/");
|
||||
expect(text).not.toContain("file.txt");
|
||||
});
|
||||
|
||||
it("filters results as the user types", () => {
|
||||
const overlay = new MoveOverlay(cwd, () => {});
|
||||
overlay.handleInput("a");
|
||||
overlay.handleInput("l");
|
||||
const text = strip(overlay.render(80));
|
||||
expect(text).toContain("alpha/");
|
||||
expect(text).not.toContain("beta/");
|
||||
});
|
||||
|
||||
it("calls done with undefined on Escape", () => {
|
||||
let result: MoveOverlayResult | undefined = "sentinel" as unknown as MoveOverlayResult;
|
||||
const overlay = new MoveOverlay(cwd, r => {
|
||||
result = r;
|
||||
});
|
||||
overlay.handleInput("\x1b");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("calls done with the highlighted directory on Enter", () => {
|
||||
let result: MoveOverlayResult | undefined;
|
||||
const overlay = new MoveOverlay(cwd, r => {
|
||||
result = r;
|
||||
});
|
||||
// First result should be "alpha/" (sorted alphabetically).
|
||||
overlay.handleInput("\r");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.directory).toBe(path.join(cwd, "alpha"));
|
||||
});
|
||||
|
||||
it("calls done with the typed path on Enter when no results match", () => {
|
||||
let result: MoveOverlayResult | undefined;
|
||||
const overlay = new MoveOverlay(cwd, r => {
|
||||
result = r;
|
||||
});
|
||||
// Type a path that won't match any directory in cwd.
|
||||
overlay.handleInput("z");
|
||||
overlay.handleInput("z");
|
||||
overlay.handleInput("\r");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.directory).toBe("zz");
|
||||
});
|
||||
|
||||
it("Tab accepts the highlighted suggestion into the input", () => {
|
||||
let result: MoveOverlayResult | undefined;
|
||||
const overlay = new MoveOverlay(cwd, r => {
|
||||
result = r;
|
||||
});
|
||||
overlay.handleInput("\t");
|
||||
// After tab, the input should be the full path of the first result.
|
||||
// Press Enter to confirm — the result should be the alpha directory.
|
||||
overlay.handleInput("\r");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.directory).toBe(path.join(cwd, "alpha"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* `/move` overlay: a path input with live directory autocomplete.
|
||||
*
|
||||
* Rendered as a centered modal via `showHookCustom(..., { overlay: true })`.
|
||||
* The user types a path, Tab autocomtes the highlighted directory, and Enter
|
||||
* confirms — yielding the resolved directory string (or `undefined` on cancel).
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { type Component, CURSOR_MARKER, type Focusable, Key, matchesKey } from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../theme/theme";
|
||||
import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
|
||||
import { bottomBorder, row, topBorder } from "./overlay-box";
|
||||
|
||||
export interface MoveOverlayResult {
|
||||
directory: string;
|
||||
}
|
||||
|
||||
interface DirEntry {
|
||||
/** Full absolute path. */
|
||||
value: string;
|
||||
/** Display label (basename + trailing slash). */
|
||||
label: string;
|
||||
}
|
||||
|
||||
const MAX_RESULTS = 15;
|
||||
const OVERLAY_WIDTH = 68;
|
||||
|
||||
/** TTL for the directory listing cache (ms). */
|
||||
const DIR_CACHE_TTL = 500;
|
||||
const dirCache = new Map<string, { time: number; entries: string[] }>();
|
||||
|
||||
function readDirCached(dir: string): string[] {
|
||||
const now = Date.now();
|
||||
const cached = dirCache.get(dir);
|
||||
if (cached && now - cached.time < DIR_CACHE_TTL) return cached.entries;
|
||||
try {
|
||||
const entries = fs.readdirSync(dir);
|
||||
dirCache.set(dir, { time: now, entries });
|
||||
return entries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a user-typed path (`~`, absolute, or relative to `cwd`) to an absolute path. */
|
||||
export function resolveMovePath(input: string, cwd: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === "~") return os.homedir();
|
||||
if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2));
|
||||
if (path.isAbsolute(trimmed)) return path.normalize(trimmed);
|
||||
return path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
/** If `input` resolves to an existing directory, return it; otherwise `null`. */
|
||||
export function resolveExistingDirectory(input: string, cwd: string): string | null {
|
||||
const resolved = resolveMovePath(input, cwd);
|
||||
try {
|
||||
return fs.statSync(resolved).isDirectory() ? resolved : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listChildDirectories(dirPath: string, max: number): DirEntry[] {
|
||||
const results: DirEntry[] = [];
|
||||
const names = readDirCached(dirPath);
|
||||
for (const name of names) {
|
||||
if (results.length >= max) break;
|
||||
if (name.startsWith(".")) continue;
|
||||
const full = path.join(dirPath, name);
|
||||
try {
|
||||
if (!fs.statSync(full).isDirectory()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
results.push({ value: full, label: `${name}/` });
|
||||
}
|
||||
results.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return results;
|
||||
}
|
||||
|
||||
function searchDirectories(prefix: string, cwd: string, max: number): DirEntry[] {
|
||||
if (!prefix) return listChildDirectories(cwd, max);
|
||||
|
||||
// If the prefix already resolves to an existing directory, list its children.
|
||||
const resolved = resolveExistingDirectory(prefix, cwd);
|
||||
if (resolved) return listChildDirectories(resolved, max);
|
||||
|
||||
// Otherwise, split into base dir + query and filter the base dir's children.
|
||||
const norm = prefix.replace(/\\/g, "/");
|
||||
const slashIdx = norm.lastIndexOf("/");
|
||||
let baseDir: string;
|
||||
let query: string;
|
||||
if (slashIdx === -1) {
|
||||
baseDir = cwd;
|
||||
query = prefix;
|
||||
} else {
|
||||
const base = norm.slice(0, slashIdx + 1);
|
||||
query = norm.slice(slashIdx + 1);
|
||||
baseDir = resolveMovePath(base, cwd);
|
||||
}
|
||||
|
||||
const lower = query.toLowerCase();
|
||||
const results: DirEntry[] = [];
|
||||
const names = readDirCached(baseDir);
|
||||
for (const name of names) {
|
||||
if (results.length >= max) break;
|
||||
if (name.startsWith(".")) continue;
|
||||
const full = path.join(baseDir, name);
|
||||
try {
|
||||
if (!fs.statSync(full).isDirectory()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!query || name.toLowerCase().includes(lower)) {
|
||||
results.push({ value: full, label: `${name}/` });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay component for `/move`: a single-line path input with a live-filtered
|
||||
* list of matching directories. Tab accepts the highlighted suggestion; Enter
|
||||
* confirms the current input (or the highlighted suggestion if the input is
|
||||
* empty); Escape cancels.
|
||||
*/
|
||||
export class MoveOverlay implements Component, Focusable {
|
||||
#focused = false;
|
||||
#input = "";
|
||||
#cursor = 0;
|
||||
#selectedIndex = 0;
|
||||
#results: DirEntry[] = [];
|
||||
#cwd: string;
|
||||
#done: (result: MoveOverlayResult | undefined) => void;
|
||||
|
||||
constructor(cwd: string, done: (result: MoveOverlayResult | undefined) => void) {
|
||||
this.#cwd = cwd;
|
||||
this.#done = done;
|
||||
// Warm the cache for the current directory so the first keystroke is instant.
|
||||
readDirCached(cwd);
|
||||
this.#updateResults();
|
||||
}
|
||||
|
||||
get focused(): boolean {
|
||||
return this.#focused;
|
||||
}
|
||||
|
||||
set focused(value: boolean) {
|
||||
this.#focused = value;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesSelectCancel(data) || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
||||
this.#done(undefined);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) {
|
||||
this.#confirm();
|
||||
return;
|
||||
}
|
||||
if (matchesSelectUp(data) || matchesKey(data, Key.up)) {
|
||||
if (this.#results.length > 0) this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
|
||||
return;
|
||||
}
|
||||
if (matchesSelectDown(data) || matchesKey(data, Key.down)) {
|
||||
if (this.#results.length > 0)
|
||||
this.#selectedIndex = Math.min(this.#results.length - 1, this.#selectedIndex + 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.tab)) {
|
||||
const selected = this.#results[this.#selectedIndex];
|
||||
if (selected) {
|
||||
this.#input = selected.value;
|
||||
this.#cursor = this.#input.length;
|
||||
this.#selectedIndex = 0;
|
||||
this.#updateResults();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.left)) {
|
||||
this.#cursor = Math.max(0, this.#cursor - 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.right)) {
|
||||
this.#cursor = Math.min(this.#input.length, this.#cursor + 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.backspace) && this.#cursor > 0) {
|
||||
this.#input = this.#input.slice(0, this.#cursor - 1) + this.#input.slice(this.#cursor);
|
||||
this.#cursor--;
|
||||
this.#selectedIndex = 0;
|
||||
this.#updateResults();
|
||||
return;
|
||||
}
|
||||
// Printable character
|
||||
if (data.length === 1 && data.charCodeAt(0) >= 32 && data !== "\x7f") {
|
||||
this.#input = this.#input.slice(0, this.#cursor) + data + this.#input.slice(this.#cursor);
|
||||
this.#cursor++;
|
||||
this.#selectedIndex = 0;
|
||||
this.#updateResults();
|
||||
}
|
||||
}
|
||||
|
||||
render(_width: number): readonly string[] {
|
||||
const w = OVERLAY_WIDTH;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(topBorder(w, "Move to directory"));
|
||||
lines.push(row(this.#renderInput(), w));
|
||||
lines.push(row("", w));
|
||||
|
||||
if (this.#results.length === 0 && this.#input.length > 0) {
|
||||
lines.push(row(theme.fg("dim", "No matching directories"), w));
|
||||
} else {
|
||||
for (let i = 0; i < Math.min(this.#results.length, MAX_RESULTS); i++) {
|
||||
const item = this.#results[i]!;
|
||||
const selected = i === this.#selectedIndex;
|
||||
const marker = selected ? theme.fg("accent", "▶ ") : " ";
|
||||
const label = selected ? theme.fg("accent", item.label) : theme.fg("text", item.label);
|
||||
lines.push(row(`${marker}${label}`, w));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(row("", w));
|
||||
lines.push(row(theme.fg("dim", "Type to filter · ↑↓ navigate · Tab accept · Enter confirm · Esc cancel"), w));
|
||||
lines.push(bottomBorder(w));
|
||||
return lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
#renderInput(): string {
|
||||
const prompt = theme.fg("dim", "Path: ");
|
||||
if (this.#input.length === 0) {
|
||||
const placeholder = theme.fg("dim", "Type a directory path…");
|
||||
const marker = this.#focused ? CURSOR_MARKER : "";
|
||||
return `${prompt}${placeholder}${marker}\x1b[7m \x1b[27m`;
|
||||
}
|
||||
const before = this.#input.slice(0, this.#cursor);
|
||||
const cursorChar = this.#cursor < this.#input.length ? this.#input[this.#cursor] : " ";
|
||||
const after = this.#input.slice(this.#cursor + 1);
|
||||
const marker = this.#focused ? CURSOR_MARKER : "";
|
||||
return `${prompt}${before}${marker}\x1b[7m${cursorChar}\x1b[27m${after}`;
|
||||
}
|
||||
|
||||
#updateResults(): void {
|
||||
this.#results = searchDirectories(this.#input, this.#cwd, MAX_RESULTS + 5);
|
||||
if (this.#selectedIndex >= this.#results.length) {
|
||||
this.#selectedIndex = Math.max(0, this.#results.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
#confirm(): void {
|
||||
const selected = this.#results[this.#selectedIndex];
|
||||
if (selected) {
|
||||
this.#done({ directory: selected.value });
|
||||
return;
|
||||
}
|
||||
if (this.#input.trim().length > 0) {
|
||||
this.#done({ directory: this.#input.trim() });
|
||||
return;
|
||||
}
|
||||
this.#done(undefined);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { BashExecutionComponent } from "../../modes/components/bash-execution";
|
||||
import { BorderedLoader } from "../../modes/components/bordered-loader";
|
||||
import { DynamicBorder } from "../../modes/components/dynamic-border";
|
||||
import { EvalExecutionComponent } from "../../modes/components/eval-execution";
|
||||
import { MoveOverlay, type MoveOverlayResult } from "../../modes/components/move-overlay";
|
||||
import { TranscriptBlock } from "../../modes/components/transcript-container";
|
||||
import { getMarkdownTheme, getSymbolTheme, theme } from "../../modes/theme/theme";
|
||||
import type { InteractiveModeContext } from "../../modes/types";
|
||||
@@ -38,9 +39,11 @@ import { computeContextBreakdown, renderContextUsage } from "../../modes/utils/c
|
||||
import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown";
|
||||
import { buildToolsMarkdown } from "../../modes/utils/tools-markdown";
|
||||
import type { AsyncJobSnapshotItem } from "../../session/agent-session";
|
||||
import { markMoveSession } from "../../session/agent-session";
|
||||
import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage";
|
||||
import type { CompactMode } from "../../session/compact-modes";
|
||||
import type { NewSessionOptions } from "../../session/session-entries";
|
||||
import { SessionManager } from "../../session/session-manager";
|
||||
import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types";
|
||||
import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
|
||||
import { outputMeta } from "../../tools/output-meta";
|
||||
@@ -911,13 +914,34 @@ export class CommandController {
|
||||
]);
|
||||
}
|
||||
|
||||
async handleMoveCommand(targetPath: string): Promise<void> {
|
||||
/**
|
||||
* `/move` — switch to a fresh empty session in a different directory.
|
||||
*
|
||||
* With no `targetPath` (TUI only), opens an autocomplete overlay so the user
|
||||
* can pick or type a directory. With a `targetPath`, resolves it directly.
|
||||
* If the target directory does not exist, the user is asked whether to create
|
||||
* it. A brand-new empty session is then started in the target directory and
|
||||
* the current session is left behind (resumable via `/resume`).
|
||||
*/
|
||||
async handleMoveCommand(targetPath?: string): Promise<void> {
|
||||
if (this.ctx.session.isStreaming) {
|
||||
this.ctx.showWarning("Wait for the current response to finish or abort it before moving.");
|
||||
return;
|
||||
}
|
||||
|
||||
const unquoted = stripOuterDoubleQuotes(targetPath);
|
||||
let input: string | undefined = targetPath?.trim() || undefined;
|
||||
|
||||
// No argument in TUI mode: open the path autocomplete overlay.
|
||||
if (!input) {
|
||||
const result = await this.ctx.showHookCustom<MoveOverlayResult | undefined>(
|
||||
(_tui, _theme, _keybindings, done) => new MoveOverlay(this.ctx.sessionManager.getCwd(), done),
|
||||
{ overlay: true },
|
||||
);
|
||||
if (!result) return; // cancelled
|
||||
input = result.directory;
|
||||
}
|
||||
|
||||
const unquoted = stripOuterDoubleQuotes(input);
|
||||
if (!unquoted) {
|
||||
this.ctx.showError("Usage: /move <path>");
|
||||
return;
|
||||
@@ -926,25 +950,64 @@ export class CommandController {
|
||||
const cwd = this.ctx.sessionManager.getCwd();
|
||||
const resolvedPath = resolveToCwd(unquoted, cwd);
|
||||
|
||||
// If the directory doesn't exist, offer to create it.
|
||||
let isDirectory: boolean;
|
||||
try {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
this.ctx.showError(`Not a directory: ${resolvedPath}`);
|
||||
isDirectory = (await fs.stat(resolvedPath)).isDirectory();
|
||||
} catch {
|
||||
isDirectory = false;
|
||||
}
|
||||
|
||||
if (!isDirectory) {
|
||||
const parentDir = path.dirname(resolvedPath);
|
||||
let parentExists = false;
|
||||
try {
|
||||
parentExists = (await fs.stat(parentDir)).isDirectory();
|
||||
} catch {
|
||||
parentExists = false;
|
||||
}
|
||||
if (!parentExists) {
|
||||
this.ctx.showError(`Cannot create "${path.basename(resolvedPath)}": parent directory does not exist`);
|
||||
return;
|
||||
}
|
||||
const confirmed = await this.ctx.showHookConfirm(
|
||||
"Create directory?",
|
||||
`"${path.basename(resolvedPath)}" does not exist. Create it?`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await fs.mkdir(resolvedPath, { recursive: true });
|
||||
} catch (err) {
|
||||
this.ctx.showError(`Failed to create directory: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
this.ctx.showError(`Directory does not exist: ${resolvedPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ctx.sessionManager.flush();
|
||||
await this.ctx.sessionManager.moveTo(resolvedPath);
|
||||
// Create a fresh empty session file in the target directory's session
|
||||
// folder, then switch to it. The current session is left behind and
|
||||
// remains resumable via /resume.
|
||||
const newSessionFile = SessionManager.createEmptySessionFile(resolvedPath);
|
||||
await this.ctx.session.switchSession(newSessionFile);
|
||||
markMoveSession(newSessionFile);
|
||||
await this.ctx.applyCwdChange(resolvedPath);
|
||||
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.pendingMessagesContainer.clear();
|
||||
this.ctx.compactionQueuedMessages = [];
|
||||
this.ctx.streamingComponent = undefined;
|
||||
this.ctx.streamingMessage = undefined;
|
||||
this.ctx.pendingTools.clear();
|
||||
this.ctx.statusLine.invalidate();
|
||||
this.ctx.statusLine.setSessionStartTime(Date.now());
|
||||
this.ctx.updateEditorTopBorder();
|
||||
this.ctx.updateEditorBorderColor();
|
||||
await this.ctx.reloadTodos();
|
||||
this.ctx.ui.requestRender(true, { clearScrollback: true });
|
||||
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
new Text(`${theme.fg("accent", `${theme.status.success} Session moved to ${resolvedPath}`)}`, 1, 1),
|
||||
new Text(`${theme.fg("accent", `${theme.status.success} Moved to ${resolvedPath}`)}`, 1, 1),
|
||||
]);
|
||||
} catch (err) {
|
||||
this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
|
||||
@@ -3689,7 +3689,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
return this.#commandController.handleForkCommand();
|
||||
}
|
||||
|
||||
handleMoveCommand(targetPath: string): Promise<void> {
|
||||
handleMoveCommand(targetPath?: string): Promise<void> {
|
||||
return this.#commandController.handleMoveCommand(targetPath);
|
||||
}
|
||||
|
||||
|
||||
@@ -328,7 +328,7 @@ export interface InteractiveModeContext {
|
||||
handleCompactCommand(customInstructions?: string, mode?: CompactMode): Promise<CompactionOutcome>;
|
||||
handleHandoffCommand(customInstructions?: string): Promise<void>;
|
||||
handleShakeCommand(mode: ShakeMode): Promise<void>;
|
||||
handleMoveCommand(targetPath: string): Promise<void>;
|
||||
handleMoveCommand(targetPath?: string): Promise<void>;
|
||||
handleRenameCommand(title: string): Promise<void>;
|
||||
handleMemoryCommand(text: string): Promise<void>;
|
||||
handleSTTToggle(): Promise<void>;
|
||||
|
||||
@@ -4714,6 +4714,8 @@ export class AgentSession {
|
||||
await disposeJuliaKernelSessionsByOwner(this.#evalKernelOwnerId);
|
||||
await shutdownTinyTitleClient();
|
||||
this.#releasePowerAssertion();
|
||||
// Clean up empty sessions created by /move so they don't accumulate.
|
||||
await cleanupEmptyMoveSession(this.sessionManager);
|
||||
await this.sessionManager.close();
|
||||
// beginDispose() stopped the advisor and captured its recorder close; await
|
||||
// it so the final advisor turn is flushed before the process may exit.
|
||||
@@ -13900,3 +13902,55 @@ export class AgentSession {
|
||||
return this.#extensionRunner;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty move-session cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Session files created by `/move` that should be cleaned up on shutdown if
|
||||
* they never received any real user/assistant messages. This prevents empty
|
||||
* session files from accumulating when the user moves to a directory and quits
|
||||
* without chatting.
|
||||
*/
|
||||
const moveSessionFiles = new Set<string>();
|
||||
|
||||
/** Mark a session file as created by `/move` so it can be cleaned up on exit. */
|
||||
export function markMoveSession(sessionFile: string): void {
|
||||
moveSessionFiles.add(path.resolve(sessionFile));
|
||||
}
|
||||
|
||||
/** Remove a session file from the move-session cleanup tracking. */
|
||||
export function unmarkMoveSession(sessionFile: string): void {
|
||||
moveSessionFiles.delete(path.resolve(sessionFile));
|
||||
}
|
||||
|
||||
/** Check whether a session file was created by `/move` and is still tracked. */
|
||||
export function isMoveSession(sessionFile: string): boolean {
|
||||
return moveSessionFiles.has(path.resolve(sessionFile));
|
||||
}
|
||||
|
||||
/**
|
||||
* If the current session was created by `/move` and contains no real
|
||||
* user/assistant messages, delete it so empty move sessions don't accumulate.
|
||||
* Called during `dispose()`.
|
||||
*/
|
||||
async function cleanupEmptyMoveSession(sessionManager: SessionManager): Promise<void> {
|
||||
const sessionFile = sessionManager.getSessionFile();
|
||||
if (!sessionFile || !moveSessionFiles.has(path.resolve(sessionFile))) return;
|
||||
const entries = sessionManager.getEntries();
|
||||
const hasRealMessages = entries.some(
|
||||
e => e.type === "message" && (e.message.role === "user" || e.message.role === "assistant"),
|
||||
);
|
||||
if (hasRealMessages) {
|
||||
// The session has real content — keep it and stop tracking it as a move session.
|
||||
moveSessionFiles.delete(path.resolve(sessionFile));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sessionManager.dropSession(sessionFile);
|
||||
} catch (err) {
|
||||
logger.warn("Failed to clean up empty move session", { sessionFile, error: String(err) });
|
||||
}
|
||||
moveSessionFiles.delete(path.resolve(sessionFile));
|
||||
}
|
||||
|
||||
@@ -1523,6 +1523,29 @@ export class SessionManager {
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh empty session file in the default session directory for
|
||||
* `cwd`, writing only the session header. The returned path can be passed to
|
||||
* `setSessionFile` / `AgentSession.switchSession` to start a new empty
|
||||
* session in that directory. Used by `/move` to switch projects without
|
||||
* dragging the current conversation along.
|
||||
*/
|
||||
static createEmptySessionFile(cwd: string, storage: SessionStorage = new FileSessionStorage()): string {
|
||||
const sessionDir = SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
||||
const id = mintSessionId();
|
||||
const timestamp = nowIso();
|
||||
const header: SessionHeader = {
|
||||
type: "session",
|
||||
version: CURRENT_SESSION_VERSION,
|
||||
id,
|
||||
timestamp,
|
||||
cwd: path.resolve(cwd),
|
||||
};
|
||||
const file = path.join(sessionDir, `${fileSafeTimestamp(timestamp)}_${id}.jsonl`);
|
||||
storage.writeTextSync(file, `${JSON.stringify(header)}\n`);
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork a session into the current project directory: copy history from another
|
||||
* session file while creating a fresh session file in this sessionDir.
|
||||
|
||||
@@ -26,9 +26,12 @@ import { describeLoopLimitRuntime } from "../modes/loop-limit";
|
||||
import { theme } from "../modes/theme/theme";
|
||||
import type { InteractiveModeContext } from "../modes/types";
|
||||
import type { AgentSession, FreshSessionResult } from "../session/agent-session";
|
||||
import { markMoveSession } from "../session/agent-session";
|
||||
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 { urlHyperlinkAlways } from "../tui";
|
||||
import { getChangelogPath, parseChangelog } from "../utils/changelog";
|
||||
import { CollabQrCodeComponent } from "./helpers/collab-qrcode";
|
||||
@@ -1590,24 +1593,31 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
||||
},
|
||||
{
|
||||
name: "move",
|
||||
description: "Move session to a different working directory",
|
||||
acpDescription: "Move the current session file",
|
||||
inlineHint: "<path>",
|
||||
description: "Switch to a fresh session in a different directory",
|
||||
acpDescription: "Start a fresh session in a different directory",
|
||||
inlineHint: "[<path>]",
|
||||
allowArgs: true,
|
||||
handle: async (command, runtime) => {
|
||||
if (runtime.session.isStreaming) return usage("Cannot move while streaming.", runtime);
|
||||
if (!command.args) return usage("Usage: /move <path>", runtime);
|
||||
const resolvedPath = path.resolve(runtime.cwd, command.args);
|
||||
let isDirectory: boolean;
|
||||
const resolvedPath = resolveToCwd(command.args, runtime.cwd);
|
||||
try {
|
||||
isDirectory = (await fs.stat(resolvedPath)).isDirectory();
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
return usage(`Not a directory: ${resolvedPath}`, runtime);
|
||||
}
|
||||
} catch {
|
||||
return usage(`Directory does not exist or is not a directory: ${resolvedPath}`, runtime);
|
||||
// Directory doesn't exist — create it (no interactive confirm in ACP).
|
||||
try {
|
||||
await fs.mkdir(resolvedPath, { recursive: true });
|
||||
} catch (err) {
|
||||
return usage(`Failed to create directory: ${errorMessage(err)}`, runtime);
|
||||
}
|
||||
}
|
||||
if (!isDirectory) return usage(`Directory does not exist or is not a directory: ${resolvedPath}`, runtime);
|
||||
try {
|
||||
await runtime.sessionManager.flush();
|
||||
await runtime.sessionManager.moveTo(resolvedPath);
|
||||
const newSessionFile = SessionManager.createEmptySessionFile(resolvedPath);
|
||||
await runtime.session.switchSession(newSessionFile);
|
||||
markMoveSession(newSessionFile);
|
||||
} catch (err) {
|
||||
return usage(`Move failed: ${errorMessage(err)}`, runtime);
|
||||
}
|
||||
@@ -1616,18 +1626,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
||||
// capabilities scoped to the new cwd.
|
||||
await runtime.reloadPlugins();
|
||||
await runtime.notifyTitleChanged?.();
|
||||
await runtime.output(`Session moved to ${runtime.sessionManager.getCwd()}.`);
|
||||
await runtime.output(`Moved to ${runtime.sessionManager.getCwd()}.`);
|
||||
return commandConsumed();
|
||||
},
|
||||
handleTui: async (command, runtime) => {
|
||||
const targetPath = command.args;
|
||||
if (!targetPath) {
|
||||
runtime.ctx.showError("Usage: /move <path>");
|
||||
runtime.ctx.editor.setText("");
|
||||
return;
|
||||
}
|
||||
runtime.ctx.editor.addToHistory(command.text);
|
||||
runtime.ctx.editor.setText("");
|
||||
await runtime.ctx.handleMoveCommand(targetPath);
|
||||
await runtime.ctx.handleMoveCommand(command.args || undefined);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as fsp from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { SessionHeader } from "@oh-my-pi/pi-coding-agent/session/session-entries";
|
||||
import { loadEntriesFromFile } from "@oh-my-pi/pi-coding-agent/session/session-loader";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
describe("SessionManager.createEmptySessionFile", () => {
|
||||
let testAgentDir: string;
|
||||
let cwd: string;
|
||||
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
|
||||
|
||||
beforeEach(async () => {
|
||||
testAgentDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-empty-session-"));
|
||||
setAgentDir(testAgentDir);
|
||||
cwd = path.join(testAgentDir, "project");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalAgentDir) {
|
||||
setAgentDir(originalAgentDir);
|
||||
} else {
|
||||
setAgentDir(fallbackAgentDir);
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
}
|
||||
await fsp.rm(testAgentDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates a valid session file with a header pointing at the given cwd", async () => {
|
||||
const file = SessionManager.createEmptySessionFile(cwd);
|
||||
expect(file).toMatch(/\.jsonl$/);
|
||||
expect(fs.existsSync(file)).toBe(true);
|
||||
|
||||
const entries = await loadEntriesFromFile(file);
|
||||
expect(entries.length).toBe(1);
|
||||
const header = entries[0] as SessionHeader;
|
||||
expect(header.type).toBe("session");
|
||||
expect(header.version).toBe(3);
|
||||
expect(header.id).toBeTruthy();
|
||||
expect(header.cwd).toBe(path.resolve(cwd));
|
||||
});
|
||||
|
||||
it("places the file in the cwd-derived default session directory", () => {
|
||||
const file = SessionManager.createEmptySessionFile(cwd);
|
||||
const expectedDir = SessionManager.getDefaultSessionDir(cwd);
|
||||
expect(path.dirname(file)).toBe(expectedDir);
|
||||
});
|
||||
|
||||
it("can be loaded by setSessionFile to start a fresh session at that path", async () => {
|
||||
const file = SessionManager.createEmptySessionFile(cwd);
|
||||
const manager = SessionManager.create(cwd);
|
||||
await manager.setSessionFile(file);
|
||||
|
||||
// The session adopts the header's cwd and has no entries beyond the header.
|
||||
expect(manager.getCwd()).toBe(path.resolve(cwd));
|
||||
expect(manager.getSessionFile()).toBe(path.resolve(file));
|
||||
expect(manager.getEntries().length).toBe(0);
|
||||
});
|
||||
|
||||
it("produces unique file paths across calls", () => {
|
||||
const fileA = SessionManager.createEmptySessionFile(cwd);
|
||||
const fileB = SessionManager.createEmptySessionFile(cwd);
|
||||
expect(fileA).not.toBe(fileB);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as fsp from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { isMoveSession, markMoveSession, unmarkMoveSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
import { makeAssistantMessage } from "./helpers";
|
||||
|
||||
describe("move-session cleanup tracking", () => {
|
||||
let testAgentDir: string;
|
||||
let cwd: string;
|
||||
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
|
||||
|
||||
beforeEach(async () => {
|
||||
testAgentDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-move-cleanup-"));
|
||||
setAgentDir(testAgentDir);
|
||||
cwd = path.join(testAgentDir, "project");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
});
|
||||
afterEach(async () => {
|
||||
if (originalAgentDir) {
|
||||
setAgentDir(originalAgentDir);
|
||||
} else {
|
||||
setAgentDir(fallbackAgentDir);
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
}
|
||||
await fsp.rm(testAgentDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("markMoveSession / isMoveSession / unmarkMoveSession round-trip", () => {
|
||||
const file = path.resolve(cwd, "session.jsonl");
|
||||
expect(isMoveSession(file)).toBe(false);
|
||||
markMoveSession(file);
|
||||
expect(isMoveSession(file)).toBe(true);
|
||||
unmarkMoveSession(file);
|
||||
expect(isMoveSession(file)).toBe(false);
|
||||
});
|
||||
|
||||
it("createEmptySessionFile + markMoveSession + dispose deletes empty session file", async () => {
|
||||
const file = SessionManager.createEmptySessionFile(cwd);
|
||||
expect(fs.existsSync(file)).toBe(true);
|
||||
markMoveSession(file);
|
||||
|
||||
// Simulate what dispose() does: load the session, then call cleanupEmptyMoveSession.
|
||||
// We test the contract: an empty (header-only) move session file is deleted.
|
||||
const manager = SessionManager.create(cwd);
|
||||
await manager.setSessionFile(file);
|
||||
|
||||
// The session has no real messages — just the header.
|
||||
const entries = manager.getEntries();
|
||||
const hasRealMessages = entries.some(
|
||||
e => e.type === "message" && (e.message.role === "user" || e.message.role === "assistant"),
|
||||
);
|
||||
expect(hasRealMessages).toBe(false);
|
||||
|
||||
await manager.dropSession(file);
|
||||
expect(fs.existsSync(file)).toBe(false);
|
||||
expect(isMoveSession(file)).toBe(true); // tracking not auto-cleared by dropSession
|
||||
unmarkMoveSession(file);
|
||||
expect(isMoveSession(file)).toBe(false);
|
||||
});
|
||||
|
||||
it("a move session that received real messages is NOT deleted", async () => {
|
||||
const file = SessionManager.createEmptySessionFile(cwd);
|
||||
markMoveSession(file);
|
||||
|
||||
const manager = SessionManager.create(cwd);
|
||||
await manager.setSessionFile(file);
|
||||
manager.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
manager.appendMessage(makeAssistantMessage());
|
||||
await manager.flush();
|
||||
|
||||
// The session now has real messages — it should survive.
|
||||
const entries = manager.getEntries();
|
||||
const hasRealMessages = entries.some(
|
||||
e => e.type === "message" && (e.message.role === "user" || e.message.role === "assistant"),
|
||||
);
|
||||
expect(hasRealMessages).toBe(true);
|
||||
|
||||
expect(fs.existsSync(file)).toBe(true);
|
||||
unmarkMoveSession(file);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user