feat(coding-agent): added /move command to relocate sessions to different working directories

- Added /move slash command to allow users to relocate sessions to different working directories.
- Added moveTo() method to SessionManager for programmatic session relocation with file system operations and rollback support.
- Added refreshSlashCommandState() method to InteractiveMode for dynamic slash command reloading when changing directories.
- Added setSlashCommands() method to AgentSession to allow external management of file-based slash commands.
- Added handleMoveCommand() handler in command-controller to process /move commands with validation and error handling.
This commit is contained in:
can1357
2026-02-12 02:38:59 +01:00
parent 57becd56ee
commit df7a45b774
8 changed files with 171 additions and 14 deletions
+5
View File
@@ -1,8 +1,13 @@
# Changelog
## [Unreleased]
### Added
- Added `/move` slash command to move session to a different working directory
- Added `moveTo()` method to SessionManager for relocating sessions with file migration and header updates
- Added `refreshSlashCommandState()` method to reload slash commands and autocomplete when working directory changes
- Added `setSlashCommands()` method to AgentSession for updating file-based slash commands
- Added OAuth authentication support for Perplexity web search via `www.perplexity.ai/rest/sse/perplexity_ask` endpoint
- Added automatic OAuth token refresh with 5-minute expiry buffer for Perplexity authentication
- Added `authMode` field to search responses to indicate authentication method used (oauth or api_key)
@@ -145,6 +145,7 @@ const BUILTIN_SLASH_COMMAND_DEFS: ReadonlyArray<BuiltinSlashCommand> = [
{ name: "resume", description: "Resume a different session" },
{ name: "background", description: "Detach UI and continue running in background" },
{ name: "debug", description: "Write debug log (TUI state and messages)" },
{ name: "move", description: "Move session to a different working directory", inlineHint: "<path>" },
{ name: "exit", description: "Exit the application" },
{ name: "quit", description: "Quit the application" },
];
@@ -6,6 +6,7 @@ import { copyToClipboard } from "@oh-my-pi/pi-natives";
import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
import { Snowflake } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import { reset as resetCapabilities } from "../../capability";
import { loadCustomShare } from "../../export/custom-share";
import type { CompactOptions } from "../../extensibility/extensions/types";
import { getGatewayStatus } from "../../ipy/gateway-coordinator";
@@ -17,6 +18,7 @@ import { getMarkdownTheme, getSymbolTheme, theme } from "../../modes/theme/theme
import type { InteractiveModeContext } from "../../modes/types";
import { createCompactionSummaryMessage } from "../../session/messages";
import { outputMeta } from "../../tools/output-meta";
import { resolveToCwd } from "../../tools/path-utils";
import { getChangelogPath, parseChangelog } from "../../utils/changelog";
import { openPath } from "../../utils/open";
@@ -435,6 +437,46 @@ export class CommandController {
this.ctx.ui.requestRender();
}
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 cwd = this.ctx.sessionManager.getCwd();
const resolvedPath = resolveToCwd(targetPath, cwd);
try {
const stat = await fs.stat(resolvedPath);
if (!stat.isDirectory()) {
this.ctx.showError(`Not a directory: ${resolvedPath}`);
return;
}
} catch {
this.ctx.showError(`Directory does not exist: ${resolvedPath}`);
return;
}
try {
await this.ctx.sessionManager.flush();
await this.ctx.sessionManager.moveTo(resolvedPath);
process.chdir(resolvedPath);
resetCapabilities();
await this.ctx.refreshSlashCommandState(resolvedPath);
this.ctx.statusLine.invalidate();
this.ctx.updateEditorTopBorder();
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(
new Text(`${theme.fg("accent", `${theme.status.success} Session moved to ${resolvedPath}`)}`, 1, 1),
);
this.ctx.ui.requestRender();
} catch (err) {
this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
async handleBashCommand(command: string, excludeFromContext = false): Promise<void> {
const isDeferred = this.ctx.session.isStreaming;
this.ctx.bashComponent = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext);
@@ -300,6 +300,17 @@ export class InputController {
await this.ctx.handleForkCommand();
return;
}
if (text === "/move" || text.startsWith("/move ")) {
const targetPath = text.slice(6).trim();
if (!targetPath) {
this.ctx.showError("Usage: /move <path>");
this.ctx.editor.setText("");
return;
}
this.ctx.editor.setText("");
await this.ctx.handleMoveCommand(targetPath);
return;
}
if (text === "/compact" || text.startsWith("/compact ")) {
const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
this.ctx.editor.setText("");
@@ -249,21 +249,8 @@ export class InteractiveMode implements InteractiveModeContext {
this.#cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush());
debugStartup("InteractiveMode.init:cleanupRegistered");
// Load and convert file commands to SlashCommand format (async)
const fileCommands = await loadSlashCommands({ cwd: process.cwd() });
await this.refreshSlashCommandState(process.cwd());
debugStartup("InteractiveMode.init:slashCommands");
this.fileSlashCommands = new Set(fileCommands.map(cmd => cmd.name));
const fileSlashCommands: SlashCommand[] = fileCommands.map(cmd => ({
name: cmd.name,
description: cmd.description,
}));
// Setup autocomplete with all commands
const autocompleteProvider = new CombinedAutocompleteProvider(
[...this.#pendingSlashCommands, ...fileSlashCommands],
process.cwd(),
);
this.editor.setAutocompleteProvider(autocompleteProvider);
// Get current model info for welcome screen
const modelName = this.session.model?.name ?? "Unknown";
@@ -370,6 +357,23 @@ export class InteractiveMode implements InteractiveModeContext {
this.updateEditorTopBorder();
}
/** Reload slash commands and autocomplete for the provided working directory. */
async refreshSlashCommandState(cwd?: string): Promise<void> {
const basePath = cwd ?? this.sessionManager.getCwd();
const fileCommands = await loadSlashCommands({ cwd: basePath });
this.fileSlashCommands = new Set(fileCommands.map(cmd => cmd.name));
const fileSlashCommands: SlashCommand[] = fileCommands.map(cmd => ({
name: cmd.name,
description: cmd.description,
}));
const autocompleteProvider = new CombinedAutocompleteProvider(
[...this.#pendingSlashCommands, ...fileSlashCommands],
basePath,
);
this.editor.setAutocompleteProvider(autocompleteProvider);
this.session.setSlashCommands(fileCommands);
}
async getUserInput(): Promise<{ text: string; images?: ImageContent[] }> {
const { promise, resolve } = Promise.withResolvers<{ text: string; images?: ImageContent[] }>();
this.onInputCallback = input => {
@@ -911,6 +915,10 @@ export class InteractiveMode implements InteractiveModeContext {
return this.#commandController.handleForkCommand();
}
handleMoveCommand(targetPath: string): Promise<void> {
return this.#commandController.handleMoveCommand(targetPath);
}
showDebugSelector(): void {
this.#selectorController.showDebugSelector();
}
+2
View File
@@ -150,8 +150,10 @@ export interface InteractiveModeContext {
handleMCPCommand(text: string): Promise<void>;
handleCompactCommand(customInstructions?: string): Promise<void>;
handleHandoffCommand(customInstructions?: string): Promise<void>;
handleMoveCommand(targetPath: string): Promise<void>;
executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise<void>;
openInBrowser(urlOrPath: string): void;
refreshSlashCommandState(cwd?: string): Promise<void>;
// Selector handling
showSettingsSelector(): void;
@@ -1118,6 +1118,11 @@ export class AgentSession {
return this.#promptTemplates;
}
/** Replace file-based slash commands used for prompt expansion. */
setSlashCommands(slashCommands: FileSlashCommand[]): void {
this.#slashCommands = [...slashCommands];
}
/** Custom commands (TypeScript slash commands) */
get customCommands(): ReadonlyArray<LoadedCustomCommand> {
return this.#customCommands;
@@ -1206,6 +1206,89 @@ export class SessionManager {
return { oldSessionFile, newSessionFile: this.#sessionFile };
}
/**
* Move the session to a new working directory.
* Moves session files and artifacts on disk, updates all internal references,
* and rewrites the session header with the new cwd.
*/
async moveTo(newCwd: string): Promise<void> {
const resolvedCwd = path.resolve(newCwd);
if (resolvedCwd === this.cwd) return;
const newSessionDir = getDefaultSessionDir(resolvedCwd, this.storage);
if (this.persist && this.#sessionFile) {
// Close the persist writer before moving files
await this.#closePersistWriter();
this.#persistChain = Promise.resolve();
this.#persistError = undefined;
this.#persistErrorReported = false;
const oldSessionFile = this.#sessionFile;
const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile));
const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl
const newArtifactDir = newSessionFile.slice(0, -6);
let movedSessionFile = false;
let movedArtifactDir = false;
try {
await fs.promises.rename(oldSessionFile, newSessionFile);
movedSessionFile = true;
try {
const stat = await fs.promises.stat(oldArtifactDir);
if (stat.isDirectory()) {
await fs.promises.rename(oldArtifactDir, newArtifactDir);
movedArtifactDir = true;
}
} catch (err) {
if (!isEnoent(err)) throw err;
}
} catch (err) {
if (movedArtifactDir) {
try {
await fs.promises.rename(newArtifactDir, oldArtifactDir);
} catch (rollbackErr) {
throw new Error(
`Failed to move artifacts and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
);
}
}
if (movedSessionFile) {
try {
await fs.promises.rename(newSessionFile, oldSessionFile);
} catch (rollbackErr) {
throw new Error(
`Failed to move session file and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
);
}
}
throw err;
}
this.#sessionFile = newSessionFile;
}
// Update cwd and sessionDir (controlled mutation of readonly fields)
(this as unknown as { cwd: string }).cwd = resolvedCwd;
(this as unknown as { sessionDir: string }).sessionDir = newSessionDir;
// Update the session header in fileEntries
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
if (header) {
header.cwd = resolvedCwd;
}
// Rewrite the session file at its new location with updated header
if (this.persist && this.#sessionFile) {
await this.#rewriteFile();
}
// Update terminal breadcrumb
if (this.#sessionFile) {
writeTerminalBreadcrumb(resolvedCwd, this.#sessionFile);
}
}
/** Sync version for initial creation (no existing writer to close) */
#newSessionSync(options?: NewSessionOptions): string | undefined {
this.#persistChain = Promise.resolve();