@thazhemadam/pi-vim 0.1.0

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,12 @@
1
+ # pi-vim
2
+
3
+ Vim-style modal editing for [Pi](https://pi.dev).
4
+
5
+ This package contains the Pi-specific integration for [`vim-state`](../../vim-state). Its package entry point is compiled ESM in `dist/index.js`, with declarations in `dist/index.d.ts`.
6
+
7
+ For local development from the monorepo root:
8
+
9
+ ```bash
10
+ npm install
11
+ npm run dev:pi
12
+ ```
@@ -0,0 +1,67 @@
1
+ import { CustomEditor, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { type EditorOptions, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
3
+ import { VimEditor, type VimEditorHost, type VimEditorOptions, type VimSnapshot } from "@thazhemadam/vim-state";
4
+ declare class PiEditorHost extends CustomEditor implements VimEditorHost {
5
+ sendInputToEditor(data: string): void;
6
+ }
7
+ declare const VimPiEditor_base: typeof PiEditorHost & (new (...args: any[]) => {
8
+ readonly vimEditor: VimEditor;
9
+ });
10
+ export declare class VimPiEditor extends VimPiEditor_base {
11
+ private readonly vim;
12
+ private cursorStyle;
13
+ private readonly appKeybindings;
14
+ private readonly vimHistory;
15
+ private activeInsertSnapshot;
16
+ /**
17
+ * Set while undo/redo restores a snapshot through public setters. Public
18
+ * setters normally reset history; suppress that reset for history restores.
19
+ */
20
+ private isRestoringHistorySnapshot;
21
+ constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, vimOptions?: VimEditorOptions, options?: EditorOptions);
22
+ get vimSnapshot(): VimSnapshot;
23
+ /** Replace the whole prompt and treat the result as the new history baseline. */
24
+ setText(text: string): void;
25
+ /** Insert programmatic text as one undoable linear history edit. */
26
+ insertTextAtCursor(text: string): void;
27
+ /** Restore the previous vim-pi-owned linear history snapshot. */
28
+ undoEditor(): void;
29
+ /** Restore the next vim-pi-owned linear history snapshot. */
30
+ redoEditor(): void;
31
+ handleInput(data: string): void;
32
+ render(width: number): string[];
33
+ restoreCursorStyle(): void;
34
+ /** Return true for host-handled inputs that replace or clear the prompt. */
35
+ private isHostBaselineResetInput;
36
+ /** Return true when input matches a Pi app-level shortcut that should not edit text. */
37
+ private isAppShortcutInput;
38
+ /** Capture the public editor state needed for vim-pi-owned history. */
39
+ private createSnapshot;
40
+ /**
41
+ * Clear all linear undo/redo entries and treat the current buffer as the new
42
+ * starting state. If the editor is currently in Insert/Replace, start a fresh
43
+ * session snapshot from that state so subsequent typed text remains undoable.
44
+ */
45
+ private resetHistoryBaseline;
46
+ /** Commit a new linear undo point and discard any abandoned redo path. */
47
+ private commitNewEdit;
48
+ /** Update linear history after one input has been applied to the editor. */
49
+ private updateLinearHistory;
50
+ /** Commit the active Insert/Replace session if it changed buffer text. */
51
+ private commitActiveInsertSession;
52
+ /** Apply simple Normal-mode history keys without routing through host undo. */
53
+ private handleNormalHistoryKey;
54
+ /** Map raw host undo shortcuts onto vim-pi history to avoid host drift. */
55
+ private handleHostHistoryShortcut;
56
+ /** Pop undo history, push the current state to redo, and restore the prior state. */
57
+ private undoHistory;
58
+ /** Pop redo history, push the current state to undo, and restore the next state. */
59
+ private redoHistory;
60
+ /** Restore a history snapshot without treating public setter calls as new edits. */
61
+ private restoreHistorySnapshot;
62
+ /** Keep the hardware cursor visible while this editor owns cursor rendering. */
63
+ private ensureHardwareCursorVisible;
64
+ /** Sync terminal cursor shape with Vim mode, avoiding duplicate escape writes. */
65
+ private syncCursorStyle;
66
+ }
67
+ export {};
package/dist/editor.js ADDED
@@ -0,0 +1,359 @@
1
+ import { CustomEditor, } from "@earendil-works/pi-coding-agent";
2
+ import { createActor } from "xstate";
3
+ import { truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
4
+ import { getVimMode, getVimModeLabel, isVimOperatorMode, isVimVisualMode, LinearHistory, VimEditor, vimMachine, } from "@thazhemadam/vim-state";
5
+ import { isPrintablePiInput, piInputToVimEvent } from "./keymap.js";
6
+ import { highlightVisualSelection, removeReverseVideoCursor, } from "./visual-selection.js";
7
+ class PiEditorHost extends CustomEditor {
8
+ sendInputToEditor(data) {
9
+ super.handleInput(data);
10
+ }
11
+ }
12
+ const MAX_HISTORY_SNAPSHOTS = 100;
13
+ export class VimPiEditor extends VimEditor(PiEditorHost) {
14
+ vim;
15
+ cursorStyle;
16
+ appKeybindings;
17
+ vimHistory = new LinearHistory(MAX_HISTORY_SNAPSHOTS);
18
+ activeInsertSnapshot;
19
+ /**
20
+ * Set while undo/redo restores a snapshot through public setters. Public
21
+ * setters normally reset history; suppress that reset for history restores.
22
+ */
23
+ isRestoringHistorySnapshot = false;
24
+ constructor(tui, theme, keybindings, vimOptions = {}, options) {
25
+ super(tui, theme, keybindings, options);
26
+ this.vimEditor.setOptions(vimOptions);
27
+ this.vim = createActor(vimMachine, {
28
+ input: { editor: this.vimEditor },
29
+ }).start();
30
+ this.appKeybindings = keybindings;
31
+ this.activeInsertSnapshot = this.createSnapshot();
32
+ this.ensureHardwareCursorVisible();
33
+ this.syncCursorStyle();
34
+ }
35
+ get vimSnapshot() {
36
+ return this.vim.getSnapshot();
37
+ }
38
+ /** Replace the whole prompt and treat the result as the new history baseline. */
39
+ setText(text) {
40
+ super.setText(text);
41
+ if (!this.isRestoringHistorySnapshot) {
42
+ this.resetHistoryBaseline();
43
+ }
44
+ }
45
+ /** Insert programmatic text as one undoable linear history edit. */
46
+ insertTextAtCursor(text) {
47
+ const before = this.createSnapshot();
48
+ super.insertTextAtCursor(text);
49
+ if (!this.isRestoringHistorySnapshot && this.getText() !== before.text) {
50
+ this.commitNewEdit(before);
51
+ if (isInsertHistorySession(this.vimSnapshot)) {
52
+ this.activeInsertSnapshot = this.createSnapshot();
53
+ }
54
+ }
55
+ }
56
+ /** Restore the previous vim-pi-owned linear history snapshot. */
57
+ undoEditor() {
58
+ this.undoHistory();
59
+ }
60
+ /** Restore the next vim-pi-owned linear history snapshot. */
61
+ redoEditor() {
62
+ this.redoHistory();
63
+ }
64
+ handleInput(data) {
65
+ const before = this.createSnapshot();
66
+ const previousSnapshot = this.vimSnapshot;
67
+ const previousMode = getVimMode(previousSnapshot);
68
+ const event = piInputToVimEvent(data);
69
+ if (this.handleNormalHistoryKey(previousSnapshot, event.key)) {
70
+ this.tui.requestRender();
71
+ return;
72
+ }
73
+ if (this.handleHostHistoryShortcut(data)) {
74
+ this.tui.requestRender();
75
+ return;
76
+ }
77
+ let historyWasReset = false;
78
+ this.vim.send(event);
79
+ this.syncCursorStyle();
80
+ const mode = getVimMode(this.vimSnapshot);
81
+ // If you were in Insert mode and are still in Insert mode,
82
+ // then pass the data to the underlying Pi editor.
83
+ if (previousMode === "insert" && mode === "insert") {
84
+ super.handleInput(data);
85
+ if (this.isHostBaselineResetInput(data, event.key)) {
86
+ this.resetHistoryBaseline();
87
+ historyWasReset = true;
88
+ }
89
+ }
90
+ else if (previousMode === "replace" &&
91
+ mode === "replace" &&
92
+ isPrintablePiInput(data)) {
93
+ super.handleInput(data);
94
+ }
95
+ else if (shouldPassOnlyEnterThrough(previousSnapshot, event.key)) {
96
+ // If a user presses only "Enter", we should pass it through
97
+ // so the can be prompt can be submitted.
98
+ super.handleInput(data);
99
+ this.resetHistoryBaseline();
100
+ historyWasReset = true;
101
+ if (isVimVisualMode(previousSnapshot)) {
102
+ this.vim.send({ type: "KEY", key: "escape" });
103
+ this.syncCursorStyle();
104
+ }
105
+ }
106
+ else if (shouldHandleNormalUpDown(previousSnapshot, event.key)) {
107
+ const atHistoryBoundary = event.key === "up"
108
+ ? this.getCursor().line === 0
109
+ : this.getCursor().line === this.getLines().length - 1;
110
+ if (atHistoryBoundary) {
111
+ // If a user presses only "Up"/"Down" at a prompt-history boundary,
112
+ // pass it through so Pi's prompt history can be cycled.
113
+ super.handleInput(data);
114
+ this.resetHistoryBaseline();
115
+ historyWasReset = true;
116
+ this.vimEditor.clampCursorColumn();
117
+ }
118
+ else {
119
+ this.vimEditor.move(event.key);
120
+ }
121
+ }
122
+ else if (previousMode === "normal" &&
123
+ mode === "normal" &&
124
+ this.isAppShortcutInput(data)) {
125
+ super.handleInput(data);
126
+ // Host shortcuts can submit, clear, or otherwise replace the prompt.
127
+ // Start history from the resulting buffer so old prompt text cannot be
128
+ // restored into the next prompt with Vim undo.
129
+ this.resetHistoryBaseline();
130
+ historyWasReset = true;
131
+ }
132
+ this.updateLinearHistory(before, previousSnapshot, this.vimSnapshot, event.key, historyWasReset);
133
+ this.tui.requestRender();
134
+ }
135
+ render(width) {
136
+ // Pi reapplies its global cursor setting after extension session_start on
137
+ // /reload. Reassert ownership while this hardware-cursor editor is active.
138
+ this.ensureHardwareCursorVisible();
139
+ const lines = super.render(width);
140
+ if (lines.length === 0) {
141
+ return lines;
142
+ }
143
+ const snapshot = this.vimSnapshot;
144
+ if (vimCursorStyle(snapshot) !== "block" || isVimVisualMode(snapshot)) {
145
+ removeReverseVideoCursor(lines);
146
+ }
147
+ if (isVimVisualMode(snapshot) && snapshot.context.visual) {
148
+ highlightVisualSelection(this, lines, snapshot.context.visual, width, this.tui.terminal.rows);
149
+ }
150
+ const label = getVimModeLabel(snapshot);
151
+ const labelWidth = label.length + 2;
152
+ const highlightedLabel = highlightVimModeLabel(label);
153
+ const last = lines.length - 1;
154
+ if (visibleWidth(lines[last]) >= labelWidth) {
155
+ lines[last] =
156
+ truncateToWidth(lines[last], Math.max(0, width - labelWidth), "") +
157
+ highlightedLabel;
158
+ }
159
+ return lines;
160
+ }
161
+ restoreCursorStyle() {
162
+ this.cursorStyle = "block";
163
+ this.tui.terminal.write("\x1b[2 q");
164
+ }
165
+ /** Return true for host-handled inputs that replace or clear the prompt. */
166
+ isHostBaselineResetInput(data, key) {
167
+ return key === "enter" || this.isAppShortcutInput(data);
168
+ }
169
+ /** Return true when input matches a Pi app-level shortcut that should not edit text. */
170
+ isAppShortcutInput(data) {
171
+ return (this.appKeybindings.matches(data, "app.interrupt") ||
172
+ this.appKeybindings.matches(data, "app.clear") ||
173
+ this.appKeybindings.matches(data, "app.suspend") ||
174
+ (this.getText().length === 0 &&
175
+ this.appKeybindings.matches(data, "app.exit")));
176
+ }
177
+ /** Capture the public editor state needed for vim-pi-owned history. */
178
+ createSnapshot() {
179
+ return {
180
+ text: this.getText(),
181
+ cursor: this.getCursor(),
182
+ };
183
+ }
184
+ /**
185
+ * Clear all linear undo/redo entries and treat the current buffer as the new
186
+ * starting state. If the editor is currently in Insert/Replace, start a fresh
187
+ * session snapshot from that state so subsequent typed text remains undoable.
188
+ */
189
+ resetHistoryBaseline() {
190
+ this.vimHistory.reset();
191
+ this.activeInsertSnapshot = isInsertHistorySession(this.vimSnapshot)
192
+ ? this.createSnapshot()
193
+ : undefined;
194
+ }
195
+ /** Commit a new linear undo point and discard any abandoned redo path. */
196
+ commitNewEdit(before) {
197
+ if (this.getText() === before.text) {
198
+ return;
199
+ }
200
+ this.vimHistory.commit(before);
201
+ }
202
+ /** Update linear history after one input has been applied to the editor. */
203
+ updateLinearHistory(before, previousSnapshot, nextSnapshot, key, historyWasReset) {
204
+ if (this.isRestoringHistorySnapshot) {
205
+ return;
206
+ }
207
+ if (historyWasReset) {
208
+ return;
209
+ }
210
+ if (isNormalHistoryCommand(previousSnapshot, key)) {
211
+ return;
212
+ }
213
+ const textChanged = this.getText() !== before.text;
214
+ const wasInsertSession = isInsertHistorySession(previousSnapshot);
215
+ const isInsertSession = isInsertHistorySession(nextSnapshot);
216
+ if (!wasInsertSession && isInsertSession) {
217
+ this.activeInsertSnapshot = before;
218
+ return;
219
+ }
220
+ if (wasInsertSession && !isInsertSession) {
221
+ const sessionBefore = this.activeInsertSnapshot ?? before;
222
+ this.activeInsertSnapshot = undefined;
223
+ this.commitNewEdit(sessionBefore);
224
+ return;
225
+ }
226
+ if (isInsertSession) {
227
+ return;
228
+ }
229
+ if (textChanged) {
230
+ this.commitNewEdit(before);
231
+ }
232
+ }
233
+ /** Commit the active Insert/Replace session if it changed buffer text. */
234
+ commitActiveInsertSession() {
235
+ if (!this.activeInsertSnapshot) {
236
+ return;
237
+ }
238
+ const before = this.activeInsertSnapshot;
239
+ this.activeInsertSnapshot = undefined;
240
+ this.commitNewEdit(before);
241
+ }
242
+ /** Apply simple Normal-mode history keys without routing through host undo. */
243
+ handleNormalHistoryKey(snapshot, key) {
244
+ if (snapshot.value !== "normal" || snapshot.context.count !== undefined) {
245
+ return false;
246
+ }
247
+ if (key === "u") {
248
+ this.undoHistory();
249
+ return true;
250
+ }
251
+ if (key === "ctrl+r") {
252
+ this.redoHistory();
253
+ return true;
254
+ }
255
+ return false;
256
+ }
257
+ /** Map raw host undo shortcuts onto vim-pi history to avoid host drift. */
258
+ handleHostHistoryShortcut(data) {
259
+ if (this.appKeybindings.matches(data, "tui.editor.undo") ||
260
+ data === "\x1f") {
261
+ this.undoHistory();
262
+ return true;
263
+ }
264
+ return false;
265
+ }
266
+ /** Pop undo history, push the current state to redo, and restore the prior state. */
267
+ undoHistory() {
268
+ this.commitActiveInsertSession();
269
+ const snapshot = this.vimHistory.undo(this.createSnapshot());
270
+ if (snapshot) {
271
+ this.restoreHistorySnapshot(snapshot);
272
+ }
273
+ }
274
+ /** Pop redo history, push the current state to undo, and restore the next state. */
275
+ redoHistory() {
276
+ this.commitActiveInsertSession();
277
+ const snapshot = this.vimHistory.redo(this.createSnapshot());
278
+ if (snapshot) {
279
+ this.restoreHistorySnapshot(snapshot);
280
+ }
281
+ }
282
+ /** Restore a history snapshot without treating public setter calls as new edits. */
283
+ restoreHistorySnapshot(snapshot) {
284
+ this.isRestoringHistorySnapshot = true;
285
+ try {
286
+ this.setText(snapshot.text);
287
+ this.vimEditor.move(snapshot.cursor);
288
+ if (!isInsertHistorySession(this.vimSnapshot)) {
289
+ this.vimEditor.clampCursorColumn();
290
+ }
291
+ }
292
+ finally {
293
+ this.isRestoringHistorySnapshot = false;
294
+ }
295
+ this.activeInsertSnapshot = isInsertHistorySession(this.vimSnapshot)
296
+ ? this.createSnapshot()
297
+ : undefined;
298
+ }
299
+ /** Keep the hardware cursor visible while this editor owns cursor rendering. */
300
+ ensureHardwareCursorVisible() {
301
+ this.tui.setShowHardwareCursor(true);
302
+ }
303
+ /** Sync terminal cursor shape with Vim mode, avoiding duplicate escape writes. */
304
+ syncCursorStyle() {
305
+ const style = vimCursorStyle(this.vimSnapshot);
306
+ if (this.cursorStyle === style) {
307
+ return;
308
+ }
309
+ this.cursorStyle = style;
310
+ this.tui.terminal.write(CURSOR_SHAPE[style]);
311
+ }
312
+ }
313
+ /** DECSCUSR escape sequences for the cursor shapes supported by Pi's terminal. */
314
+ const CURSOR_SHAPE = {
315
+ bar: "\x1b[6 q",
316
+ block: "\x1b[2 q",
317
+ underline: "\x1b[4 q",
318
+ };
319
+ /** Return true while one Insert/Replace session should be one undo block. */
320
+ function isInsertHistorySession(snapshot) {
321
+ const mode = getVimMode(snapshot);
322
+ return mode === "insert" || mode === "replace";
323
+ }
324
+ /** Return true for Normal-mode keys that consume linear history. */
325
+ function isNormalHistoryCommand(snapshot, key) {
326
+ return snapshot.value === "normal" && (key === "u" || key === "ctrl+r");
327
+ }
328
+ /** Return the hardware cursor shape for the current Vim machine snapshot. */
329
+ // Match nightfox.nvim's lualine mode palette: bg0 text on base mode colors.
330
+ const MODE_LABEL_STYLES = {
331
+ INSERT: "\x1b[1;38;2;19;26;36;48;2;129;178;154m",
332
+ NORMAL: "\x1b[1;38;2;19;26;36;48;2;113;156;214m",
333
+ OPERATOR: "\x1b[1;38;2;19;26;36;48;2;219;192;116m",
334
+ VISUAL: "\x1b[1;38;2;19;26;36;48;2;157;121;214m",
335
+ "VISUAL LINE": "\x1b[1;38;2;19;26;36;48;2;157;121;214m",
336
+ REPLACE: "\x1b[1;38;2;19;26;36;48;2;201;79;109m",
337
+ };
338
+ const ANSI_RESET = "\x1b[0m";
339
+ function highlightVimModeLabel(label) {
340
+ return `${MODE_LABEL_STYLES[label]} ${label} ${ANSI_RESET}`;
341
+ }
342
+ function vimCursorStyle(snapshot) {
343
+ if (isVimOperatorMode(snapshot) ||
344
+ snapshot.value === "replace" ||
345
+ snapshot.value === "replace-once") {
346
+ return "underline";
347
+ }
348
+ return getVimMode(snapshot) === "insert" ? "bar" : "block";
349
+ }
350
+ function shouldPassOnlyEnterThrough(snapshot, key) {
351
+ return (key === "enter" &&
352
+ snapshot.context.count === undefined &&
353
+ (snapshot.value === "normal" || isVimVisualMode(snapshot)));
354
+ }
355
+ function shouldHandleNormalUpDown(snapshot, key) {
356
+ return (snapshot.value === "normal" &&
357
+ snapshot.context.count === undefined &&
358
+ (key === "up" || key === "down"));
359
+ }
@@ -0,0 +1,2 @@
1
+ import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ export default function vimPiExtension(pi: ExtensionAPI): void;
@@ -0,0 +1,36 @@
1
+ import { copyToClipboard, } from "@earendil-works/pi-coding-agent";
2
+ import { VimPiEditor } from "./editor.js";
3
+ export default function vimPiExtension(pi) {
4
+ const piVimLocalRegistersFlag = "pi-vim-local-registers";
5
+ /** Mirror register writes to the system clipboard unless local registers are requested. */
6
+ function vimOptions() {
7
+ const keepRegistersLocal = pi.getFlag(piVimLocalRegistersFlag);
8
+ const onUnnamedRegisterWrite = ({ text }) => void copyToClipboard(text).catch(() => undefined);
9
+ return keepRegistersLocal ? {} : { onUnnamedRegisterWrite };
10
+ }
11
+ pi.registerFlag(piVimLocalRegistersFlag, {
12
+ description: "Keep Vim registers local instead of using the system clipboard",
13
+ type: "boolean",
14
+ default: false,
15
+ });
16
+ let editor;
17
+ pi.on("session_start", (_event, ctx) => {
18
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
19
+ editor = new VimPiEditor(tui, theme, keybindings, vimOptions());
20
+ return editor;
21
+ });
22
+ const prefill = process.env.VIM_PI_PREFILL;
23
+ if (prefill && !ctx.ui.getEditorText()) {
24
+ ctx.ui.setEditorText(prefill.replace(/\\n/g, "\n"));
25
+ }
26
+ });
27
+ pi.on("session_shutdown", () => {
28
+ editor?.restoreCursorStyle();
29
+ });
30
+ pi.registerCommand("pi-vim-status", {
31
+ description: "Show pi-vim extension status",
32
+ handler: async (_args, ctx) => {
33
+ ctx.ui.notify("pi-vim extension loaded.", "info");
34
+ },
35
+ });
36
+ }
@@ -0,0 +1,4 @@
1
+ export { default } from "./extension.js";
2
+ export { VimPiEditor } from "./editor.js";
3
+ export { isPrintablePiInput, normalizePiKey, piInputToVimEvent, } from "./keymap.js";
4
+ export * from "@thazhemadam/vim-state";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { default } from "./extension.js";
2
+ export { VimPiEditor } from "./editor.js";
3
+ export { isPrintablePiInput, normalizePiKey, piInputToVimEvent, } from "./keymap.js";
4
+ export * from "@thazhemadam/vim-state";
@@ -0,0 +1,4 @@
1
+ import type { VimEvent } from "@thazhemadam/vim-state";
2
+ export declare function piInputToVimEvent(data: string): VimEvent;
3
+ export declare function normalizePiKey(data: string): string;
4
+ export declare function isPrintablePiInput(data: string): boolean;
package/dist/keymap.js ADDED
@@ -0,0 +1,13 @@
1
+ import { decodeKittyPrintable, parseKey } from "@earendil-works/pi-tui";
2
+ export function piInputToVimEvent(data) {
3
+ return { type: "KEY", key: normalizePiKey(data) };
4
+ }
5
+ export function normalizePiKey(data) {
6
+ return decodeKittyPrintable(data) ?? parseKey(data) ?? data;
7
+ }
8
+ export function isPrintablePiInput(data) {
9
+ return (decodeKittyPrintable(data) !== undefined ||
10
+ (data.length === 1 &&
11
+ data.charCodeAt(0) >= 32 &&
12
+ data.charCodeAt(0) !== 127));
13
+ }
@@ -0,0 +1,6 @@
1
+ import type { CustomEditor } from "@earendil-works/pi-coding-agent";
2
+ import type { VimVisualSelection } from "@thazhemadam/vim-state";
3
+ /** Remove Pi's fake block cursor when vim-pi uses a hardware cursor. */
4
+ export declare function removeReverseVideoCursor(lines: string[]): void;
5
+ /** Overlay the active Visual selection on Pi's rendered editor rows. */
6
+ export declare function highlightVisualSelection(editor: CustomEditor, renderedLines: string[], selection: VimVisualSelection, width: number, terminalRows: number): void;