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