pi-better-paste-markers 0.1.0 → 0.3.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 +3 -0
- package/index.ts +173 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,8 @@ 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
|
+
- 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).
|
|
25
|
+
- 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.
|
|
24
26
|
|
|
25
27
|
## Install
|
|
26
28
|
|
|
@@ -37,5 +39,6 @@ The extension replaces the main editor on `session_start` via `ctx.ui.setEditorC
|
|
|
37
39
|
- `getText()` — collapses blocks back to canonical `[paste #N ...]` markers (drafts, autocomplete snapshots, undo).
|
|
38
40
|
- `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.
|
|
39
41
|
- `getExpandedText()` — used by the follow-up (Alt+Enter), external-editor and extension-API (`getEditorText`) paths.
|
|
42
|
+
- `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).
|
|
40
43
|
|
|
41
44
|
Paste records are kept after a block is deleted so that undoing the deletion restores a block that still collapses and expands correctly.
|
package/index.ts
CHANGED
|
@@ -32,6 +32,8 @@ import { matchesKey, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
|
|
|
32
32
|
const MIDDLE_RE = /^paste #(\d+) (\d+) chars \/ (\d+) lines$/;
|
|
33
33
|
/** Max characters shown for the first/last preview line before truncation. */
|
|
34
34
|
const PREVIEW_MAX_CHARS = 80;
|
|
35
|
+
/** Gray `❯` prompt drawn before the input line (matches pi-powerline-footer chrome). */
|
|
36
|
+
const INPUT_PROMPT = "\x1b[38;2;200;200;200m❯\x1b[0m";
|
|
35
37
|
|
|
36
38
|
interface PasteRecord {
|
|
37
39
|
content: string;
|
|
@@ -62,10 +64,19 @@ interface PasteBlock {
|
|
|
62
64
|
/** Which side of the block a deletion key bites from. */
|
|
63
65
|
type DeleteDirection = "backward" | "forward" | "line";
|
|
64
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
|
+
|
|
65
75
|
export default function betterPasteMarkers(pi: ExtensionAPI): void {
|
|
66
76
|
pi.on("session_start", (_event, ctx) => {
|
|
77
|
+
const getAppTheme = () => ctx.ui.theme as unknown as ThemeBackgroundProvider;
|
|
67
78
|
ctx.ui.setEditorComponent(
|
|
68
|
-
(tui, theme, keybindings) => new BetterPasteEditor(tui, theme, keybindings),
|
|
79
|
+
(tui, theme, keybindings) => new BetterPasteEditor(tui, theme, keybindings, getAppTheme),
|
|
69
80
|
);
|
|
70
81
|
});
|
|
71
82
|
}
|
|
@@ -75,10 +86,18 @@ export class BetterPasteEditor extends CustomEditor {
|
|
|
75
86
|
private pasteRecords = new Map<number, PasteRecord>();
|
|
76
87
|
private ownPasteCounter = 0;
|
|
77
88
|
private editorKeybindings: KeybindingsManager;
|
|
78
|
-
|
|
79
|
-
|
|
89
|
+
/** Resolves the current app theme so the block background follows theme switches. */
|
|
90
|
+
private getAppTheme?: () => ThemeBackgroundProvider;
|
|
91
|
+
|
|
92
|
+
constructor(
|
|
93
|
+
tui: TUI,
|
|
94
|
+
theme: EditorTheme,
|
|
95
|
+
keybindings: KeybindingsManager,
|
|
96
|
+
getAppTheme?: () => ThemeBackgroundProvider,
|
|
97
|
+
) {
|
|
80
98
|
super(tui, theme, keybindings);
|
|
81
99
|
this.editorKeybindings = keybindings;
|
|
100
|
+
this.getAppTheme = getAppTheme;
|
|
82
101
|
}
|
|
83
102
|
|
|
84
103
|
/**
|
|
@@ -307,6 +326,95 @@ export class BetterPasteEditor extends CustomEditor {
|
|
|
307
326
|
override expandPasteMarkers(text: string): string {
|
|
308
327
|
return this.expand(this.collapse(super.expandPasteMarkers(text)));
|
|
309
328
|
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Draw a `❯` prompt before the input, mirroring the chrome that
|
|
332
|
+
* pi-powerline-footer applies to the default editor. Replacing the editor
|
|
333
|
+
* component drops that wrapper, so we render the prompt ourselves.
|
|
334
|
+
*/
|
|
335
|
+
override render(width: number): string[] {
|
|
336
|
+
if (width < 10) return super.render(width);
|
|
337
|
+
|
|
338
|
+
const contentWidth = Math.max(1, width - 3);
|
|
339
|
+
const lines = super.render(contentWidth);
|
|
340
|
+
if (lines.length === 0) return lines;
|
|
341
|
+
|
|
342
|
+
// The bottom border is the last `───…` row before any autocomplete rows.
|
|
343
|
+
let bottomBorderIndex = lines.length - 1;
|
|
344
|
+
for (let i = lines.length - 1; i >= 1; i--) {
|
|
345
|
+
const stripped = (lines[i] ?? "").replace(/\x1b\[[0-9;]*m/g, "");
|
|
346
|
+
if (stripped.length > 0 && /^─{3,}/.test(stripped)) {
|
|
347
|
+
bottomBorderIndex = i;
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const promptPrefix = ` ${INPUT_PROMPT} `;
|
|
353
|
+
const contPrefix = " ";
|
|
354
|
+
const border = " " + this.borderColor("─".repeat(Math.max(0, width - 2)));
|
|
355
|
+
const result: string[] = [border];
|
|
356
|
+
for (let i = 1; i < bottomBorderIndex; i++) {
|
|
357
|
+
result.push(`${i === 1 ? promptPrefix : contPrefix}${lines[i] ?? ""}`);
|
|
358
|
+
}
|
|
359
|
+
if (bottomBorderIndex === 1) {
|
|
360
|
+
result.push(`${promptPrefix}${" ".repeat(contentWidth)}`);
|
|
361
|
+
}
|
|
362
|
+
result.push(border);
|
|
363
|
+
for (let i = bottomBorderIndex + 1; i < lines.length; i++) {
|
|
364
|
+
result.push(lines[i] ?? "");
|
|
365
|
+
}
|
|
366
|
+
this.paintBlocks(result, bottomBorderIndex);
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Block text portions that should carry the background, longest first. */
|
|
371
|
+
private blockSpans(): string[] {
|
|
372
|
+
const lines = this.state.lines;
|
|
373
|
+
const spans: string[] = [];
|
|
374
|
+
for (const block of this.findBlocks()) {
|
|
375
|
+
const first = lines[block.startLine] ?? "";
|
|
376
|
+
const middle = lines[block.startLine + 1] ?? "";
|
|
377
|
+
const last = lines[block.endLine] ?? "";
|
|
378
|
+
if (block.endLine === block.startLine + 2) {
|
|
379
|
+
spans.push(first.slice(block.startCol), middle, last.slice(0, block.endCol));
|
|
380
|
+
} else {
|
|
381
|
+
for (let line = block.startLine; line <= block.endLine; line++) spans.push(lines[line] ?? "");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return spans.filter((span) => span.length > 0).sort((a, b) => b.length - a.length);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** ANSI prefix for the block background, or null when the theme lacks it. */
|
|
388
|
+
private blockBackground(): string | null {
|
|
389
|
+
const theme = this.getAppTheme?.();
|
|
390
|
+
if (!theme) return null;
|
|
391
|
+
try {
|
|
392
|
+
const wrapped = theme.bg(BLOCK_BG, "");
|
|
393
|
+
const reset = "\x1b[49m";
|
|
394
|
+
const ansi = wrapped.endsWith(reset) ? wrapped.slice(0, -reset.length) : wrapped;
|
|
395
|
+
return ansi || null;
|
|
396
|
+
} catch {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Paint only the block text (not the padding) with the theme background. */
|
|
402
|
+
private paintBlocks(rows: string[], bottomBorderIndex: number): void {
|
|
403
|
+
const spans = this.blockSpans();
|
|
404
|
+
if (spans.length === 0) return;
|
|
405
|
+
const background = this.blockBackground();
|
|
406
|
+
if (!background) return;
|
|
407
|
+
for (let i = 1; i < bottomBorderIndex; i++) {
|
|
408
|
+
const row = rows[i];
|
|
409
|
+
if (row === undefined) continue;
|
|
410
|
+
for (const span of spans) {
|
|
411
|
+
if (stripAnsi(row).includes(span)) {
|
|
412
|
+
rows[i] = paintSpan(row, span, background);
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
310
418
|
}
|
|
311
419
|
|
|
312
420
|
/** First-line preview: keep the head, add `...` only when something was cut. */
|
|
@@ -318,3 +426,65 @@ function previewHead(text: string): string {
|
|
|
318
426
|
function previewTail(text: string): string {
|
|
319
427
|
return text.length <= PREVIEW_MAX_CHARS ? text : `...${text.slice(text.length - (PREVIEW_MAX_CHARS - 3))}`;
|
|
320
428
|
}
|
|
429
|
+
|
|
430
|
+
/** Length of the escape/APC sequence starting at `index`, or 0 for plain text. */
|
|
431
|
+
function ansiSequenceLength(text: string, index: number): number {
|
|
432
|
+
if (text[index] !== "\x1b") return 0;
|
|
433
|
+
const next = text[index + 1];
|
|
434
|
+
if (next === "[") {
|
|
435
|
+
for (let i = index + 2; i < text.length; i++) {
|
|
436
|
+
const code = text.charCodeAt(i);
|
|
437
|
+
if (code >= 0x40 && code <= 0x7e) return i - index + 1;
|
|
438
|
+
}
|
|
439
|
+
return text.length - index;
|
|
440
|
+
}
|
|
441
|
+
if (next === "_" || next === "]" || next === "P" || next === "^") {
|
|
442
|
+
for (let i = index + 2; i < text.length; i++) {
|
|
443
|
+
if (text[i] === "\x07") return i - index + 1;
|
|
444
|
+
if (text[i] === "\x1b" && text[i + 1] === "\\") return i - index + 2;
|
|
445
|
+
}
|
|
446
|
+
return text.length - index;
|
|
447
|
+
}
|
|
448
|
+
return 2;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Drop ANSI/APC escape sequences, keeping the visible characters. */
|
|
452
|
+
function stripAnsi(text: string): string {
|
|
453
|
+
let out = "";
|
|
454
|
+
for (let i = 0; i < text.length; ) {
|
|
455
|
+
const esc = ansiSequenceLength(text, i);
|
|
456
|
+
if (esc > 0) {
|
|
457
|
+
i += esc;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
out += text[i];
|
|
461
|
+
i++;
|
|
462
|
+
}
|
|
463
|
+
return out;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Paint only the `span` characters of `row` with `background`, leaving padding untouched. */
|
|
467
|
+
function paintSpan(row: string, span: string, background: string): string {
|
|
468
|
+
const startPlain = stripAnsi(row).indexOf(span);
|
|
469
|
+
if (startPlain < 0) return row;
|
|
470
|
+
const endPlain = startPlain + span.length;
|
|
471
|
+
let plain = 0;
|
|
472
|
+
let start = -1;
|
|
473
|
+
let end = -1;
|
|
474
|
+
for (let i = 0; i < row.length; ) {
|
|
475
|
+
const esc = ansiSequenceLength(row, i);
|
|
476
|
+
if (esc > 0) {
|
|
477
|
+
i += esc;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (plain === startPlain) start = i;
|
|
481
|
+
// `end` sits right after the last span character, so a cursor escape that
|
|
482
|
+
// follows the text stays outside the painted range.
|
|
483
|
+
if (plain === endPlain - 1) end = i + 1;
|
|
484
|
+
plain++;
|
|
485
|
+
i++;
|
|
486
|
+
}
|
|
487
|
+
if (start < 0 || end < 0) return row;
|
|
488
|
+
const inner = row.slice(start, end).replaceAll("\x1b[0m", `\x1b[0m${background}`);
|
|
489
|
+
return `${row.slice(0, start)}${background}${inner}\x1b[49m${row.slice(end)}`;
|
|
490
|
+
}
|
package/package.json
CHANGED