pi-double-esc 1.0.11 → 1.0.12

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.
Files changed (3) hide show
  1. package/README.md +8 -7
  2. package/double-esc.ts +89 -75
  3. package/package.json +1 -1
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 replaces pi's editor component with a `CustomEditor` subclass that intercepts Escape key presses:
81
+ The extension decorates pi's current editor instead of replacing it:
82
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
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 Editor parent).
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 { CustomEditor, type ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
21
- import { matchesKey, truncateToWidth, visibleWidth, type TUI, type EditorTheme } from "@earendil-works/pi-tui";
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
- 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
- }
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
- handleInput(data: string): void {
58
- if (matchesKey(data, "escape")) {
59
- const result = handleEscape(this.escState, this.isIdle());
53
+ const clearDebounce = (): void => {
54
+ clearTimeout(debounceTimer);
55
+ debounceTimer = undefined;
56
+ };
60
57
 
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;
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
- // 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();
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
- super.handleInput(data);
92
- }
83
+ clearDebounce();
84
+ handleInput(data);
85
+ };
93
86
 
94
- render(width: number): string[] {
95
- const lines = super.render(width);
96
- if (lines.length === 0) return lines;
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
- 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
- }
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.setEditorComponent((tui, editorTheme, kb) =>
117
- new DoubleEscapeEditor(tui, editorTheme, kb, ctx.ui.theme, () => ctx.isIdle()),
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.11",
3
+ "version": "1.0.12",
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",