feat(coding-agent): added unsent editor draft persistence for resumed sessions

- Added session-draft persistence methods in SessionManager to write unsent editor text to an artifacts-sidecar draft file and delete it after single-shot consumption.
- Persisted editor text during interactive shutdown and restored that draft on resume when the editor was empty, enabling Ctrl+D draft recovery.
- Updated Ctrl+D handling in the editor/controller path and added tests covering draft round-trip, artifact cleanup, stale-draft eviction, and in-memory no-op behavior.
This commit is contained in:
can1357
2026-05-04 02:20:29 +02:00
parent 0fd1e1f637
commit c2a0253028
6 changed files with 174 additions and 6 deletions
+4
View File
@@ -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
@@ -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;
}
@@ -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();
}
@@ -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
@@ -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<void> {
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<string | null> {
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;
@@ -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<boolean> {
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();
});
});