pi-double-esc 1.0.11 → 1.0.13
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 +8 -7
- package/double-esc.ts +89 -75
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -78,15 +78,16 @@ PI_DOUBLE_ESC_MS=2000 pi
|
|
|
78
78
|
|
|
79
79
|
## How It Works
|
|
80
80
|
|
|
81
|
-
The extension
|
|
81
|
+
The extension decorates pi's current editor instead of replacing it:
|
|
82
82
|
|
|
83
|
-
1. On each `session_start`, `ctx.ui.
|
|
84
|
-
2.
|
|
85
|
-
3.
|
|
86
|
-
4.
|
|
87
|
-
5.
|
|
83
|
+
1. On each `session_start`, it reads the current editor factory with `ctx.ui.getEditorComponent()`.
|
|
84
|
+
2. It creates that editor, or a `CustomEditor` when no extension installed one earlier.
|
|
85
|
+
3. It decorates the editor's input and render methods with double-Escape behavior.
|
|
86
|
+
4. While streaming, the first Escape shows a visual hint and starts a debounce timer.
|
|
87
|
+
5. A second Escape within the window passes Escape to the current editor, which aborts the response.
|
|
88
|
+
6. Any other keypress or timeout expiry dismisses the hint.
|
|
88
89
|
|
|
89
|
-
The debounce logic lives in `src/double-esc-logic.ts` as pure functions for testability.
|
|
90
|
+
This design preserves behavior from editor extensions loaded before `pi-double-esc`. The debounce logic lives in `src/double-esc-logic.ts` as pure functions for testability.
|
|
90
91
|
|
|
91
92
|
## Development
|
|
92
93
|
|
package/double-esc.ts
CHANGED
|
@@ -11,14 +11,26 @@
|
|
|
11
11
|
* When not streaming:
|
|
12
12
|
* - Escape works normally (immediate) — no debounce applied
|
|
13
13
|
*
|
|
14
|
-
* Autocomplete dismissal always works on single Escape (handled by
|
|
14
|
+
* Autocomplete dismissal always works on single Escape (handled by the current editor).
|
|
15
|
+
* The extension decorates an editor installed earlier instead of replacing it.
|
|
15
16
|
*
|
|
16
17
|
* The debounce timeout defaults to 1500ms and can be configured via
|
|
17
18
|
* the PI_DOUBLE_ESC_MS environment variable.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
|
-
import {
|
|
21
|
-
|
|
21
|
+
import {
|
|
22
|
+
CustomEditor,
|
|
23
|
+
type ExtensionAPI,
|
|
24
|
+
type ExtensionContext,
|
|
25
|
+
type Theme,
|
|
26
|
+
} from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import {
|
|
28
|
+
matchesKey,
|
|
29
|
+
truncateToWidth,
|
|
30
|
+
visibleWidth,
|
|
31
|
+
type EditorComponent,
|
|
32
|
+
type TUI,
|
|
33
|
+
} from "@earendil-works/pi-tui";
|
|
22
34
|
import {
|
|
23
35
|
createInitialState,
|
|
24
36
|
getDefaultDebounceMs,
|
|
@@ -28,93 +40,95 @@ import {
|
|
|
28
40
|
type DoubleEscapeState,
|
|
29
41
|
} from "./src/index.js";
|
|
30
42
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
}
|
|
43
|
+
/** Add double-Escape behavior without replacing the current editor instance. */
|
|
44
|
+
function decorateDoubleEscapeEditor(
|
|
45
|
+
editor: EditorComponent,
|
|
46
|
+
tui: TUI,
|
|
47
|
+
appTheme: Theme,
|
|
48
|
+
isIdle: () => boolean,
|
|
49
|
+
): EditorComponent {
|
|
50
|
+
let escapeState: DoubleEscapeState = createInitialState();
|
|
51
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
56
52
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
53
|
+
const clearDebounce = (): void => {
|
|
54
|
+
clearTimeout(debounceTimer);
|
|
55
|
+
debounceTimer = undefined;
|
|
56
|
+
};
|
|
60
57
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}, getDefaultDebounceMs());
|
|
69
|
-
this.tui.requestRender();
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (result.action === "abort") {
|
|
74
|
-
this.clearDebounce();
|
|
75
|
-
super.handleInput(data);
|
|
76
|
-
return;
|
|
58
|
+
const handleInput = editor.handleInput.bind(editor);
|
|
59
|
+
editor.handleInput = (data: string): void => {
|
|
60
|
+
if (!matchesKey(data, "escape")) {
|
|
61
|
+
if (escapeState.hintActive) {
|
|
62
|
+
escapeState = handleOtherKey(escapeState).state;
|
|
63
|
+
clearDebounce();
|
|
64
|
+
tui.requestRender();
|
|
77
65
|
}
|
|
78
|
-
|
|
79
|
-
// "nothing" — idle state, pass through
|
|
80
|
-
super.handleInput(data);
|
|
66
|
+
handleInput(data);
|
|
81
67
|
return;
|
|
82
68
|
}
|
|
83
69
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
70
|
+
const result = handleEscape(escapeState, isIdle());
|
|
71
|
+
escapeState = result.state;
|
|
72
|
+
|
|
73
|
+
if (result.action === "show_hint") {
|
|
74
|
+
clearDebounce();
|
|
75
|
+
debounceTimer = setTimeout(() => {
|
|
76
|
+
escapeState = handleTimeout(escapeState).state;
|
|
77
|
+
tui.requestRender();
|
|
78
|
+
}, getDefaultDebounceMs());
|
|
79
|
+
tui.requestRender();
|
|
80
|
+
return;
|
|
89
81
|
}
|
|
90
82
|
|
|
91
|
-
|
|
92
|
-
|
|
83
|
+
clearDebounce();
|
|
84
|
+
handleInput(data);
|
|
85
|
+
};
|
|
93
86
|
|
|
94
|
-
render(
|
|
95
|
-
|
|
96
|
-
|
|
87
|
+
const render = editor.render.bind(editor);
|
|
88
|
+
editor.render = (width: number): string[] => {
|
|
89
|
+
const lines = render(width);
|
|
90
|
+
if (!escapeState.hintActive || lines.length === 0) return lines;
|
|
97
91
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
92
|
+
const label = " esc again to abort ";
|
|
93
|
+
const styledLabel = appTheme.fg("dim", label);
|
|
94
|
+
const last = lines.length - 1;
|
|
95
|
+
const line = lines[last]!;
|
|
96
|
+
const lineWidth = visibleWidth(line);
|
|
97
|
+
const gap = 2;
|
|
98
|
+
if (lineWidth >= label.length + gap) {
|
|
99
|
+
lines[last] =
|
|
100
|
+
truncateToWidth(line, lineWidth - label.length - gap, "") +
|
|
101
|
+
styledLabel +
|
|
102
|
+
truncateToWidth(line, gap, "");
|
|
108
103
|
}
|
|
109
|
-
|
|
110
104
|
return lines;
|
|
111
|
-
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return editor;
|
|
112
108
|
}
|
|
113
109
|
|
|
110
|
+
// Marks the factory this extension installs. Repeated session_start events
|
|
111
|
+
// (reload, new session, resume) must reuse it instead of stacking a second
|
|
112
|
+
// decorator: the outer copy would swallow the first Escape and the inner one
|
|
113
|
+
// would then show the hint again instead of aborting.
|
|
114
|
+
const DECORATED_FACTORY = Symbol("pi-double-esc.decoratedFactory");
|
|
115
|
+
|
|
116
|
+
type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
|
|
117
|
+
type DecoratedEditorFactory = EditorFactory & { [DECORATED_FACTORY]?: true };
|
|
118
|
+
|
|
114
119
|
export default function (pi: ExtensionAPI) {
|
|
115
120
|
pi.on("session_start", (_event, ctx) => {
|
|
116
|
-
ctx.ui.
|
|
117
|
-
|
|
118
|
-
|
|
121
|
+
const previousEditorFactory = ctx.ui.getEditorComponent() as DecoratedEditorFactory | undefined;
|
|
122
|
+
if (previousEditorFactory?.[DECORATED_FACTORY]) return;
|
|
123
|
+
|
|
124
|
+
const editorFactory: DecoratedEditorFactory = (tui, editorTheme, keybindings) => {
|
|
125
|
+
const editor =
|
|
126
|
+
previousEditorFactory?.(tui, editorTheme, keybindings) ??
|
|
127
|
+
new CustomEditor(tui, editorTheme, keybindings);
|
|
128
|
+
return decorateDoubleEscapeEditor(editor, tui, ctx.ui.theme, () => ctx.isIdle());
|
|
129
|
+
};
|
|
130
|
+
editorFactory[DECORATED_FACTORY] = true;
|
|
131
|
+
|
|
132
|
+
ctx.ui.setEditorComponent(editorFactory);
|
|
119
133
|
});
|
|
120
134
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-double-esc",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
4
4
|
"description": "Prevent accidental Escape from aborting the LLM — requires double-press to interrupt while streaming",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Tom X Nguyen",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"lint:dead": "knip --no-gitignore"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
41
|
-
"@earendil-works/pi-tui": "0.
|
|
40
|
+
"@earendil-works/pi-coding-agent": "0.86.0",
|
|
41
|
+
"@earendil-works/pi-tui": "0.86.0",
|
|
42
42
|
"@types/node": "25.9.1",
|
|
43
43
|
"@vitest/coverage-v8": "4.1.7",
|
|
44
44
|
"knip": "6.14.1",
|