diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1922810b3..31a91a3ff 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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) diff --git a/packages/coding-agent/src/extensibility/slash-commands.ts b/packages/coding-agent/src/extensibility/slash-commands.ts index 602d88c18..4910ff2ae 100644 --- a/packages/coding-agent/src/extensibility/slash-commands.ts +++ b/packages/coding-agent/src/extensibility/slash-commands.ts @@ -145,6 +145,7 @@ const BUILTIN_SLASH_COMMAND_DEFS: ReadonlyArray = [ { 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: "" }, { name: "exit", description: "Exit the application" }, { name: "quit", description: "Quit the application" }, ]; diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 22f950dbd..3b9d6fed4 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -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 { + 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 { const isDeferred = this.ctx.session.isStreaming; this.ctx.bashComponent = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 337207c12..14e4ffc67 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -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 "); + 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(""); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index cd5f91487..baf63401b 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -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 { + 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 { + return this.#commandController.handleMoveCommand(targetPath); + } + showDebugSelector(): void { this.#selectorController.showDebugSelector(); } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 3af1566c4..be67abee5 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -150,8 +150,10 @@ export interface InteractiveModeContext { handleMCPCommand(text: string): Promise; handleCompactCommand(customInstructions?: string): Promise; handleHandoffCommand(customInstructions?: string): Promise; + handleMoveCommand(targetPath: string): Promise; executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise; openInBrowser(urlOrPath: string): void; + refreshSlashCommandState(cwd?: string): Promise; // Selector handling showSettingsSelector(): void; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 5cff9b5f7..3f574a5bd 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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 { return this.#customCommands; diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 6a3759683..f55887ea0 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -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 { + 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();