@wwwyo/pi-vim 0.0.0-stage → 0.14.2

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,449 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ const PI_NATIVE_CLIPBOARD_TIMEOUT_MS = 5000;
7
+ const CLIPBOARD_WRITE_TIMEOUT_MS = PI_NATIVE_CLIPBOARD_TIMEOUT_MS + 500;
8
+ const CLIPBOARD_SPAWN_FAILURE_LIMIT = 3;
9
+ const CLIPBOARD_READ_TIMEOUT_MS = 750;
10
+ const CLIPBOARD_READ_MAX_BUFFER_BYTES = 1024 * 1024;
11
+
12
+ export type ClipboardWriteFn = (
13
+ text: string,
14
+ signal: AbortSignal,
15
+ ) => Promise<void>;
16
+ export type ClipboardReadFn = () => string | null;
17
+ type ClipboardProcess = ReturnType<typeof spawn>;
18
+
19
+ type ClipboardCircuitBreaker = {
20
+ consecutiveEnvironmentFailures: number;
21
+ disabled: boolean;
22
+ };
23
+
24
+ const processClipboardCircuitBreaker: ClipboardCircuitBreaker = {
25
+ consecutiveEnvironmentFailures: 0,
26
+ disabled: false,
27
+ };
28
+
29
+ function resetClipboardCircuitBreaker(): void {
30
+ processClipboardCircuitBreaker.consecutiveEnvironmentFailures = 0;
31
+ processClipboardCircuitBreaker.disabled = false;
32
+ }
33
+
34
+ class ClipboardSpawnError extends Error {
35
+ constructor(message: string, options?: { cause?: unknown }) {
36
+ super(message, options);
37
+ this.name = "ClipboardSpawnError";
38
+ }
39
+ }
40
+
41
+ type SpawnErrnoLike = Error & { code?: unknown; syscall?: unknown };
42
+
43
+ function isNodeSpawnErrno(error: unknown): boolean {
44
+ if (!(error instanceof Error)) return false;
45
+
46
+ const candidate = error as SpawnErrnoLike;
47
+ return (
48
+ typeof candidate.code === "string" &&
49
+ candidate.code.length > 0 &&
50
+ typeof candidate.syscall === "string" &&
51
+ candidate.syscall.startsWith("spawn")
52
+ );
53
+ }
54
+
55
+ function isClipboardEnvironmentFailure(error: unknown): boolean {
56
+ return error instanceof ClipboardSpawnError || isNodeSpawnErrno(error);
57
+ }
58
+
59
+ function tryResolvePiCodingAgentModuleUrl(): string | undefined {
60
+ try {
61
+ return import.meta.resolve("@earendil-works/pi-coding-agent");
62
+ } catch {
63
+ try {
64
+ const cliPath = process.argv[1];
65
+ if (!cliPath || !existsSync(cliPath)) return undefined;
66
+
67
+ let distDir = dirname(realpathSync(cliPath));
68
+ if (basename(distDir) === "bundle") distDir = dirname(distDir);
69
+ const modulePath = join(distDir, "index.js");
70
+
71
+ if (
72
+ basename(distDir) === "dist" &&
73
+ basename(dirname(distDir)) === "pi-coding-agent" &&
74
+ existsSync(modulePath)
75
+ ) {
76
+ return pathToFileURL(modulePath).href;
77
+ }
78
+ } catch {
79
+ // Compiled runtimes may expose virtual entrypoints through existsSync()
80
+ // that cannot be resolved through the host filesystem.
81
+ }
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ const CLIPBOARD_HELPER_COPY_FAILED_EXIT_CODE = 2;
87
+
88
+ type ClipboardHelperSources = {
89
+ read: string;
90
+ write: string;
91
+ };
92
+
93
+ let clipboardHelperSources: ClipboardHelperSources | null | undefined;
94
+
95
+ export function buildClipboardWriteHelperSource(moduleUrl: string): string {
96
+ return `
97
+ import { copyToClipboard } from ${JSON.stringify(moduleUrl)};
98
+
99
+ const chunks = [];
100
+ for await (const chunk of process.stdin) {
101
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
102
+ }
103
+
104
+ try {
105
+ await Promise.resolve(copyToClipboard(Buffer.concat(chunks).toString("utf8")));
106
+ } catch {
107
+ process.exitCode = ${CLIPBOARD_HELPER_COPY_FAILED_EXIT_CODE};
108
+ }
109
+ `;
110
+ }
111
+
112
+ export function buildClipboardReadHelperSource(moduleUrl: string): string {
113
+ return `
114
+ import { createRequire } from "node:module";
115
+
116
+ const require = createRequire(${JSON.stringify(moduleUrl)});
117
+ const clipboard = require("@mariozechner/clipboard");
118
+ if (!await clipboard.hasText()) {
119
+ process.exit(0);
120
+ }
121
+ const text = await clipboard.getText();
122
+ if (typeof text === "string") {
123
+ process.stdout.write(text);
124
+ }
125
+ `;
126
+ }
127
+
128
+ function getClipboardHelperSources(): ClipboardHelperSources | null {
129
+ if (clipboardHelperSources !== undefined) return clipboardHelperSources;
130
+
131
+ const moduleUrl = tryResolvePiCodingAgentModuleUrl();
132
+ clipboardHelperSources = moduleUrl
133
+ ? {
134
+ read: buildClipboardReadHelperSource(moduleUrl),
135
+ write: buildClipboardWriteHelperSource(moduleUrl),
136
+ }
137
+ : null;
138
+ return clipboardHelperSources;
139
+ }
140
+
141
+ export function readClipboardInChildProcess(): string | null {
142
+ const helperSources = getClipboardHelperSources();
143
+ if (!helperSources) return null;
144
+
145
+ try {
146
+ const result = spawnSync(
147
+ process.execPath,
148
+ ["--input-type=module", "-e", helperSources.read],
149
+ {
150
+ encoding: "utf8",
151
+ maxBuffer: CLIPBOARD_READ_MAX_BUFFER_BYTES,
152
+ stdio: ["ignore", "pipe", "ignore"],
153
+ timeout: CLIPBOARD_READ_TIMEOUT_MS,
154
+ windowsHide: true,
155
+ },
156
+ );
157
+
158
+ if (result.error || result.status !== 0 || result.signal) return null;
159
+ return result.stdout ?? "";
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ function createClipboardAbortError(message: string): Error {
166
+ const error = new Error(message);
167
+ error.name = "AbortError";
168
+ return error;
169
+ }
170
+
171
+ function getAbortError(signal: AbortSignal): Error {
172
+ return signal.reason instanceof Error
173
+ ? signal.reason
174
+ : createClipboardAbortError("clipboard write aborted");
175
+ }
176
+
177
+ function killClipboardProcess(child: ClipboardProcess): void {
178
+ if (child.exitCode !== null || child.signalCode !== null) return;
179
+
180
+ try {
181
+ child.kill("SIGKILL");
182
+ } catch {
183
+ return;
184
+ }
185
+ }
186
+
187
+ export function writeClipboardInChildProcess(
188
+ text: string,
189
+ signal: AbortSignal,
190
+ ): Promise<void> {
191
+ return new Promise<void>((resolve, reject) => {
192
+ if (signal.aborted) {
193
+ reject(getAbortError(signal));
194
+ return;
195
+ }
196
+
197
+ const helperSources = getClipboardHelperSources();
198
+ if (!helperSources) {
199
+ reject(new ClipboardSpawnError("clipboard helper module unavailable"));
200
+ return;
201
+ }
202
+
203
+ let child: ClipboardProcess | null = null;
204
+ let settled = false;
205
+ const stdoutChunks: Buffer[] = [];
206
+
207
+ function finish(error?: unknown): void {
208
+ if (settled) return;
209
+ settled = true;
210
+ signal.removeEventListener("abort", onAbort);
211
+ if (error) {
212
+ reject(error);
213
+ } else {
214
+ resolve();
215
+ }
216
+ }
217
+
218
+ function onAbort(): void {
219
+ if (child) {
220
+ killClipboardProcess(child);
221
+ }
222
+ finish(getAbortError(signal));
223
+ }
224
+
225
+ try {
226
+ child = spawn(
227
+ process.execPath,
228
+ ["--input-type=module", "-e", helperSources.write],
229
+ {
230
+ stdio: ["pipe", "pipe", "ignore"],
231
+ windowsHide: true,
232
+ },
233
+ );
234
+ } catch (error) {
235
+ finish(
236
+ new ClipboardSpawnError("clipboard helper spawn failed", {
237
+ cause: error,
238
+ }),
239
+ );
240
+ return;
241
+ }
242
+
243
+ signal.addEventListener("abort", onAbort, { once: true });
244
+
245
+ child.stdout?.on("data", (chunk: Buffer | string) => {
246
+ stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
247
+ });
248
+ child.stdout?.on("error", (error) => {
249
+ finish(error);
250
+ });
251
+
252
+ child.once("error", (error) => {
253
+ finish(
254
+ new ClipboardSpawnError("clipboard helper spawn failed", {
255
+ cause: error,
256
+ }),
257
+ );
258
+ });
259
+
260
+ child.once("close", (code) => {
261
+ if (settled) return;
262
+
263
+ if (signal.aborted) {
264
+ finish(getAbortError(signal));
265
+ return;
266
+ }
267
+
268
+ if (code === 0) {
269
+ try {
270
+ for (const chunk of stdoutChunks) {
271
+ process.stdout.write(chunk);
272
+ }
273
+ } catch (error) {
274
+ finish(error);
275
+ return;
276
+ }
277
+ finish();
278
+ return;
279
+ }
280
+
281
+ // Exit code 2 means the helper ran but the copy itself failed; that is
282
+ // a clipboard-backend failure, not an environment failure, so it must
283
+ // not count toward the spawn circuit breaker.
284
+ if (code === CLIPBOARD_HELPER_COPY_FAILED_EXIT_CODE) {
285
+ finish(new Error("clipboard helper reported a failed copy"));
286
+ return;
287
+ }
288
+
289
+ finish(
290
+ new ClipboardSpawnError(
291
+ `clipboard helper failed with exit code ${code ?? "null"}`,
292
+ ),
293
+ );
294
+ });
295
+
296
+ if (!child.stdin) {
297
+ killClipboardProcess(child);
298
+ finish(new ClipboardSpawnError("clipboard helper stdin unavailable"));
299
+ return;
300
+ }
301
+
302
+ child.stdin.on("error", (error: NodeJS.ErrnoException) => {
303
+ if (signal.aborted) {
304
+ finish(getAbortError(signal));
305
+ return;
306
+ }
307
+
308
+ if (error.code === "EPIPE" || error.code === "ERR_STREAM_DESTROYED") {
309
+ return;
310
+ }
311
+
312
+ finish(error);
313
+ });
314
+
315
+ try {
316
+ child.stdin.end(text);
317
+ } catch (error) {
318
+ finish(error);
319
+ }
320
+ });
321
+ }
322
+
323
+ export class ClipboardMirror {
324
+ private activeController: AbortController | null = null;
325
+ private activeText: string | null = null;
326
+ private draining = false;
327
+ private pendingText: string | null = null;
328
+ private lastWriteFailedFlag = false;
329
+
330
+ constructor(
331
+ private writeFn: ClipboardWriteFn,
332
+ private timeoutMs: number = CLIPBOARD_WRITE_TIMEOUT_MS,
333
+ private readonly circuitBreaker: ClipboardCircuitBreaker = processClipboardCircuitBreaker,
334
+ ) {}
335
+
336
+ setWriteFn(writeFn: ClipboardWriteFn): void {
337
+ this.activeController?.abort(
338
+ createClipboardAbortError("clipboard writer replaced"),
339
+ );
340
+ this.writeFn = writeFn;
341
+ // Deliberately keep lastWriteFailedFlag: swapping the writer does not
342
+ // change whether the OS clipboard is stale relative to the register;
343
+ // only a landed mirror write clears the flag.
344
+ resetClipboardCircuitBreaker();
345
+ }
346
+
347
+ setTimeoutMs(timeoutMs: number): void {
348
+ this.timeoutMs = Math.max(0, timeoutMs);
349
+ }
350
+
351
+ hasPendingWrite(): boolean {
352
+ return (
353
+ this.activeText !== null || this.pendingText !== null || this.draining
354
+ );
355
+ }
356
+
357
+ lastWriteFailed(): boolean {
358
+ return this.lastWriteFailedFlag;
359
+ }
360
+
361
+ mirror(text: string): void {
362
+ if (this.circuitBreaker.disabled) {
363
+ this.lastWriteFailedFlag = true;
364
+ return;
365
+ }
366
+
367
+ this.pendingText = text;
368
+
369
+ if (!this.draining) {
370
+ void this.drain();
371
+ }
372
+ }
373
+
374
+ private async drain(): Promise<void> {
375
+ if (this.draining) return;
376
+ this.draining = true;
377
+
378
+ try {
379
+ while (this.pendingText !== null && !this.circuitBreaker.disabled) {
380
+ const text = this.pendingText;
381
+ this.pendingText = null;
382
+ const controller = new AbortController();
383
+ this.activeController = controller;
384
+ this.activeText = text;
385
+
386
+ try {
387
+ await this.writeWithTimeout(text, controller);
388
+ this.circuitBreaker.consecutiveEnvironmentFailures = 0;
389
+ this.lastWriteFailedFlag = false;
390
+ } catch (error) {
391
+ this.recordWriteFailure(error);
392
+ } finally {
393
+ if (this.activeController === controller) {
394
+ this.activeController = null;
395
+ }
396
+ this.activeText = null;
397
+ }
398
+ }
399
+
400
+ if (this.circuitBreaker.disabled && this.pendingText !== null) {
401
+ this.pendingText = null;
402
+ this.lastWriteFailedFlag = true;
403
+ }
404
+ } finally {
405
+ this.draining = false;
406
+ if (this.pendingText !== null && !this.circuitBreaker.disabled) {
407
+ void this.drain();
408
+ }
409
+ }
410
+ }
411
+
412
+ private recordWriteFailure(error: unknown): void {
413
+ this.lastWriteFailedFlag = true;
414
+ if (!isClipboardEnvironmentFailure(error)) {
415
+ this.circuitBreaker.consecutiveEnvironmentFailures = 0;
416
+ return;
417
+ }
418
+
419
+ this.circuitBreaker.consecutiveEnvironmentFailures += 1;
420
+ if (
421
+ this.circuitBreaker.consecutiveEnvironmentFailures >=
422
+ CLIPBOARD_SPAWN_FAILURE_LIMIT
423
+ ) {
424
+ this.circuitBreaker.disabled = true;
425
+ this.pendingText = null;
426
+ }
427
+ }
428
+
429
+ private async writeWithTimeout(
430
+ text: string,
431
+ controller: AbortController,
432
+ ): Promise<void> {
433
+ const timeoutError = createClipboardAbortError("clipboard write timed out");
434
+ const timeoutId = setTimeout(() => {
435
+ controller.abort(timeoutError);
436
+ }, this.timeoutMs);
437
+
438
+ try {
439
+ await this.writeFn(text, controller.signal);
440
+ } catch (error) {
441
+ if (controller.signal.aborted) {
442
+ throw getAbortError(controller.signal);
443
+ }
444
+ throw error;
445
+ } finally {
446
+ clearTimeout(timeoutId);
447
+ }
448
+ }
449
+ }
@@ -0,0 +1,23 @@
1
+ export type ClipboardMirrorPolicy = "all" | "yank" | "never";
2
+ export type RegisterWriteSource = "mutation" | "yank";
3
+ export const DEFAULT_CLIPBOARD_MIRROR_POLICY: ClipboardMirrorPolicy = "all";
4
+
5
+ function fmt(v: unknown) {
6
+ const type = v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
7
+ try {
8
+ return `${JSON.stringify(v) ?? type} (type ${type})`;
9
+ } catch {
10
+ return `(type ${type})`;
11
+ }
12
+ }
13
+
14
+ export function resolveClipboardMirrorPolicy(value: unknown) {
15
+ if (value === undefined) return { policy: DEFAULT_CLIPBOARD_MIRROR_POLICY };
16
+ const p = typeof value === "string" ? value.trim().toLowerCase() : "";
17
+ if (p === "all" || p === "yank" || p === "never")
18
+ return { policy: p as ClipboardMirrorPolicy };
19
+ return {
20
+ policy: DEFAULT_CLIPBOARD_MIRROR_POLICY,
21
+ warning: `Invalid piVim.clipboardMirror ${fmt(value)}; expected all, yank, never.`,
22
+ };
23
+ }
@@ -0,0 +1,138 @@
1
+ import { CURSOR_MARKER } from "@earendil-works/pi-tui";
2
+
3
+ const SOFTWARE_CURSOR_START = "\x1b[7m";
4
+ const SOFTWARE_CURSOR_RESETS = ["\x1b[0m", "\x1b[27m"] as const;
5
+ export const INSERT_CURSOR_SHAPE = "\x1b[5 q";
6
+ export const BLOCK_CURSOR_SHAPE = "\x1b[1 q";
7
+ const RESET_CURSOR_SHAPE = "\x1b[0 q";
8
+ const SHOW_HARDWARE_CURSOR = "\x1b[?25h";
9
+
10
+ export type CursorShapeSequence =
11
+ | typeof INSERT_CURSOR_SHAPE
12
+ | typeof BLOCK_CURSOR_SHAPE
13
+ | typeof RESET_CURSOR_SHAPE
14
+ | typeof SHOW_HARDWARE_CURSOR;
15
+
16
+ export type CursorShapeRuntime = {
17
+ writeCursorShape: (sequence: CursorShapeSequence) => void;
18
+ setShowHardwareCursor: (show: boolean) => void;
19
+ getShowHardwareCursor?: () => boolean | undefined;
20
+ };
21
+
22
+ export type CursorShapeCleanup = (event?: { reason?: string }) => void;
23
+
24
+ type CursorShapeTuiCandidate = {
25
+ terminal?: { write?: unknown };
26
+ setShowHardwareCursor?: unknown;
27
+ getShowHardwareCursor?: unknown;
28
+ };
29
+
30
+ export function getCursorShapeRuntime(tui: unknown): CursorShapeRuntime | null {
31
+ if (typeof tui !== "object" || tui === null) return null;
32
+
33
+ const candidate = tui as CursorShapeTuiCandidate;
34
+ const terminal = candidate.terminal;
35
+ if (typeof terminal !== "object" || terminal === null) return null;
36
+
37
+ const write = terminal.write;
38
+ const setShowHardwareCursor = candidate.setShowHardwareCursor;
39
+ if (
40
+ typeof write !== "function" ||
41
+ typeof setShowHardwareCursor !== "function"
42
+ ) {
43
+ return null;
44
+ }
45
+
46
+ const runtime: CursorShapeRuntime = {
47
+ writeCursorShape(sequence: CursorShapeSequence): void {
48
+ write.call(terminal, sequence);
49
+ },
50
+ setShowHardwareCursor(show: boolean): void {
51
+ setShowHardwareCursor.call(candidate, show);
52
+ },
53
+ };
54
+
55
+ if (typeof candidate.getShowHardwareCursor === "function") {
56
+ const getShowHardwareCursor = candidate.getShowHardwareCursor;
57
+ runtime.getShowHardwareCursor = () => {
58
+ const value = getShowHardwareCursor.call(candidate);
59
+ return typeof value === "boolean" ? value : undefined;
60
+ };
61
+ }
62
+
63
+ return runtime;
64
+ }
65
+
66
+ export function enableCursorShapeSupport(
67
+ tui: unknown,
68
+ ): CursorShapeCleanup | null {
69
+ const runtime = getCursorShapeRuntime(tui);
70
+ if (!runtime) return null;
71
+
72
+ const previousShowHardwareCursor = runtime.getShowHardwareCursor?.();
73
+ runtime.setShowHardwareCursor(true);
74
+
75
+ return (event) => {
76
+ runtime.writeCursorShape(RESET_CURSOR_SHAPE);
77
+ if (event?.reason === "quit") {
78
+ runtime.writeCursorShape(SHOW_HARDWARE_CURSOR);
79
+ } else if (previousShowHardwareCursor !== undefined) {
80
+ runtime.setShowHardwareCursor(previousShowHardwareCursor);
81
+ }
82
+ };
83
+ }
84
+
85
+ function findSoftwareCursorReset(
86
+ line: string,
87
+ startIndex: number,
88
+ ): { index: number; sequence: (typeof SOFTWARE_CURSOR_RESETS)[number] } | null {
89
+ let firstReset: {
90
+ index: number;
91
+ sequence: (typeof SOFTWARE_CURSOR_RESETS)[number];
92
+ } | null = null;
93
+
94
+ for (const sequence of SOFTWARE_CURSOR_RESETS) {
95
+ const index = line.indexOf(sequence, startIndex);
96
+ if (index === -1) continue;
97
+ if (!firstReset || index < firstReset.index) {
98
+ firstReset = { index, sequence };
99
+ }
100
+ }
101
+
102
+ return firstReset;
103
+ }
104
+
105
+ export function stripSoftwareCursorAfterMarker(line: string): string {
106
+ const markerIndex = line.indexOf(CURSOR_MARKER);
107
+ if (markerIndex === -1) return line;
108
+
109
+ const searchStart = markerIndex + CURSOR_MARKER.length;
110
+ const cursorStart = line.indexOf(SOFTWARE_CURSOR_START, searchStart);
111
+ if (cursorStart === -1) return line;
112
+
113
+ const cursorContentStart = cursorStart + SOFTWARE_CURSOR_START.length;
114
+ const reset = findSoftwareCursorReset(line, cursorContentStart);
115
+ if (!reset) return line;
116
+
117
+ return (
118
+ line.slice(0, cursorStart) +
119
+ line.slice(cursorContentStart, reset.index) +
120
+ line.slice(reset.index + reset.sequence.length)
121
+ );
122
+ }
123
+
124
+ export function hasPromptCursorMarker(lines: string[]): boolean {
125
+ return lines.some((line) => line.includes(CURSOR_MARKER));
126
+ }
127
+
128
+ export function stripSoftwareCursorWhenHardwareCursorIsUsed(
129
+ lines: string[],
130
+ ): void {
131
+ for (let i = lines.length - 1; i >= 0; i--) {
132
+ const line = lines[i];
133
+ if (!line?.includes(CURSOR_MARKER)) continue;
134
+
135
+ lines[i] = stripSoftwareCursorAfterMarker(line);
136
+ return;
137
+ }
138
+ }