feat(move): interactive /move overlay with fresh session in target directory

Replaces the old /move (which relocated the current session file) with a
new flow that starts a fresh empty session in the target directory, leaving
the previous session resumable via /resume. With no argument, /move opens
a path autocomplete overlay (type to filter, Tab to accept, Enter to
confirm). If the target directory does not exist, a confirmation prompt
offers to create it. Empty move sessions are cleaned up on shutdown.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
oldschoola
2026-06-24 12:08:07 -07:00
parent 958a923a42
commit 98656408d5
11 changed files with 752 additions and 31 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### 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 all extension loading silently failing on the cross-compiled `omp-darwin-arm64` release binary (downloaded directly or via a Homebrew tap wrapper) because `__computeBunfsPackageRoot` mis-handled `import.meta.dir = "//root/omp-darwin-arm64"`. Bun 1.3.14 reports `<bunfs-root>/<binary-name>` for the compiled entry's `import.meta.dir`, but the pre-fix function joined `metaDir + "packages"` and produced `/root/omp-darwin-arm64/packages` — the binary basename was baked into every bunfs path, so the TypeBox/legacy-pi shims and every `@oh-my-pi/pi-*` package-root override failed `existsSync` validation and `resolveCanonicalPiSpecifier` fell through to a bunfs `Bun.resolveSync` that also could not find the module. The function now detects the bunfs-root + binary-basename shape (`path.basename(path.dirname(metaDir)) === "root"`) and strips the trailing binary segment by slicing the original `metaDir`; the production bunfs shim join path also preserves Bun's bunfs-native `//root` / `B:\~BUN\root` prefix that `path.join` would otherwise collapse. ([#3329](https://github.com/can1357/oh-my-pi/issues/3329))
@@ -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);
}
}
@@ -30,6 +30,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";
@@ -37,9 +38,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";
@@ -910,13 +913,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;
@@ -925,25 +949,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)}`);
@@ -3609,7 +3609,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);
}
+1 -1
View File
@@ -320,7 +320,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>;
@@ -4308,6 +4308,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.
@@ -13069,3 +13071,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,8 +26,11 @@ 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 { 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";
@@ -1593,24 +1596,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);
}
@@ -1619,19 +1629,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);
});
});