diff --git a/extensions/clear-context/index.ts b/extensions/clear-context/index.ts new file mode 100644 index 00000000..7923dc94 --- /dev/null +++ b/extensions/clear-context/index.ts @@ -0,0 +1,83 @@ +import { + type ExtensionAPI, + type ExtensionContext, + type KeybindingsManager, +} from "@earendil-works/pi-coding-agent"; +import { Key, matchesKey, type EditorComponent } from "@earendil-works/pi-tui"; +import { + BelowEditorNavigationEditor, + BelowEditorStripState, +} from "../shared/below-editor-navigation.ts"; +import { + registerEditorLayer, + removeEditorLayer, +} from "../shared/editor-layers.ts"; + +export function shouldClearContext( + data: string, + editorText: string, + isIdle: boolean, +) { + return isIdle && editorText.length === 0 && matchesKey(data, Key.ctrl("c")); +} + +export class ClearContextEditor extends BelowEditorNavigationEditor { + private readonly isIdle: () => boolean; + + constructor( + base: EditorComponent, + keybindings: KeybindingsManager, + isIdle: () => boolean, + ) { + super( + base, + keybindings, + new BelowEditorStripState(), + () => false, + () => undefined, + () => undefined, + ); + this.isIdle = isIdle; + } + + override handleInput(data: string) { + if (shouldClearContext(data, this.getText(), this.isIdle())) { + // Route through Pi's built-in /new command so session replacement keeps + // Pi's persistence, cleanup, and transcript lifecycle as the source of truth. + this.setText("/new"); + this.onSubmit?.("/new"); + return; + } + + super.handleInput(data); + } +} + +function installClearContextShortcut(pi: ExtensionAPI, ctx: ExtensionContext) { + if (ctx.mode !== "tui") return () => {}; + + registerEditorLayer(pi, ctx, { + id: "clear-context", + order: 100, + wrap: (base, _tui, _theme, keybindings) => + new ClearContextEditor(base, keybindings, () => ctx.isIdle()), + }); + + return () => removeEditorLayer(pi, "clear-context"); +} + +export default function clearContext(pi: ExtensionAPI) { + let removeShortcut = () => {}; + + pi.on("session_start", (_event, ctx) => { + removeShortcut(); + removeShortcut = installClearContextShortcut(pi, ctx); + }); + + pi.on("session_shutdown", () => { + removeShortcut(); + removeShortcut = () => {}; + }); +} + +export { installClearContextShortcut }; diff --git a/tests/extensions/clear-context/index.test.ts b/tests/extensions/clear-context/index.test.ts new file mode 100644 index 00000000..bcd72971 --- /dev/null +++ b/tests/extensions/clear-context/index.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + SessionManager, + type ExtensionAPI, + type ExtensionContext, + type KeybindingsManager, +} from "@earendil-works/pi-coding-agent"; +import type { EditorComponent, EditorTheme, TUI } from "@earendil-works/pi-tui"; +import type { EditorLayer } from "../../../extensions/shared/editor-layers.ts"; +import { + ClearContextEditor, + installClearContextShortcut, + shouldClearContext, +} from "../../../extensions/clear-context/index.ts"; + +test("Ctrl+C clears context only for an idle empty editor", () => { + assert.equal(shouldClearContext("\u0003", "", true), true); + assert.equal(shouldClearContext("\u0003", "draft", true), false); + assert.equal(shouldClearContext("\u0003", "", false), false); + assert.equal(shouldClearContext("x", "", true), false); +}); + +test("whitespace-only drafts preserve the native editor behavior", () => { + assert.equal(shouldClearContext("\u0003", " \n", true), false); +}); + +test("shortcut is registered as an editor layer and preserves the existing editor", () => { + const listeners = new Map void>(); + let registeredLayer: EditorLayer | undefined; + const pi = { + events: { + emit(channel: string, value: unknown) { + if (channel === "openpi:editor-layers:register") { + registeredLayer = (value as { layer: EditorLayer }).layer; + } + listeners.get(channel)?.(value); + }, + on(channel: string, listener: (value: unknown) => void) { + listeners.set(channel, listener); + }, + }, + } as unknown as ExtensionAPI; + const base: EditorComponent = { + render: () => [], + invalidate() {}, + handleInput(data) { + inputs.push(data); + }, + getText: () => text, + setText(value) { + text = value; + }, + }; + const inputs: string[] = []; + let text = "draft"; + const ctx = { + mode: "tui", + isIdle: () => true, + } as unknown as ExtensionContext; + + const remove = installClearContextShortcut(pi, ctx); + assert.equal(registeredLayer?.id, "clear-context"); + const editor = registeredLayer!.wrap( + base, + {} as TUI, + {} as EditorTheme, + { matches: () => false } as unknown as KeybindingsManager, + ); + editor.handleInput("x"); + assert.deepEqual(inputs, ["x"]); + + remove(); +}); + +test("layer handles Ctrl+C only for idle empty editor", () => { + const inputs: string[] = []; + let text = ""; + let submitted: string | undefined; + const base: EditorComponent = { + render: () => [], + invalidate() {}, + handleInput(data) { + inputs.push(data); + }, + getText: () => text, + setText(value) { + text = value; + }, + onSubmit(value) { + submitted = value; + }, + }; + const editor = new ClearContextEditor( + base, + { matches: () => false } as unknown as KeybindingsManager, + () => true, + ); + + editor.handleInput("\u0003"); + assert.equal(submitted, "/new"); + assert.deepEqual(inputs, []); + + text = "draft"; + editor.handleInput("\u0003"); + assert.deepEqual(inputs, ["\u0003"]); +}); + +test("Pi's new session has no history from the cleared session", () => { + const session = SessionManager.inMemory(process.cwd()); + session.appendCustomMessageEntry("test-history", "old context", true); + const previousSessionId = session.getSessionId(); + + session.newSession(); + + assert.notEqual(session.getSessionId(), previousSessionId); + assert.deepEqual(session.getBranch(), []); + assert.deepEqual(session.buildSessionContext().messages, []); +});