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.
Files changed (3) hide show
  1. package/README.md +5 -1
  2. package/index.ts +247 -13
  3. 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 `❯` and keeps the other content lines indented, matching the chrome pi-powerline-footer adds to the default editor.
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
- constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) {
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 (this.pasteRecords.size === 0 || this.isShowingAutocomplete()) return false;
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 = this.pasteRecords.get(id);
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 = ++this.ownPasteCounter;
211
- this.pasteRecords.set(id, {
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 = this.pasteRecords.get(id)!;
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 (this.pasteRecords.size === 0) return text;
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 this.pasteRecords) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-paste-markers",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Richer collapsed paste markers for pi: first/last line preview around the canonical paste marker",
5
5
  "type": "module",
6
6
  "license": "MIT",