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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user