diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 03ec72a40..2016acd5e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 ` 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 `/` 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)) diff --git a/packages/coding-agent/src/modes/components/__tests__/move-overlay.test.ts b/packages/coding-agent/src/modes/components/__tests__/move-overlay.test.ts new file mode 100644 index 000000000..1f3ed2650 --- /dev/null +++ b/packages/coding-agent/src/modes/components/__tests__/move-overlay.test.ts @@ -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")); + }); +}); diff --git a/packages/coding-agent/src/modes/components/move-overlay.ts b/packages/coding-agent/src/modes/components/move-overlay.ts new file mode 100644 index 000000000..2c8335448 --- /dev/null +++ b/packages/coding-agent/src/modes/components/move-overlay.ts @@ -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(); + +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); + } +} diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index e85ba8262..6f21fcba0 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -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 { + /** + * `/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 { 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( + (_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 "); 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)}`); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 453305298..269c84bf2 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -3609,7 +3609,7 @@ export class InteractiveMode implements InteractiveModeContext { return this.#commandController.handleForkCommand(); } - handleMoveCommand(targetPath: string): Promise { + handleMoveCommand(targetPath?: string): Promise { return this.#commandController.handleMoveCommand(targetPath); } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 731b64a84..5adbe49ff 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -320,7 +320,7 @@ export interface InteractiveModeContext { handleCompactCommand(customInstructions?: string, mode?: CompactMode): Promise; handleHandoffCommand(customInstructions?: string): Promise; handleShakeCommand(mode: ShakeMode): Promise; - handleMoveCommand(targetPath: string): Promise; + handleMoveCommand(targetPath?: string): Promise; handleRenameCommand(title: string): Promise; handleMemoryCommand(text: string): Promise; handleSTTToggle(): Promise; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index ffc815401..4ca57147d 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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(); + +/** 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 { + 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)); +} diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 18c7e4dd9..38878cbf6 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -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. diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 33351ab6c..6fc80e5cd 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -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 = [ }, { name: "move", - description: "Move session to a different working directory", - acpDescription: "Move the current session file", - inlineHint: "", + description: "Switch to a fresh session in a different directory", + acpDescription: "Start a fresh session in a different directory", + inlineHint: "[]", allowArgs: true, handle: async (command, runtime) => { if (runtime.session.isStreaming) return usage("Cannot move while streaming.", runtime); if (!command.args) return usage("Usage: /move ", 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 = [ // 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 "); - 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); }, }, { diff --git a/packages/coding-agent/test/session-manager/create-empty-session-file.test.ts b/packages/coding-agent/test/session-manager/create-empty-session-file.test.ts new file mode 100644 index 000000000..fee869dee --- /dev/null +++ b/packages/coding-agent/test/session-manager/create-empty-session-file.test.ts @@ -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); + }); +}); diff --git a/packages/coding-agent/test/session-manager/move-session-cleanup.test.ts b/packages/coding-agent/test/session-manager/move-session-cleanup.test.ts new file mode 100644 index 000000000..12730f309 --- /dev/null +++ b/packages/coding-agent/test/session-manager/move-session-cleanup.test.ts @@ -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); + }); +});