pi-double-esc 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ <div align="center">
2
+
3
+ # ⏏️ pi-double-esc
4
+
5
+ **Prevent accidental Escape aborts in [pi](https://github.com/earendil-works/pi-coding-agent)**
6
+
7
+ _Require a second Escape press within 500ms to confirm any abort action._
8
+
9
+ [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi-coding-agent)
10
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
11
+
12
+ <img alt="double-esc hint" src="./media/esc.jpg" width="800">
13
+
14
+ </div>
15
+
16
+ ---
17
+
18
+ ## What It Does
19
+
20
+ When the LLM is streaming a response:
21
+
22
+ - **First Escape press**: Shows an `esc again to abort` hint on the editor border — does **not** abort
23
+ - **Second Escape** within the debounce window: Actually aborts the streaming response
24
+ - If the debounce window expires, the hint clears and escape resets
25
+
26
+ When the LLM is **not** streaming, Escape works normally (immediate) — no debounce applied. Autocomplete dismissal always works on a single Escape.
27
+
28
+ ## Installation
29
+
30
+ ### Option 1: Install via pi package (Recommended)
31
+
32
+ Install directly from GitHub as a pi package:
33
+
34
+ ```bash
35
+ pi install https://github.com/monotykamary/pi-double-esc@main
36
+ ```
37
+
38
+ Or add to your `settings.json`:
39
+
40
+ ```json
41
+ {
42
+ "packages": [
43
+ "https://github.com/monotykamary/pi-double-esc@main"
44
+ ]
45
+ }
46
+ ```
47
+
48
+ ### Option 2: Global Installation
49
+
50
+ Copy the extension to pi's global extensions directory:
51
+
52
+ ```bash
53
+ cp double-esc.ts ~/.pi/agent/extensions/
54
+ ```
55
+
56
+ ### Option 3: Project-Local Installation
57
+
58
+ Copy to your project's `.pi/extensions/` directory:
59
+
60
+ ```bash
61
+ mkdir -p .pi/extensions
62
+ cp double-esc.ts .pi/extensions/
63
+ ```
64
+
65
+ ### Option 4: Quick Test
66
+
67
+ ```bash
68
+ pi -e ./double-esc.ts
69
+ ```
70
+
71
+ ## Configuration
72
+
73
+ Set the `PI_DOUBLE_ESC_MS` environment variable to change the debounce timeout (default: 1500ms):
74
+
75
+ ```bash
76
+ PI_DOUBLE_ESC_MS=2000 pi
77
+ ```
78
+
79
+ ## How It Works
80
+
81
+ The extension replaces pi's editor component with a `CustomEditor` subclass that intercepts Escape key presses:
82
+
83
+ 1. On each `session_start`, `ctx.ui.setEditorComponent()` installs the custom editor
84
+ 2. The editor uses `ctx.isIdle()` (which reflects `!session.isStreaming`) to detect streaming state
85
+ 3. While streaming, the first Escape shows a visual hint and starts a debounce timer
86
+ 4. A second Escape within the window calls `super.handleInput(data)` to perform the actual abort
87
+ 5. Any other keypress or timeout expiry dismisses the hint
88
+
89
+ The debounce logic lives in `src/double-esc-logic.ts` as pure functions for testability.
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ npm install # install dev dependencies
95
+ npm test # run tests
96
+ npm run typecheck # type check
97
+ npm run lint:dead # check for unused exports
98
+ ```
99
+
100
+ ## License
101
+
102
+ MIT
package/double-esc.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Double Escape Debounce — prevents accidental Escape from aborting the LLM
3
+ *
4
+ * Usage: pi --extension ./double-esc.ts
5
+ *
6
+ * When the LLM is streaming:
7
+ * - First Escape press: shows "ESC AGAIN TO ABORT" hint in the editor border
8
+ * - Second Escape within the debounce window: actually aborts streaming
9
+ * - If the window expires, the hint clears and escape resets
10
+ *
11
+ * When not streaming:
12
+ * - Escape works normally (immediate) — no debounce applied
13
+ *
14
+ * Autocomplete dismissal always works on single Escape (handled by Editor parent).
15
+ *
16
+ * The debounce timeout defaults to 1500ms and can be configured via
17
+ * the PI_DOUBLE_ESC_MS environment variable.
18
+ */
19
+
20
+ import { CustomEditor, type ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
21
+ import { matchesKey, truncateToWidth, visibleWidth, type TUI, type EditorTheme } from "@earendil-works/pi-tui";
22
+ import {
23
+ createInitialState,
24
+ getDefaultDebounceMs,
25
+ handleEscape,
26
+ handleOtherKey,
27
+ handleTimeout,
28
+ type DoubleEscapeState,
29
+ } from "./src/index.js";
30
+
31
+ class DoubleEscapeEditor extends CustomEditor {
32
+ private escState: DoubleEscapeState = createInitialState();
33
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
34
+ private isIdle: () => boolean;
35
+ private appTheme: Theme;
36
+
37
+ constructor(
38
+ tui: TUI,
39
+ editorTheme: EditorTheme,
40
+ keybindings: any,
41
+ theme: Theme,
42
+ isIdle: () => boolean,
43
+ options?: any,
44
+ ) {
45
+ super(tui, editorTheme, keybindings, options);
46
+ this.appTheme = theme;
47
+ this.isIdle = isIdle;
48
+ }
49
+
50
+ private clearDebounce(): void {
51
+ if (this.debounceTimer) {
52
+ clearTimeout(this.debounceTimer);
53
+ this.debounceTimer = null;
54
+ }
55
+ }
56
+
57
+ handleInput(data: string): void {
58
+ if (matchesKey(data, "escape")) {
59
+ const result = handleEscape(this.escState, this.isIdle());
60
+
61
+ this.escState = result.state;
62
+
63
+ if (result.action === "show_hint") {
64
+ this.clearDebounce();
65
+ this.debounceTimer = setTimeout(() => {
66
+ this.escState = handleTimeout(this.escState).state;
67
+ this.tui.requestRender();
68
+ }, getDefaultDebounceMs());
69
+ this.tui.requestRender();
70
+ return;
71
+ }
72
+
73
+ if (result.action === "abort") {
74
+ this.clearDebounce();
75
+ super.handleInput(data);
76
+ return;
77
+ }
78
+
79
+ // "nothing" — idle state, pass through
80
+ super.handleInput(data);
81
+ return;
82
+ }
83
+
84
+ // Non-escape key while hint is showing: dismiss hint
85
+ if (this.escState.hintActive) {
86
+ this.escState = handleOtherKey(this.escState).state;
87
+ this.clearDebounce();
88
+ this.tui.requestRender();
89
+ }
90
+
91
+ super.handleInput(data);
92
+ }
93
+
94
+ render(width: number): string[] {
95
+ const lines = super.render(width);
96
+ if (lines.length === 0) return lines;
97
+
98
+ if (this.escState.hintActive) {
99
+ const label = " esc again to abort ";
100
+ const styledLabel = this.appTheme.fg("dim", label);
101
+ const last = lines.length - 1;
102
+ const line = lines[last]!;
103
+ const lineW = visibleWidth(line);
104
+ const gap = 2;
105
+ if (lineW >= label.length + gap) {
106
+ lines[last] = truncateToWidth(line, lineW - label.length - gap, "") + styledLabel + truncateToWidth(line, gap, "");
107
+ }
108
+ }
109
+
110
+ return lines;
111
+ }
112
+ }
113
+
114
+ export default function (pi: ExtensionAPI) {
115
+ pi.on("session_start", (_event, ctx) => {
116
+ ctx.ui.setEditorComponent((tui, editorTheme, kb) =>
117
+ new DoubleEscapeEditor(tui, editorTheme, kb, ctx.ui.theme, () => ctx.isIdle()),
118
+ );
119
+ });
120
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "pi-double-esc",
3
+ "version": "1.0.1",
4
+ "description": "Prevent accidental Escape from aborting the LLM \u2014 requires double-press to interrupt while streaming",
5
+ "type": "module",
6
+ "author": "Tom X Nguyen",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/monotykamary/pi-double-esc.git"
11
+ },
12
+ "homepage": "https://github.com/monotykamary/pi-double-esc#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/monotykamary/pi-double-esc/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi",
19
+ "pi-coding-agent",
20
+ "extension",
21
+ "escape",
22
+ "debounce",
23
+ "double-escape",
24
+ "interrupt",
25
+ "abort"
26
+ ],
27
+ "files": [
28
+ "*.ts",
29
+ "src/",
30
+ "README.md"
31
+ ],
32
+ "scripts": {
33
+ "test": "vitest run",
34
+ "test:watch": "vitest",
35
+ "test:coverage": "vitest run --coverage",
36
+ "typecheck": "tsc --noEmit",
37
+ "lint:dead": "knip --no-gitignore"
38
+ },
39
+ "devDependencies": {
40
+ "@earendil-works/pi-coding-agent": "0.79.4",
41
+ "@earendil-works/pi-tui": "0.79.4",
42
+ "@types/node": "25.9.1",
43
+ "@vitest/coverage-v8": "4.1.7",
44
+ "knip": "6.14.1",
45
+ "typescript": "6.0.3",
46
+ "vitest": "4.1.7"
47
+ },
48
+ "pi": {
49
+ "extensions": [
50
+ "./double-esc.ts"
51
+ ]
52
+ },
53
+ "overrides": {
54
+ "brace-expansion": "5.0.6"
55
+ },
56
+ "peerDependencies": {}
57
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Double-escape debounce logic — pure functions and state class for testing.
3
+ *
4
+ * The editor extension in double-esc.ts uses these building blocks.
5
+ * All state is encapsulated in DoubleEscapeState; the editor merely
6
+ * calls press() and clearOnOtherKey() and reads the resulting state.
7
+ */
8
+
9
+ export interface DoubleEscapeState {
10
+ /** First escape has been pressed, waiting for confirmation */
11
+ hintActive: boolean;
12
+ /** Number of escapes in the current debounce window */
13
+ escapeCount: number;
14
+ }
15
+
16
+ export interface DoubleEscapeResult {
17
+ /** Updated state after the action */
18
+ state: DoubleEscapeState;
19
+ /** What the caller should do */
20
+ action: "show_hint" | "abort" | "nothing";
21
+ }
22
+
23
+ const DEFAULT_DEBOUNCE_MS = 1500;
24
+
25
+ export function getDefaultDebounceMs(): number {
26
+ const env = process.env.PI_DOUBLE_ESC_MS;
27
+ if (env) {
28
+ const parsed = parseInt(env, 10);
29
+ if (!isNaN(parsed) && parsed > 0) return parsed;
30
+ }
31
+ return DEFAULT_DEBOUNCE_MS;
32
+ }
33
+
34
+ /**
35
+ * Handle an escape key press.
36
+ *
37
+ * @param currentState Current debounce state
38
+ * @param isIdle Whether the agent is idle (not streaming)
39
+ * @returns Result with updated state and action
40
+ */
41
+ export function handleEscape(
42
+ currentState: DoubleEscapeState,
43
+ isIdle: boolean,
44
+ ): DoubleEscapeResult {
45
+ // When idle and no hint is showing, pass through immediately
46
+ if (isIdle && !currentState.hintActive) {
47
+ return { state: currentState, action: "nothing" };
48
+ }
49
+
50
+ const newCount = currentState.escapeCount + 1;
51
+
52
+ if (newCount === 1) {
53
+ // First press: show hint, start debounce
54
+ return {
55
+ state: { hintActive: true, escapeCount: 1 },
56
+ action: "show_hint",
57
+ };
58
+ }
59
+
60
+ // Second+ press within window: abort
61
+ return {
62
+ state: { hintActive: false, escapeCount: 0 },
63
+ action: "abort",
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Handle any non-escape key press while hint is showing.
69
+ * Dismisses the hint and resets the debounce state.
70
+ */
71
+ export function handleOtherKey(currentState: DoubleEscapeState): DoubleEscapeResult {
72
+ if (!currentState.hintActive) {
73
+ return { state: currentState, action: "nothing" };
74
+ }
75
+
76
+ return {
77
+ state: { hintActive: false, escapeCount: 0 },
78
+ action: "nothing",
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Handle debounce timeout expiry.
84
+ * Clears the hint and resets state.
85
+ */
86
+ export function handleTimeout(currentState: DoubleEscapeState): DoubleEscapeResult {
87
+ if (!currentState.hintActive) {
88
+ return { state: currentState, action: "nothing" };
89
+ }
90
+
91
+ return {
92
+ state: { hintActive: false, escapeCount: 0 },
93
+ action: "nothing",
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Create the initial state.
99
+ */
100
+ export function createInitialState(): DoubleEscapeState {
101
+ return { hintActive: false, escapeCount: 0 };
102
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { handleEscape, handleOtherKey, handleTimeout, createInitialState, getDefaultDebounceMs } from "./double-esc-logic.js";
2
+ export type { DoubleEscapeState, DoubleEscapeResult } from "./double-esc-logic.js";
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: "node",
7
+ include: ["__tests__/**/*.test.ts"],
8
+ exclude: ["node_modules", "dist", ".idea", ".git", ".cache"],
9
+ coverage: {
10
+ provider: "v8",
11
+ reporter: ["text", "json", "html"],
12
+ exclude: ["node_modules/", "**/*.d.ts", "**/*.test.ts"],
13
+ },
14
+ },
15
+ });