diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4a790ddfa..55a5a62fb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added Ctrl+D draft persistence: pressing Ctrl+D with text in the editor now exits the app and saves the unsent text as a per-session draft. Resuming the same session (e.g. via `--resume`) restores the draft into the editor (one-shot, removed after restore). + ## [14.6.4] - 2026-05-03 ### Added diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index 2d424c971..5bb32b601 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -197,12 +197,11 @@ export class CustomEditor extends Editor { return; } - // Intercept configured exit shortcut (only when editor is empty) + // Intercept configured exit shortcut. Always consume the shortcut so it + // never reaches the parent handler; firing onExit is the controller's + // chance to snapshot the current text as a draft before shutting down. if (this.#matchesAction(data, "app.exit")) { - if (this.getText().length === 0 && this.onExit) { - this.onExit(); - } - // Always consume exit shortcut (don't pass to parent) + this.onExit?.(); return; } diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index dfcbbf934..6313c1a69 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -403,7 +403,9 @@ export class InputController { } handleCtrlD(): void { - // Only called when editor is empty (enforced by CustomEditor) + // Editor text (if any) is snapshotted at the start of shutdown() and + // persisted as a draft for the next resume. Empty text is also fine — + // shutdown clears any stale sidecar in that case. void this.ctx.shutdown(); } diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 9701ddb11..aaaa67974 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -445,6 +445,20 @@ export class InteractiveMode implements InteractiveModeContext { // Restore mode from session (e.g. plan mode on resume) await this.#restoreModeFromSession(); + // Restore unsent editor draft from previous session shutdown (Ctrl+D). + // One-shot: consumeDraft removes the sidecar after read so the next + // resume does not re-restore the same text. + try { + const draft = await this.sessionManager.consumeDraft(); + if (draft && !this.editor.getText()) { + this.editor.setText(draft); + this.updateEditorBorderColor(); + this.ui.requestRender(); + } + } catch (err) { + logger.warn("Failed to restore session draft", { error: String(err) }); + } + // Subscribe to agent events this.#subscribeToAgent(); @@ -1189,8 +1203,18 @@ export class InteractiveMode implements InteractiveModeContext { if (this.#isShuttingDown) return; this.#isShuttingDown = true; + // Snapshot the editor before any teardown empties it. Persisting the draft + // here covers Ctrl+D shutdown with non-empty text; for /exit the editor is + // already cleared so saveDraft("") just removes any stale sidecar. + const draftText = this.editor.getText(); + // Flush pending session writes before shutdown await this.sessionManager.flush(); + try { + await this.sessionManager.saveDraft(draftText); + } catch (err) { + logger.warn("Failed to save session draft", { error: String(err) }); + } this.#btwController.dispose(); // Emit shutdown event to hooks diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 63363d413..b08d539ac 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -2182,6 +2182,63 @@ export class SessionManager { return manager.getPath(id); } + /** + * Path to the unsent-input draft sidecar for the current session. Lives inside + * the artifacts directory so it is removed together with the session on + * `dropSession`. Returns null when the session has no on-disk identity. + */ + #getDraftPath(): string | null { + const dir = this.getArtifactsDir(); + return dir ? path.join(dir, "draft.txt") : null; + } + + /** + * Persist (or clear) the current editor draft so the next resume of this + * session can restore it. Empty text deletes any stale draft. No-op when the + * session is not persisted. + */ + async saveDraft(text: string): Promise { + const draftPath = this.#getDraftPath(); + if (!draftPath || !this.persist) return; + if (text.length === 0) { + try { + await this.storage.unlink(draftPath); + } catch (err) { + if (!isEnoent(err)) throw err; + } + return; + } + // Force the session header onto disk so resume can find the file we are + // attaching this draft to. Without this, a session whose first message + // never produced an assistant reply would persist a draft next to a + // session file that does not exist on disk. + await this.ensureOnDisk(); + await this.storage.writeText(draftPath, text); + } + + /** + * Read and remove the saved draft. Returns the previously-saved text, or + * null when no draft is pending. Single-shot: a successful read removes the + * sidecar so a subsequent resume does not re-restore the same text. + */ + async consumeDraft(): Promise { + const draftPath = this.#getDraftPath(); + if (!draftPath) return null; + let text: string; + try { + text = await this.storage.readText(draftPath); + } catch (err) { + if (isEnoent(err)) return null; + throw err; + } + try { + await this.storage.unlink(draftPath); + } catch (err) { + if (!isEnoent(err)) throw err; + } + return text; + } + /** The source that set the session name: "user" (manual /rename or RPC) or "auto" (generated title). */ get titleSource(): "auto" | "user" | undefined { return this.#titleSource; diff --git a/packages/coding-agent/test/session-manager/draft.test.ts b/packages/coding-agent/test/session-manager/draft.test.ts new file mode 100644 index 000000000..a3aab3f4f --- /dev/null +++ b/packages/coding-agent/test/session-manager/draft.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { isEnoent, TempDir } from "@oh-my-pi/pi-utils"; + +async function fileExists(p: string): Promise { + try { + await Bun.file(p).stat(); + return true; + } catch (err) { + if (isEnoent(err)) return false; + throw err; + } +} + +describe("SessionManager draft", () => { + it("round-trips text through saveDraft + consumeDraft", async () => { + using tempDir = TempDir.createSync("@pi-session-draft-roundtrip-"); + const session = SessionManager.create(tempDir.path(), tempDir.path()); + session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + await session.flush(); + + await session.saveDraft("unsent text"); + + // consumeDraft is single-shot: returns the text and removes the sidecar. + expect(await session.consumeDraft()).toBe("unsent text"); + expect(await session.consumeDraft()).toBeNull(); + }); + + it("places the draft inside the artifacts directory so dropSession cleans it", async () => { + using tempDir = TempDir.createSync("@pi-session-draft-location-"); + const session = SessionManager.create(tempDir.path(), tempDir.path()); + session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + await session.flush(); + + await session.saveDraft("inside artifacts"); + + const artifactsDir = session.getArtifactsDir(); + expect(artifactsDir).not.toBeNull(); + const draftPath = path.join(artifactsDir!, "draft.txt"); + expect(await fileExists(draftPath)).toBe(true); + + const sessionFile = session.getSessionFile(); + if (!sessionFile) throw new Error("Expected session file"); + await session.dropSession(sessionFile); + + expect(await fileExists(draftPath)).toBe(false); + }); + + it("removes any stale draft when saving an empty string", async () => { + using tempDir = TempDir.createSync("@pi-session-draft-empty-"); + const session = SessionManager.create(tempDir.path(), tempDir.path()); + session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + await session.flush(); + + await session.saveDraft("first attempt"); + await session.saveDraft(""); + + expect(await session.consumeDraft()).toBeNull(); + }); + + it("forces the session header onto disk so resume can find the draft owner", async () => { + using tempDir = TempDir.createSync("@pi-session-draft-ensure-on-disk-"); + const session = SessionManager.create(tempDir.path(), tempDir.path()); + // No assistant reply yet: without ensureOnDisk the session file would not + // exist, leaving an orphan draft sidecar that --resume can never reach. + session.appendMessage({ role: "user", content: "draft only", timestamp: 1 }); + + await session.saveDraft("queued for next time"); + + const sessionFile = session.getSessionFile(); + if (!sessionFile) throw new Error("Expected session file"); + expect(await fileExists(sessionFile)).toBe(true); + }); + + it("is a no-op for in-memory sessions", async () => { + const session = SessionManager.inMemory(); + + await session.saveDraft("ignored"); + expect(await session.consumeDraft()).toBeNull(); + }); +});