pi-better-paste-markers 0.2.0 → 0.3.1
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 +5 -1
- package/index.ts +247 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,9 @@ Pastes over 10 lines or 1000 chars render as a three-line block:
|
|
|
21
21
|
- Everywhere else (drafts, autocomplete, undo) the block collapses back to the standard single-line `[paste #N ...]` marker.
|
|
22
22
|
- The block deletes as one unit: a single Backspace/Delete (or Ctrl+W/U/K) that touches it removes the whole block, like the built-in `[paste #N ...]` marker. Text before and after the block on the same line is kept.
|
|
23
23
|
- Undo restores the whole block and it keeps expanding/submitting correctly.
|
|
24
|
+
- Survives `/new`, `/resume` and fork: pi rebuilds the editor component during a session switch, so blocks are rebuilt from the shared registry (and from paste content captured out of the base editor) instead of degrading to a literal `[paste #N ...]` marker.
|
|
24
25
|
- The input is prefixed with a gray `❯` prompt (the one pi-powerline-footer draws on the default editor is lost when a custom editor component replaces it).
|
|
26
|
+
- The block is painted with the theme's `userMessageBg` background (follows light/dark and theme switches); only the block text is filled, the padding after it stays normal.
|
|
25
27
|
|
|
26
28
|
## Install
|
|
27
29
|
|
|
@@ -38,6 +40,8 @@ The extension replaces the main editor on `session_start` via `ctx.ui.setEditorC
|
|
|
38
40
|
- `getText()` — collapses blocks back to canonical `[paste #N ...]` markers (drafts, autocomplete snapshots, undo).
|
|
39
41
|
- `expandPasteMarkers()` — plain Enter submits through the base editor's private `submitValue()`, which expands paste markers with this method; shadowing it makes Enter submit the original pasted content instead of the three-line preview.
|
|
40
42
|
- `getExpandedText()` — used by the follow-up (Alt+Enter), external-editor and extension-API (`getEditorText`) paths.
|
|
41
|
-
- `render()` — prefixes the input with a gray
|
|
43
|
+
- `render()` — prefixes the input with a gray `❯`, keeps the other content lines indented, and paints the paste-block text with `ctx.ui.theme.bg("userMessageBg", …)` (re-applied after the cursor's reset so the highlight survives the cursor).
|
|
42
44
|
|
|
43
45
|
Paste records are kept after a block is deleted so that undoing the deletion restores a block that still collapses and expands correctly.
|
|
46
|
+
|
|
47
|
+
The registry is shared across editor instances: during `/new`, `/resume` or a fork, pi tears the custom editor down (`resetExtensionUI()`), rebuilds it, and carries the draft over with `setText(editor.getText())` — i.e. collapsed markers. Blocks are therefore rebuilt on `setText`, from the shared registry or from content captured out of the base editor (whose `pastes` map pi clears on the same path).
|
package/index.ts
CHANGED
|
@@ -64,23 +64,98 @@ interface PasteBlock {
|
|
|
64
64
|
/** Which side of the block a deletion key bites from. */
|
|
65
65
|
type DeleteDirection = "backward" | "forward" | "line";
|
|
66
66
|
|
|
67
|
+
/** Minimal slice of pi's app theme we need to paint the block background. */
|
|
68
|
+
interface ThemeBackgroundProvider {
|
|
69
|
+
bg(color: string, text: string): string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Theme key used as the paste-block background (also used for user messages). */
|
|
73
|
+
const BLOCK_BG = "userMessageBg";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Paste content captured from the base editor right before it drops its
|
|
77
|
+
* registry, keyed by its canonical single-line marker.
|
|
78
|
+
*
|
|
79
|
+
* Session switches (`/new`, `/resume`, fork) tear our editor down
|
|
80
|
+
* (`resetExtensionUI()` -> `setCustomEditorComponent(undefined)`), so a paste
|
|
81
|
+
* made while the session is rebuilt lands in the base `CustomEditor`. pi then
|
|
82
|
+
* rebuilds our editor with `setText(editor.getText())` — the collapsed marker —
|
|
83
|
+
* and the base `setText` clears its `pastes` map. The content would be lost and
|
|
84
|
+
* Enter would submit a literal `[paste #N ...]`. Capturing it in `getText()`
|
|
85
|
+
* (which pi calls just before the rebuild) keeps it recoverable.
|
|
86
|
+
*/
|
|
87
|
+
const recoveredPastes = new Map<string, PasteRecord>();
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Block registry shared by every editor instance of this session.
|
|
91
|
+
*
|
|
92
|
+
* pi rebuilds the editor component more than once around a session switch and
|
|
93
|
+
* carries the draft across with `setText(editor.getText())`, i.e. collapsed
|
|
94
|
+
* markers. Keeping the registry per instance lost the content on the second
|
|
95
|
+
* rebuild (the new instance saw no records and submitted the marker verbatim),
|
|
96
|
+
* so it lives here instead.
|
|
97
|
+
*/
|
|
98
|
+
const sharedPastes = new Map<number, PasteRecord>();
|
|
99
|
+
let sharedPasteCounter = 0;
|
|
100
|
+
|
|
101
|
+
let baseEditorCaptureInstalled = false;
|
|
102
|
+
|
|
103
|
+
/** Snapshot paste content of a base editor before its registry gets cleared. */
|
|
104
|
+
function installBaseEditorCapture(): void {
|
|
105
|
+
if (baseEditorCaptureInstalled) return;
|
|
106
|
+
baseEditorCaptureInstalled = true;
|
|
107
|
+
|
|
108
|
+
const proto = CustomEditor.prototype as unknown as { getText(): string };
|
|
109
|
+
const originalGetText = proto.getText;
|
|
110
|
+
|
|
111
|
+
proto.getText = function (this: unknown): string {
|
|
112
|
+
const text = originalGetText.call(this);
|
|
113
|
+
const registry = (this as { pastes?: Map<number, string> }).pastes;
|
|
114
|
+
if (!registry || registry.size === 0 || !text.includes("[paste #")) return text;
|
|
115
|
+
|
|
116
|
+
for (const [id, content] of registry) {
|
|
117
|
+
const lines = content.split("\n");
|
|
118
|
+
const marker =
|
|
119
|
+
lines.length > 10
|
|
120
|
+
? `[paste #${id} +${lines.length} lines]`
|
|
121
|
+
: `[paste #${id} ${content.length} chars]`;
|
|
122
|
+
if (!text.includes(marker) || recoveredPastes.has(marker)) continue;
|
|
123
|
+
recoveredPastes.set(marker, {
|
|
124
|
+
content,
|
|
125
|
+
lines: lines.length,
|
|
126
|
+
chars: content.length,
|
|
127
|
+
first: lines[0] ?? "",
|
|
128
|
+
last: lines[lines.length - 1] ?? "",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return text;
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
67
135
|
export default function betterPasteMarkers(pi: ExtensionAPI): void {
|
|
136
|
+
installBaseEditorCapture();
|
|
68
137
|
pi.on("session_start", (_event, ctx) => {
|
|
138
|
+
const getAppTheme = () => ctx.ui.theme as unknown as ThemeBackgroundProvider;
|
|
69
139
|
ctx.ui.setEditorComponent(
|
|
70
|
-
(tui, theme, keybindings) => new BetterPasteEditor(tui, theme, keybindings),
|
|
140
|
+
(tui, theme, keybindings) => new BetterPasteEditor(tui, theme, keybindings, getAppTheme),
|
|
71
141
|
);
|
|
72
142
|
});
|
|
73
143
|
}
|
|
74
144
|
|
|
75
145
|
export class BetterPasteEditor extends CustomEditor {
|
|
76
|
-
/** id -> record; ids come from our own counter, so they never collide. */
|
|
77
|
-
private pasteRecords = new Map<number, PasteRecord>();
|
|
78
|
-
private ownPasteCounter = 0;
|
|
79
146
|
private editorKeybindings: KeybindingsManager;
|
|
80
|
-
|
|
81
|
-
|
|
147
|
+
/** Resolves the current app theme so the block background follows theme switches. */
|
|
148
|
+
private getAppTheme?: () => ThemeBackgroundProvider;
|
|
149
|
+
|
|
150
|
+
constructor(
|
|
151
|
+
tui: TUI,
|
|
152
|
+
theme: EditorTheme,
|
|
153
|
+
keybindings: KeybindingsManager,
|
|
154
|
+
getAppTheme?: () => ThemeBackgroundProvider,
|
|
155
|
+
) {
|
|
82
156
|
super(tui, theme, keybindings);
|
|
83
157
|
this.editorKeybindings = keybindings;
|
|
158
|
+
this.getAppTheme = getAppTheme;
|
|
84
159
|
}
|
|
85
160
|
|
|
86
161
|
/**
|
|
@@ -94,7 +169,7 @@ export class BetterPasteEditor extends CustomEditor {
|
|
|
94
169
|
}
|
|
95
170
|
|
|
96
171
|
private tryDeleteBlock(data: string): boolean {
|
|
97
|
-
if (
|
|
172
|
+
if (sharedPastes.size === 0 || this.isShowingAutocomplete()) return false;
|
|
98
173
|
|
|
99
174
|
const kb = this.editorKeybindings;
|
|
100
175
|
let direction: DeleteDirection | null = null;
|
|
@@ -134,7 +209,7 @@ export class BetterPasteEditor extends CustomEditor {
|
|
|
134
209
|
const middle = MIDDLE_RE.exec(lines[i + 1] ?? "");
|
|
135
210
|
if (!middle) continue;
|
|
136
211
|
const id = Number(middle[1]);
|
|
137
|
-
const record =
|
|
212
|
+
const record = sharedPastes.get(id);
|
|
138
213
|
if (!record) continue;
|
|
139
214
|
const opening = `[${record.first}`;
|
|
140
215
|
const closing = `${record.last}]`;
|
|
@@ -207,8 +282,8 @@ export class BetterPasteEditor extends CustomEditor {
|
|
|
207
282
|
this.lastAction = null;
|
|
208
283
|
this.pushUndoSnapshot();
|
|
209
284
|
|
|
210
|
-
const id = ++
|
|
211
|
-
|
|
285
|
+
const id = ++sharedPasteCounter;
|
|
286
|
+
sharedPastes.set(id, {
|
|
212
287
|
content: filteredText,
|
|
213
288
|
lines: pastedLines.length,
|
|
214
289
|
chars: filteredText.length,
|
|
@@ -216,7 +291,7 @@ export class BetterPasteEditor extends CustomEditor {
|
|
|
216
291
|
last: previewTail(pastedLines[pastedLines.length - 1] ?? ""),
|
|
217
292
|
});
|
|
218
293
|
|
|
219
|
-
const record =
|
|
294
|
+
const record = sharedPastes.get(id)!;
|
|
220
295
|
const block = [
|
|
221
296
|
`[${record.first}`,
|
|
222
297
|
`paste #${id} ${filteredText.length} chars / ${pastedLines.length} lines`,
|
|
@@ -255,7 +330,7 @@ export class BetterPasteEditor extends CustomEditor {
|
|
|
255
330
|
|
|
256
331
|
/** Collapse every intact 3-line block back to its canonical single-line marker. */
|
|
257
332
|
private collapse(text: string): string {
|
|
258
|
-
if (
|
|
333
|
+
if (sharedPastes.size === 0) return text;
|
|
259
334
|
const lines = text.split("\n");
|
|
260
335
|
const blocks = this.findBlocks(lines);
|
|
261
336
|
if (blocks.length === 0) return text;
|
|
@@ -278,7 +353,7 @@ export class BetterPasteEditor extends CustomEditor {
|
|
|
278
353
|
/** Replace collapsed canonical markers with their original content. */
|
|
279
354
|
private expand(text: string): string {
|
|
280
355
|
let result = text;
|
|
281
|
-
for (const [id, rec] of
|
|
356
|
+
for (const [id, rec] of sharedPastes) {
|
|
282
357
|
result = result
|
|
283
358
|
.split(`[paste #${id} ${rec.chars} chars]`)
|
|
284
359
|
.join(rec.content)
|
|
@@ -288,6 +363,53 @@ export class BetterPasteEditor extends CustomEditor {
|
|
|
288
363
|
return result;
|
|
289
364
|
}
|
|
290
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Rebuild blocks from collapsed markers handed back by pi.
|
|
368
|
+
*
|
|
369
|
+
* pi rebuilds the editor component around session switches with
|
|
370
|
+
* `setText(editor.getText())`, i.e. canonical inline markers. Without this
|
|
371
|
+
* the block would return as literal text that submits verbatim. Content
|
|
372
|
+
* comes from the shared registry (a previous instance's paste) or from
|
|
373
|
+
* `recoveredPastes` (a paste that landed in the base editor during the
|
|
374
|
+
* switch).
|
|
375
|
+
*/
|
|
376
|
+
override setText(text: string): void {
|
|
377
|
+
super.setText(this.restoreBlocks(text));
|
|
378
|
+
recoveredPastes.clear();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private restoreBlocks(text: string): string {
|
|
382
|
+
if (!text.includes("[paste #")) return text;
|
|
383
|
+
|
|
384
|
+
let result = text;
|
|
385
|
+
for (const [id, record] of sharedPastes) {
|
|
386
|
+
result = this.substituteMarker(result, id, record);
|
|
387
|
+
}
|
|
388
|
+
for (const [marker, record] of recoveredPastes) {
|
|
389
|
+
const id = Number(/\[paste #(\d+)/.exec(marker)?.[1]);
|
|
390
|
+
if (!Number.isFinite(id)) continue;
|
|
391
|
+
sharedPastes.set(id, record);
|
|
392
|
+
sharedPasteCounter = Math.max(sharedPasteCounter, id);
|
|
393
|
+
result = this.substituteMarker(result, id, record);
|
|
394
|
+
}
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Replace one paste's canonical marker(s) with its three-line block. */
|
|
399
|
+
private substituteMarker(text: string, id: number, record: PasteRecord): string {
|
|
400
|
+
const linesMarker = `[paste #${id} +${record.lines} lines]`;
|
|
401
|
+
const charsMarker = `[paste #${id} ${record.chars} chars]`;
|
|
402
|
+
const marker = text.includes(linesMarker) ? linesMarker : text.includes(charsMarker) ? charsMarker : null;
|
|
403
|
+
if (!marker) return text;
|
|
404
|
+
|
|
405
|
+
const first = previewHead(record.first);
|
|
406
|
+
const last = previewTail(record.last);
|
|
407
|
+
sharedPastes.set(id, { ...record, first, last });
|
|
408
|
+
return text
|
|
409
|
+
.split(marker)
|
|
410
|
+
.join([`[${first}`, `paste #${id} ${record.chars} chars / ${record.lines} lines`, `${last}]`].join("\n"));
|
|
411
|
+
}
|
|
412
|
+
|
|
291
413
|
override getText(): string {
|
|
292
414
|
return this.collapse(super.getText());
|
|
293
415
|
}
|
|
@@ -346,8 +468,58 @@ export class BetterPasteEditor extends CustomEditor {
|
|
|
346
468
|
for (let i = bottomBorderIndex + 1; i < lines.length; i++) {
|
|
347
469
|
result.push(lines[i] ?? "");
|
|
348
470
|
}
|
|
471
|
+
this.paintBlocks(result, bottomBorderIndex);
|
|
349
472
|
return result;
|
|
350
473
|
}
|
|
474
|
+
|
|
475
|
+
/** Block text portions that should carry the background, longest first. */
|
|
476
|
+
private blockSpans(): string[] {
|
|
477
|
+
const lines = this.state.lines;
|
|
478
|
+
const spans: string[] = [];
|
|
479
|
+
for (const block of this.findBlocks()) {
|
|
480
|
+
const first = lines[block.startLine] ?? "";
|
|
481
|
+
const middle = lines[block.startLine + 1] ?? "";
|
|
482
|
+
const last = lines[block.endLine] ?? "";
|
|
483
|
+
if (block.endLine === block.startLine + 2) {
|
|
484
|
+
spans.push(first.slice(block.startCol), middle, last.slice(0, block.endCol));
|
|
485
|
+
} else {
|
|
486
|
+
for (let line = block.startLine; line <= block.endLine; line++) spans.push(lines[line] ?? "");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return spans.filter((span) => span.length > 0).sort((a, b) => b.length - a.length);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** ANSI prefix for the block background, or null when the theme lacks it. */
|
|
493
|
+
private blockBackground(): string | null {
|
|
494
|
+
const theme = this.getAppTheme?.();
|
|
495
|
+
if (!theme) return null;
|
|
496
|
+
try {
|
|
497
|
+
const wrapped = theme.bg(BLOCK_BG, "");
|
|
498
|
+
const reset = "\x1b[49m";
|
|
499
|
+
const ansi = wrapped.endsWith(reset) ? wrapped.slice(0, -reset.length) : wrapped;
|
|
500
|
+
return ansi || null;
|
|
501
|
+
} catch {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Paint only the block text (not the padding) with the theme background. */
|
|
507
|
+
private paintBlocks(rows: string[], bottomBorderIndex: number): void {
|
|
508
|
+
const spans = this.blockSpans();
|
|
509
|
+
if (spans.length === 0) return;
|
|
510
|
+
const background = this.blockBackground();
|
|
511
|
+
if (!background) return;
|
|
512
|
+
for (let i = 1; i < bottomBorderIndex; i++) {
|
|
513
|
+
const row = rows[i];
|
|
514
|
+
if (row === undefined) continue;
|
|
515
|
+
for (const span of spans) {
|
|
516
|
+
if (stripAnsi(row).includes(span)) {
|
|
517
|
+
rows[i] = paintSpan(row, span, background);
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
351
523
|
}
|
|
352
524
|
|
|
353
525
|
/** First-line preview: keep the head, add `...` only when something was cut. */
|
|
@@ -359,3 +531,65 @@ function previewHead(text: string): string {
|
|
|
359
531
|
function previewTail(text: string): string {
|
|
360
532
|
return text.length <= PREVIEW_MAX_CHARS ? text : `...${text.slice(text.length - (PREVIEW_MAX_CHARS - 3))}`;
|
|
361
533
|
}
|
|
534
|
+
|
|
535
|
+
/** Length of the escape/APC sequence starting at `index`, or 0 for plain text. */
|
|
536
|
+
function ansiSequenceLength(text: string, index: number): number {
|
|
537
|
+
if (text[index] !== "\x1b") return 0;
|
|
538
|
+
const next = text[index + 1];
|
|
539
|
+
if (next === "[") {
|
|
540
|
+
for (let i = index + 2; i < text.length; i++) {
|
|
541
|
+
const code = text.charCodeAt(i);
|
|
542
|
+
if (code >= 0x40 && code <= 0x7e) return i - index + 1;
|
|
543
|
+
}
|
|
544
|
+
return text.length - index;
|
|
545
|
+
}
|
|
546
|
+
if (next === "_" || next === "]" || next === "P" || next === "^") {
|
|
547
|
+
for (let i = index + 2; i < text.length; i++) {
|
|
548
|
+
if (text[i] === "\x07") return i - index + 1;
|
|
549
|
+
if (text[i] === "\x1b" && text[i + 1] === "\\") return i - index + 2;
|
|
550
|
+
}
|
|
551
|
+
return text.length - index;
|
|
552
|
+
}
|
|
553
|
+
return 2;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Drop ANSI/APC escape sequences, keeping the visible characters. */
|
|
557
|
+
function stripAnsi(text: string): string {
|
|
558
|
+
let out = "";
|
|
559
|
+
for (let i = 0; i < text.length; ) {
|
|
560
|
+
const esc = ansiSequenceLength(text, i);
|
|
561
|
+
if (esc > 0) {
|
|
562
|
+
i += esc;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
out += text[i];
|
|
566
|
+
i++;
|
|
567
|
+
}
|
|
568
|
+
return out;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Paint only the `span` characters of `row` with `background`, leaving padding untouched. */
|
|
572
|
+
function paintSpan(row: string, span: string, background: string): string {
|
|
573
|
+
const startPlain = stripAnsi(row).indexOf(span);
|
|
574
|
+
if (startPlain < 0) return row;
|
|
575
|
+
const endPlain = startPlain + span.length;
|
|
576
|
+
let plain = 0;
|
|
577
|
+
let start = -1;
|
|
578
|
+
let end = -1;
|
|
579
|
+
for (let i = 0; i < row.length; ) {
|
|
580
|
+
const esc = ansiSequenceLength(row, i);
|
|
581
|
+
if (esc > 0) {
|
|
582
|
+
i += esc;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (plain === startPlain) start = i;
|
|
586
|
+
// `end` sits right after the last span character, so a cursor escape that
|
|
587
|
+
// follows the text stays outside the painted range.
|
|
588
|
+
if (plain === endPlain - 1) end = i + 1;
|
|
589
|
+
plain++;
|
|
590
|
+
i++;
|
|
591
|
+
}
|
|
592
|
+
if (start < 0 || end < 0) return row;
|
|
593
|
+
const inner = row.slice(start, end).replaceAll("\x1b[0m", `\x1b[0m${background}`);
|
|
594
|
+
return `${row.slice(0, start)}${background}${inner}\x1b[49m${row.slice(end)}`;
|
|
595
|
+
}
|
package/package.json
CHANGED