yoyo-pi 0.1.4

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.
@@ -0,0 +1,561 @@
1
+ /**
2
+ * Vim Mode Extension
3
+ *
4
+ * /vim toggles a vim-like modal editor for pi's prompt.
5
+ *
6
+ * - Status pill: "VIM <NORMAL|INSERT|VISUAL>" via the active footer/statusbar
7
+ * - Insert mode: normal pi editor behavior
8
+ * - Normal mode: h/j/k/l, 0/$, w/b, x, i/a, v, / and ! helpers
9
+ * - Visual mode: mode indicator + vim navigation fallback
10
+ * - External editor: Ctrl+G uses $VISUAL/$EDITOR; if unset, auto-falls back to nvim/vim/vi when found.
11
+ */
12
+
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import {
16
+ CustomEditor,
17
+ keyHint,
18
+ type AppKeybinding,
19
+ type ExtensionAPI,
20
+ type ExtensionCommandContext,
21
+ type KeybindingsManager,
22
+ } from "@earendil-works/pi-coding-agent";
23
+ import { matchesKey, type EditorComponent, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
24
+
25
+ type EditorFactory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => EditorComponent;
26
+ type VimMode = "normal" | "insert" | "visual";
27
+ type VimStatusContext = { ui: { setStatus(key: string, text: string | undefined): void } };
28
+ type VimModeBridge = {
29
+ isEnabled(): boolean;
30
+ getMode(): VimMode;
31
+ refreshStatus(ctx: VimStatusContext): void;
32
+ wrapEditorFactory(baseFactory: EditorFactory | undefined, ctx: VimStatusContext): EditorFactory;
33
+ };
34
+ type KenxStatusbarBridge = {
35
+ isEnabled(): boolean;
36
+ getEditorFactory(): EditorFactory | undefined;
37
+ setStatus?(key: string, text: string | undefined): void;
38
+ };
39
+
40
+ type ExternalEditorInfo = {
41
+ command?: string;
42
+ autoDetected: boolean;
43
+ setVisual?: boolean;
44
+ previousVisual?: string;
45
+ };
46
+
47
+ const STATUS_KEY = "vim-mode";
48
+ const VIM_BRIDGE_KEY = Symbol.for("yoyo-pi.vim-mode.bridge");
49
+ const KENX_STATUSBAR_BRIDGE_KEY = Symbol.for("yoyo-pi.kenx-statusbar.bridge");
50
+ const FALLBACK_EDITORS = ["nvim", "vim", "vi"];
51
+
52
+ const MODE_TEXT: Record<VimMode, string> = {
53
+ normal: "normal mode",
54
+ insert: "insert mode",
55
+ visual: "visual mode",
56
+ };
57
+
58
+ const NORMAL_MOTIONS: Record<string, string> = {
59
+ h: "\x1b[D", // left
60
+ j: "\x1b[B", // down
61
+ k: "\x1b[A", // up
62
+ l: "\x1b[C", // right
63
+ "0": "\x01", // line start (Ctrl+A)
64
+ $: "\x05", // line end (Ctrl+E)
65
+ w: "\x1bf", // word right (Alt+F)
66
+ b: "\x1bb", // word left (Alt+B)
67
+ x: "\x1b[3~", // delete char under cursor
68
+ };
69
+
70
+ function commandExists(command: string): boolean {
71
+ const pathEnv = process.env.PATH ?? "";
72
+ const dirs = pathEnv.split(path.delimiter).filter(Boolean);
73
+ const extensions =
74
+ process.platform === "win32"
75
+ ? (process.env.PATHEXT?.split(";").filter(Boolean) ?? [".EXE", ".CMD", ".BAT", ""])
76
+ : [""];
77
+
78
+ for (const dir of dirs) {
79
+ for (const ext of extensions) {
80
+ const candidate = path.join(dir, command + ext);
81
+ try {
82
+ fs.accessSync(candidate, fs.constants.X_OK);
83
+ return true;
84
+ } catch {
85
+ // Try next PATH entry.
86
+ }
87
+ }
88
+ }
89
+
90
+ return false;
91
+ }
92
+
93
+ function describeEditor(command: string | undefined): string | undefined {
94
+ if (!command?.trim()) return undefined;
95
+ const executable = command.trim().split(/\s+/)[0] ?? command.trim();
96
+ return path.basename(executable);
97
+ }
98
+
99
+ function ensureExternalEditor(): ExternalEditorInfo {
100
+ const configured = (process.env.VISUAL || process.env.EDITOR)?.trim();
101
+ if (configured) {
102
+ return { command: configured, autoDetected: false };
103
+ }
104
+
105
+ const fallback = FALLBACK_EDITORS.find(commandExists);
106
+ if (fallback) {
107
+ // Pi's built-in external editor action reads $VISUAL first.
108
+ const previousVisual = process.env.VISUAL;
109
+ process.env.VISUAL = fallback;
110
+ return { command: fallback, autoDetected: true, setVisual: true, previousVisual };
111
+ }
112
+
113
+ return { autoDetected: false };
114
+ }
115
+
116
+ function restoreExternalEditor(editorInfo: ExternalEditorInfo): void {
117
+ if (!editorInfo.setVisual) return;
118
+ if (editorInfo.previousVisual === undefined) delete process.env.VISUAL;
119
+ else process.env.VISUAL = editorInfo.previousVisual;
120
+ }
121
+
122
+ function isPrintable(data: string): boolean {
123
+ if (!data || data.includes("\x1b")) return false;
124
+ for (const ch of data) {
125
+ const code = ch.codePointAt(0) ?? 0;
126
+ if (code < 32 || code === 127) return false;
127
+ }
128
+ return true;
129
+ }
130
+
131
+ function getKenxStatusbarBridge(): KenxStatusbarBridge | undefined {
132
+ return (globalThis as Record<symbol, KenxStatusbarBridge | undefined>)[KENX_STATUSBAR_BRIDGE_KEY];
133
+ }
134
+
135
+ function setVimStatus(ctx: VimStatusContext, mode: VimMode): void {
136
+ const text = `VIM ${mode.toUpperCase()}`;
137
+ ctx.ui.setStatus(STATUS_KEY, text);
138
+ getKenxStatusbarBridge()?.setStatus?.(STATUS_KEY, text);
139
+ }
140
+
141
+ function clearVimStatus(ctx: VimStatusContext): void {
142
+ ctx.ui.setStatus(STATUS_KEY, undefined);
143
+ getKenxStatusbarBridge()?.setStatus?.(STATUS_KEY, undefined);
144
+ }
145
+
146
+ function getKenxStatusbarEditorFactory(): EditorFactory | undefined {
147
+ const bridge = getKenxStatusbarBridge();
148
+ return bridge?.isEnabled() ? bridge.getEditorFactory() : undefined;
149
+ }
150
+
151
+ class VimModeEditor implements EditorComponent {
152
+ public actionHandlers = new Map<AppKeybinding, () => void>();
153
+ public onEscape?: () => void;
154
+ public onCtrlD?: () => void;
155
+ public onPasteImage?: () => void;
156
+ public onExtensionShortcut?: (data: string) => boolean;
157
+
158
+ private mode: VimMode;
159
+ private pendingOperator: "d" | undefined;
160
+ private _focused = false;
161
+
162
+ constructor(
163
+ private readonly base: EditorComponent,
164
+ private readonly keybindings: KeybindingsManager,
165
+ private readonly onModeChange: (mode: VimMode) => void,
166
+ initialMode: VimMode = "insert",
167
+ ) {
168
+ this.mode = initialMode;
169
+ }
170
+
171
+ get focused(): boolean {
172
+ return this._focused;
173
+ }
174
+
175
+ set focused(value: boolean) {
176
+ this._focused = value;
177
+ const focusable = this.base as EditorComponent & { focused?: boolean };
178
+ if ("focused" in focusable) focusable.focused = value;
179
+ }
180
+
181
+ get wantsKeyRelease(): boolean | undefined {
182
+ return this.base.wantsKeyRelease;
183
+ }
184
+
185
+ get onSubmit(): ((text: string) => void) | undefined {
186
+ return this.base.onSubmit;
187
+ }
188
+
189
+ set onSubmit(value: ((text: string) => void) | undefined) {
190
+ this.base.onSubmit = value;
191
+ }
192
+
193
+ get onChange(): ((text: string) => void) | undefined {
194
+ return this.base.onChange;
195
+ }
196
+
197
+ set onChange(value: ((text: string) => void) | undefined) {
198
+ this.base.onChange = value;
199
+ }
200
+
201
+ get borderColor(): EditorComponent["borderColor"] {
202
+ return this.base.borderColor;
203
+ }
204
+
205
+ set borderColor(value: EditorComponent["borderColor"]) {
206
+ this.base.borderColor = value;
207
+ }
208
+
209
+ private setMode(mode: VimMode): void {
210
+ if (this.mode === mode) return;
211
+ this.pendingOperator = undefined;
212
+ this.mode = mode;
213
+ this.onModeChange(mode);
214
+ this.invalidate();
215
+ }
216
+
217
+ private runEditorInput(data: string): void {
218
+ this.pendingOperator = undefined;
219
+ this.base.handleInput(data);
220
+ }
221
+
222
+ private deleteCurrentLine(): void {
223
+ this.pendingOperator = undefined;
224
+
225
+ const editor = this.base as EditorComponent & {
226
+ getLines?: () => string[];
227
+ getCursor?: () => { line: number; col: number };
228
+ };
229
+ const lines = editor.getLines?.() ?? this.base.getText().split("\n");
230
+ const cursor = editor.getCursor?.() ?? { line: Math.max(0, lines.length - 1), col: 0 };
231
+ const lineToDelete = Math.max(0, Math.min(cursor.line, Math.max(0, lines.length - 1)));
232
+
233
+ if (lines.length <= 1) {
234
+ this.base.setText("");
235
+ return;
236
+ }
237
+
238
+ const nextLines = [...lines];
239
+ nextLines.splice(lineToDelete, 1);
240
+ this.base.setText(nextLines.join("\n"));
241
+
242
+ // EditorComponent does not expose cursor mutation, but pi's built-in Editor keeps
243
+ // runtime state on a normal property. Restore vim-like cursor position when possible.
244
+ const runtime = this.base as unknown as {
245
+ state?: { cursorLine: number; cursorCol: number };
246
+ tui?: { requestRender(): void };
247
+ };
248
+ if (runtime.state) {
249
+ const nextLine = Math.max(0, Math.min(lineToDelete, nextLines.length - 1));
250
+ runtime.state.cursorLine = nextLine;
251
+ runtime.state.cursorCol = Math.max(0, Math.min(cursor.col, nextLines[nextLine]?.length ?? 0));
252
+ runtime.tui?.requestRender();
253
+ }
254
+ }
255
+
256
+ private isShowingAutocomplete(): boolean {
257
+ const editor = this.base as EditorComponent & { isShowingAutocomplete?: () => boolean };
258
+ return editor.isShowingAutocomplete?.() ?? false;
259
+ }
260
+
261
+ private handleAppInput(data: string): boolean {
262
+ if (this.keybindings.matches(data, "app.clipboard.pasteImage")) {
263
+ this.onPasteImage?.();
264
+ return true;
265
+ }
266
+
267
+ if (this.keybindings.matches(data, "app.interrupt")) {
268
+ if (!this.isShowingAutocomplete()) {
269
+ const handler = this.onEscape ?? this.actionHandlers.get("app.interrupt");
270
+ if (handler) {
271
+ handler();
272
+ return true;
273
+ }
274
+ }
275
+ this.runEditorInput(data);
276
+ return true;
277
+ }
278
+
279
+ if (this.keybindings.matches(data, "app.exit")) {
280
+ if (this.getText().length === 0) {
281
+ const handler = this.onCtrlD ?? this.actionHandlers.get("app.exit");
282
+ if (handler) handler();
283
+ return true;
284
+ }
285
+ return false;
286
+ }
287
+
288
+ for (const [action, handler] of this.actionHandlers) {
289
+ if (action !== "app.interrupt" && action !== "app.exit" && this.keybindings.matches(data, action)) {
290
+ handler();
291
+ return true;
292
+ }
293
+ }
294
+
295
+ return false;
296
+ }
297
+
298
+ handleInput(data: string): void {
299
+ if (this.onExtensionShortcut?.(data)) return;
300
+
301
+ if (matchesKey(data, "escape")) {
302
+ if (this.mode === "insert") {
303
+ if (this.isShowingAutocomplete()) {
304
+ this.runEditorInput(data);
305
+ } else {
306
+ this.setMode("normal");
307
+ }
308
+ return;
309
+ }
310
+
311
+ if (this.mode === "visual") {
312
+ this.setMode("normal");
313
+ return;
314
+ }
315
+
316
+ // In normal mode, keep Pi's app-level Escape behavior (interrupt/abort).
317
+ if (!this.handleAppInput(data)) this.runEditorInput(data);
318
+ return;
319
+ }
320
+
321
+ if (this.handleAppInput(data)) return;
322
+
323
+ if (data === "dd" && this.mode !== "insert") {
324
+ this.deleteCurrentLine();
325
+ return;
326
+ }
327
+
328
+ if (this.mode === "insert") {
329
+ this.runEditorInput(data);
330
+ return;
331
+ }
332
+
333
+ if (this.mode === "normal" && this.pendingOperator === "d") {
334
+ if (data === "d") {
335
+ this.deleteCurrentLine();
336
+ return;
337
+ }
338
+ this.pendingOperator = undefined;
339
+ }
340
+
341
+ if (this.mode === "normal" && data === "d") {
342
+ this.pendingOperator = "d";
343
+ this.invalidate();
344
+ return;
345
+ }
346
+
347
+ if (data in NORMAL_MOTIONS) {
348
+ this.runEditorInput(NORMAL_MOTIONS[data]!);
349
+ return;
350
+ }
351
+
352
+ if (data === "i") {
353
+ this.setMode("insert");
354
+ return;
355
+ }
356
+
357
+ if (data === "a") {
358
+ this.runEditorInput("\x1b[C");
359
+ this.setMode("insert");
360
+ return;
361
+ }
362
+
363
+ if (data === "I") {
364
+ this.runEditorInput("\x01");
365
+ this.setMode("insert");
366
+ return;
367
+ }
368
+
369
+ if (data === "A") {
370
+ this.runEditorInput("\x05");
371
+ this.setMode("insert");
372
+ return;
373
+ }
374
+
375
+ if (data === "v") {
376
+ this.setMode(this.mode === "visual" ? "normal" : "visual");
377
+ return;
378
+ }
379
+
380
+ if (this.mode === "visual" && (data === "y" || data === "d")) {
381
+ // The built-in prompt editor does not expose a selection API; keep this as a safe mode exit.
382
+ this.setMode("normal");
383
+ return;
384
+ }
385
+
386
+ if (data === "/" || data === "!") {
387
+ // Slash commands and shell prompts are important in pi; make them easy from normal mode.
388
+ this.setMode("insert");
389
+ this.runEditorInput(data);
390
+ return;
391
+ }
392
+
393
+ // Let control sequences and app shortcuts through (Ctrl+G external editor, Ctrl+C, Enter, etc.).
394
+ if (!isPrintable(data)) {
395
+ this.runEditorInput(data);
396
+ }
397
+ }
398
+
399
+ render(width: number): string[] {
400
+ return this.base.render(width);
401
+ }
402
+
403
+ invalidate(): void {
404
+ this.base.invalidate();
405
+ }
406
+
407
+ dispose(): void {
408
+ (this.base as EditorComponent & { dispose?: () => void }).dispose?.();
409
+ }
410
+
411
+ getText(): string {
412
+ return this.base.getText();
413
+ }
414
+
415
+ setText(text: string): void {
416
+ this.base.setText(text);
417
+ }
418
+
419
+ addToHistory(text: string): void {
420
+ this.base.addToHistory?.(text);
421
+ }
422
+
423
+ insertTextAtCursor(text: string): void {
424
+ this.base.insertTextAtCursor?.(text);
425
+ }
426
+
427
+ getExpandedText(): string {
428
+ return this.base.getExpandedText?.() ?? this.base.getText();
429
+ }
430
+
431
+ setAutocompleteProvider(provider: Parameters<NonNullable<EditorComponent["setAutocompleteProvider"]>>[0]): void {
432
+ this.base.setAutocompleteProvider?.(provider);
433
+ }
434
+
435
+ setPaddingX(padding: number): void {
436
+ this.base.setPaddingX?.(padding);
437
+ }
438
+
439
+ setAutocompleteMaxVisible(maxVisible: number): void {
440
+ this.base.setAutocompleteMaxVisible?.(maxVisible);
441
+ }
442
+ }
443
+
444
+ export default function vimModeExtension(pi: ExtensionAPI) {
445
+ let enabled = false;
446
+ let mode: VimMode = "insert";
447
+ let baseEditor: EditorFactory | undefined;
448
+ let editorInfo: ExternalEditorInfo = { autoDetected: false };
449
+
450
+ const bridge: VimModeBridge = {
451
+ isEnabled: () => enabled,
452
+ getMode: () => mode,
453
+ refreshStatus: (ctx) => {
454
+ if (enabled) setVimStatus(ctx, mode);
455
+ },
456
+ wrapEditorFactory: (baseFactory, ctx) => createVimEditorFactory(ctx, baseFactory),
457
+ };
458
+ (globalThis as Record<symbol, VimModeBridge | undefined>)[VIM_BRIDGE_KEY] = bridge;
459
+
460
+ function createVimEditorFactory(ctx: VimStatusContext, baseFactory: EditorFactory | undefined): EditorFactory {
461
+ return (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => {
462
+ const base = baseFactory?.(tui, theme, keybindings) ?? new CustomEditor(tui, theme, keybindings);
463
+ return new VimModeEditor(
464
+ base,
465
+ keybindings,
466
+ (nextMode) => {
467
+ mode = nextMode;
468
+ setVimStatus(ctx, nextMode);
469
+ },
470
+ mode,
471
+ );
472
+ };
473
+ }
474
+
475
+ function enable(ctx: ExtensionCommandContext): void {
476
+ if (!ctx.hasUI) return;
477
+
478
+ if (enabled) {
479
+ setVimStatus(ctx, mode);
480
+ ctx.ui.notify("Vim mode 已经启用。再次输入 /vim 可退出。", "info");
481
+ return;
482
+ }
483
+
484
+ baseEditor = ctx.ui.getEditorComponent() ?? getKenxStatusbarEditorFactory();
485
+ editorInfo = ensureExternalEditor();
486
+ mode = "insert";
487
+ enabled = true;
488
+
489
+ ctx.ui.setEditorComponent(createVimEditorFactory(ctx, baseEditor));
490
+ setVimStatus(ctx, mode);
491
+
492
+ const editorName = describeEditor(editorInfo.command);
493
+ const externalHint = editorName
494
+ ? `${keyHint("app.editor.external", editorName)}${editorInfo.autoDetected ? "(自动兜底)" : ""}`
495
+ : "未发现 $VISUAL/$EDITOR/nvim/vim/vi,使用内置 vim-like 兜底";
496
+ ctx.ui.notify(`Vim mode enabled: ${externalHint}`, "info");
497
+ }
498
+
499
+ function disable(ctx: ExtensionCommandContext): void {
500
+ if (!enabled) {
501
+ ctx.ui.notify("Vim mode 当前未启用。输入 /vim 可进入。", "info");
502
+ return;
503
+ }
504
+
505
+ enabled = false;
506
+ mode = "insert";
507
+ restoreExternalEditor(editorInfo);
508
+ editorInfo = { autoDetected: false };
509
+ ctx.ui.setEditorComponent(getKenxStatusbarEditorFactory() ?? baseEditor);
510
+ baseEditor = undefined;
511
+ clearVimStatus(ctx);
512
+ ctx.ui.notify("Vim mode disabled", "info");
513
+ }
514
+
515
+ pi.registerCommand("vim", {
516
+ description: "Toggle vim mode for the prompt editor",
517
+ handler: async (args, ctx) => {
518
+ const action = args.trim().toLowerCase();
519
+ if (["on", "enable", "enter", "start"].includes(action)) {
520
+ enable(ctx);
521
+ return;
522
+ }
523
+
524
+ if (["off", "disable", "quit", "exit", "stop"].includes(action)) {
525
+ disable(ctx);
526
+ return;
527
+ }
528
+
529
+ if (action === "status") {
530
+ if (enabled) {
531
+ setVimStatus(ctx, mode);
532
+ ctx.ui.notify(`Vim mode: ${MODE_TEXT[mode]}`, "info");
533
+ } else {
534
+ ctx.ui.notify("Vim mode: disabled", "info");
535
+ }
536
+ return;
537
+ }
538
+
539
+ if (action && action !== "toggle") {
540
+ ctx.ui.notify("Usage: /vim [on|off|status]", "warning");
541
+ return;
542
+ }
543
+
544
+ if (enabled) disable(ctx);
545
+ else enable(ctx);
546
+ },
547
+ });
548
+
549
+ pi.on("session_shutdown", async (_event, ctx) => {
550
+ if (enabled) {
551
+ restoreExternalEditor(editorInfo);
552
+ clearVimStatus(ctx);
553
+ }
554
+ enabled = false;
555
+ mode = "insert";
556
+ editorInfo = { autoDetected: false };
557
+ baseEditor = undefined;
558
+ const globalBridge = globalThis as Record<symbol, VimModeBridge | undefined>;
559
+ if (globalBridge[VIM_BRIDGE_KEY] === bridge) delete globalBridge[VIM_BRIDGE_KEY];
560
+ });
561
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "yoyo-pi",
3
+ "version": "0.1.4",
4
+ "description": "Polished pi extension pack: Vim prompt editing, choice pickers, themes, status bars, file/todo sidebars, context snapshots, and plan mode.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "coding-agent",
10
+ "terminal",
11
+ "tui",
12
+ "vim"
13
+ ],
14
+ "license": "MIT",
15
+ "homepage": "https://github.com/kenxcomp/yoyo-pi#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+ssh://git@github.com/kenxcomp/yoyo-pi.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/kenxcomp/yoyo-pi/issues"
22
+ },
23
+ "files": [
24
+ "extensions/",
25
+ "docs/previews/",
26
+ "README.zh-CN.md"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "pi": {
32
+ "extensions": [
33
+ "./extensions/clear-context.ts",
34
+ "./extensions/vim-mode.ts",
35
+ "./extensions/choice-picker.ts",
36
+ "./extensions/kenx-infra/index.ts",
37
+ "./extensions/gr0k-hack/index.ts",
38
+ "./extensions/plan-mode/index.ts"
39
+ ],
40
+ "image": "https://raw.githubusercontent.com/kenxcomp/yoyo-pi/main/docs/previews/status-bar.png"
41
+ },
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-coding-agent": "*",
44
+ "@earendil-works/pi-tui": "*",
45
+ "typebox": "*"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@earendil-works/pi-coding-agent": {
49
+ "optional": true
50
+ },
51
+ "@earendil-works/pi-tui": {
52
+ "optional": true
53
+ },
54
+ "typebox": {
55
+ "optional": true
56
+ }
57
+ }
58
+ }