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 +102 -0
- package/double-esc.ts +120 -0
- package/package.json +57 -0
- package/src/double-esc-logic.ts +102 -0
- package/src/index.ts +2 -0
- package/vitest.config.ts +15 -0
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
|
+
[](https://github.com/earendil-works/pi-coding-agent)
|
|
10
|
+
[](./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
package/vitest.config.ts
ADDED
|
@@ -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
|
+
});
|