pi-better-paste-markers 0.1.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.
Files changed (3) hide show
  1. package/README.md +41 -0
  2. package/index.ts +320 -0
  3. package/package.json +37 -0
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # pi-better-paste-markers
2
+
3
+ Richer collapsed paste markers for [pi](https://github.com/earendil-works/pi).
4
+
5
+ Large pastes collapse into a bare `[paste #1 1623 chars]` marker with no feedback about what was actually pasted — easy to mix up clipboards (Linux primary/secondary). This replaces the marker with a three-line preview block. Pure extension: no core changes. Follow-up to [earendil-works/pi#10216](https://github.com/earendil-works/pi/issues/10216).
6
+
7
+ ## What it does
8
+
9
+ Pastes over 10 lines or 1000 chars render as a three-line block:
10
+
11
+ ```
12
+ [first line of the pasted text, truncated...
13
+ paste #1 1623 chars / 42 lines
14
+ ...last line of the pasted text, truncated from the left]
15
+ ```
16
+
17
+ - Small pastes keep the built-in behavior (inserted as-is, no marker).
18
+ - The first/last previews show up to 80 characters; `...` is added only when the line was actually cut.
19
+ - The block is ordinary editable buffer lines; the middle line carries the marker.
20
+ - On submit the block is replaced with the original pasted content — nothing is lost.
21
+ - Everywhere else (drafts, autocomplete, undo) the block collapses back to the standard single-line `[paste #N ...]` marker.
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
+ - Undo restores the whole block and it keeps expanding/submitting correctly.
24
+
25
+ ## Install
26
+
27
+ ```
28
+ pi install git:github.com/acidnik/pi-better-paste-markers
29
+ ```
30
+
31
+ ## How it works
32
+
33
+ The extension replaces the main editor on `session_start` via `ctx.ui.setEditorComponent()` with a `CustomEditor` subclass:
34
+
35
+ - `handlePaste()` — small pastes delegate to the base; large pastes insert the 3-line block and keep the content in an extension-side map.
36
+ - `handleInput()` — intercepts delete keys (`deleteCharBackward/Forward`, `deleteWordBackward/Forward`, `deleteToLineStart/End`) and removes the whole block when the cursor touches one; every other key goes to the base editor untouched.
37
+ - `getText()` — collapses blocks back to canonical `[paste #N ...]` markers (drafts, autocomplete snapshots, undo).
38
+ - `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
+ - `getExpandedText()` — used by the follow-up (Alt+Enter), external-editor and extension-API (`getEditorText`) paths.
40
+
41
+ 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 ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * pi-better-paste-markers — richer collapsed paste markers for pi.
3
+ *
4
+ * Large pastes (>10 lines or >1000 chars) collapse into a bare
5
+ * `[paste #N 1234 chars]` marker that gives no feedback about what was actually
6
+ * pasted (easy to mix up clipboards). This extension replaces the main editor
7
+ * with one that renders the marker as a three-line block:
8
+ *
9
+ * [first line of the pasted text, truncated...
10
+ * paste #1 1623 chars / 42 lines
11
+ * ...last line of the pasted text, truncated from the left]
12
+ *
13
+ * The buffer keeps the block as ordinary editable lines, and the canonical
14
+ * single-line marker lives on the middle line. On submit (`getExpandedText()`)
15
+ * the whole block is replaced with the original paste content; everywhere else
16
+ * (`getText()`) it collapses back to the canonical marker, so drafts,
17
+ * autocomplete snapshots and undo all see the standard form.
18
+ *
19
+ * Delete keys are intercepted so the three lines behave as one unit: a single
20
+ * backspace/delete that touches a block removes the whole block, like the
21
+ * built-in `[paste #N ...]` marker.
22
+ */
23
+
24
+ import {
25
+ CustomEditor,
26
+ type ExtensionAPI,
27
+ type KeybindingsManager,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import { matchesKey, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
30
+
31
+ /** The middle line of a paste block (our own format, ids from our own counter). */
32
+ const MIDDLE_RE = /^paste #(\d+) (\d+) chars \/ (\d+) lines$/;
33
+ /** Max characters shown for the first/last preview line before truncation. */
34
+ const PREVIEW_MAX_CHARS = 80;
35
+
36
+ interface PasteRecord {
37
+ content: string;
38
+ lines: number;
39
+ chars: number;
40
+ /** First-line preview; carries a trailing `...` when truncated. */
41
+ first: string;
42
+ /** Last-line preview; carries a leading `...` when truncated. */
43
+ last: string;
44
+ }
45
+
46
+ /** Where a rendered three-line paste block lives in the editor buffer. */
47
+ interface PasteBlock {
48
+ /** Logical line holding the leading `[first line...` preview. */
49
+ startLine: number;
50
+ /** Logical line holding the trailing `...last line]` preview. */
51
+ endLine: number;
52
+ /** Column in `startLine` where the block's opening `[` sits. */
53
+ startCol: number;
54
+ /** Column in `endLine` just past the block's closing `]`. */
55
+ endCol: number;
56
+ /** Paste id carried by the block's middle line. */
57
+ id: number;
58
+ /** The paste this block stands for. */
59
+ record: PasteRecord;
60
+ }
61
+
62
+ /** Which side of the block a deletion key bites from. */
63
+ type DeleteDirection = "backward" | "forward" | "line";
64
+
65
+ export default function betterPasteMarkers(pi: ExtensionAPI): void {
66
+ pi.on("session_start", (_event, ctx) => {
67
+ ctx.ui.setEditorComponent(
68
+ (tui, theme, keybindings) => new BetterPasteEditor(tui, theme, keybindings),
69
+ );
70
+ });
71
+ }
72
+
73
+ export class BetterPasteEditor extends CustomEditor {
74
+ /** id -> record; ids come from our own counter, so they never collide. */
75
+ private pasteRecords = new Map<number, PasteRecord>();
76
+ private ownPasteCounter = 0;
77
+ private editorKeybindings: KeybindingsManager;
78
+
79
+ constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) {
80
+ super(tui, theme, keybindings);
81
+ this.editorKeybindings = keybindings;
82
+ }
83
+
84
+ /**
85
+ * Delete keys hit our three-line blocks as one unit: any deletion that would
86
+ * bite into a block removes the whole block, matching the built-in
87
+ * single-line `[paste #N ...]` marker behavior.
88
+ */
89
+ override handleInput(data: string): void {
90
+ if (this.tryDeleteBlock(data)) return;
91
+ super.handleInput(data);
92
+ }
93
+
94
+ private tryDeleteBlock(data: string): boolean {
95
+ if (this.pasteRecords.size === 0 || this.isShowingAutocomplete()) return false;
96
+
97
+ const kb = this.editorKeybindings;
98
+ let direction: DeleteDirection | null = null;
99
+ if (
100
+ kb.matches(data, "tui.editor.deleteCharBackward") ||
101
+ kb.matches(data, "tui.editor.deleteWordBackward") ||
102
+ matchesKey(data, "shift+backspace")
103
+ ) {
104
+ direction = "backward";
105
+ } else if (
106
+ kb.matches(data, "tui.editor.deleteCharForward") ||
107
+ kb.matches(data, "tui.editor.deleteWordForward") ||
108
+ matchesKey(data, "shift+delete")
109
+ ) {
110
+ direction = "forward";
111
+ } else if (
112
+ kb.matches(data, "tui.editor.deleteToLineStart") ||
113
+ kb.matches(data, "tui.editor.deleteToLineEnd")
114
+ ) {
115
+ // Ctrl+U / Ctrl+K can shear a block apart in the middle of a line,
116
+ // so treat any block line as a hit for these.
117
+ direction = "line";
118
+ }
119
+ if (!direction) return false;
120
+
121
+ const block = this.findBlocks().find((candidate) => this.cursorHitsBlock(candidate, direction!));
122
+ if (!block) return false;
123
+
124
+ this.deleteBlock(block);
125
+ return true;
126
+ }
127
+
128
+ /** Find every intact three-line block in `lines` (defaults to the buffer). */
129
+ private findBlocks(lines: string[] = this.state.lines): PasteBlock[] {
130
+ const blocks: PasteBlock[] = [];
131
+ for (let i = 0; i + 2 < lines.length; i++) {
132
+ const middle = MIDDLE_RE.exec(lines[i + 1] ?? "");
133
+ if (!middle) continue;
134
+ const id = Number(middle[1]);
135
+ const record = this.pasteRecords.get(id);
136
+ if (!record) continue;
137
+ const opening = `[${record.first}`;
138
+ const closing = `${record.last}]`;
139
+ const firstLine = lines[i] ?? "";
140
+ const lastLine = lines[i + 2] ?? "";
141
+ if (!firstLine.endsWith(opening) || !lastLine.startsWith(closing)) continue;
142
+ blocks.push({
143
+ startLine: i,
144
+ endLine: i + 2,
145
+ startCol: firstLine.length - opening.length,
146
+ endCol: closing.length,
147
+ id,
148
+ record,
149
+ });
150
+ i += 2; // the next block cannot overlap this one
151
+ }
152
+ return blocks;
153
+ }
154
+
155
+ /** Does a deletion in `direction` reach into this block? */
156
+ private cursorHitsBlock(block: PasteBlock, direction: DeleteDirection): boolean {
157
+ const line = this.state.cursorLine;
158
+ const col = this.state.cursorCol;
159
+ if (line < block.startLine || line > block.endLine) return false;
160
+ if (direction === "line") return true;
161
+
162
+ const firstLineLength = (this.state.lines[block.startLine] ?? "").length;
163
+ if (line === block.startLine) {
164
+ return direction === "backward"
165
+ ? col > block.startCol && col <= firstLineLength
166
+ : col >= block.startCol && col <= firstLineLength;
167
+ }
168
+ if (line === block.endLine) {
169
+ return direction === "backward" ? col <= block.endCol : col < block.endCol;
170
+ }
171
+ return true; // middle line is entirely inside the block
172
+ }
173
+
174
+ /** Remove all block lines, keeping the text before and after on one line. */
175
+ private deleteBlock(block: PasteBlock): void {
176
+ this.pushUndoSnapshot();
177
+ this.lastAction = null;
178
+ this.exitHistoryBrowsing();
179
+
180
+ const lines = this.state.lines;
181
+ const before = (lines[block.startLine] ?? "").slice(0, block.startCol);
182
+ const after = (lines[block.endLine] ?? "").slice(block.endCol);
183
+ lines[block.startLine] = before + after;
184
+ lines.splice(block.startLine + 1, block.endLine - block.startLine);
185
+
186
+ // Keep the paste record: undo restores the block lines, and the record is
187
+ // what lets collapse()/expand() recognize them again afterwards.
188
+ this.state.cursorLine = block.startLine;
189
+ this.state.cursorCol = before.length;
190
+ if (this.onChange) this.onChange(this.getText());
191
+ }
192
+
193
+ override handlePaste(pastedText: string): void {
194
+ const filteredText = this.cleanPastedText(pastedText);
195
+ const pastedLines = filteredText.split("\n");
196
+
197
+ // Small pastes keep the base behavior (inserted as-is, no marker).
198
+ if (pastedLines.length <= 10 && filteredText.length <= 1000) {
199
+ super.handlePaste(pastedText);
200
+ return;
201
+ }
202
+
203
+ this.cancelAutocomplete();
204
+ this.exitHistoryBrowsing();
205
+ this.lastAction = null;
206
+ this.pushUndoSnapshot();
207
+
208
+ const id = ++this.ownPasteCounter;
209
+ this.pasteRecords.set(id, {
210
+ content: filteredText,
211
+ lines: pastedLines.length,
212
+ chars: filteredText.length,
213
+ first: previewHead(pastedLines[0] ?? ""),
214
+ last: previewTail(pastedLines[pastedLines.length - 1] ?? ""),
215
+ });
216
+
217
+ const record = this.pasteRecords.get(id)!;
218
+ const block = [
219
+ `[${record.first}`,
220
+ `paste #${id} ${filteredText.length} chars / ${pastedLines.length} lines`,
221
+ `${record.last}]`,
222
+ ];
223
+ this.insertTextAtCursorInternal(block.join("\n"));
224
+ }
225
+
226
+ /**
227
+ * Base cleanup for pasted text: decode tmux CSI-u re-encoding of control
228
+ * bytes inside bracketed paste, normalize line endings/tabs, drop
229
+ * non-printables (keep newlines), and keep the "paste a path mid-word"
230
+ * space-prepending quirk.
231
+ */
232
+ private cleanPastedText(pastedText: string): string {
233
+ const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match: string, code: string) => {
234
+ const cp = Number(code);
235
+ if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
236
+ if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
237
+ return match;
238
+ });
239
+ const cleanText = this.normalizeText(decodedText);
240
+ let filteredText = cleanText
241
+ .split("")
242
+ .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
243
+ .join("");
244
+ if (/^[/~.]/.test(filteredText)) {
245
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
246
+ const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : "";
247
+ if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
248
+ filteredText = ` ${filteredText}`;
249
+ }
250
+ }
251
+ return filteredText;
252
+ }
253
+
254
+ /** Collapse every intact 3-line block back to its canonical single-line marker. */
255
+ private collapse(text: string): string {
256
+ if (this.pasteRecords.size === 0) return text;
257
+ const lines = text.split("\n");
258
+ const blocks = this.findBlocks(lines);
259
+ if (blocks.length === 0) return text;
260
+ // Rewrite from the bottom up so earlier line indexes stay valid.
261
+ for (let b = blocks.length - 1; b >= 0; b--) {
262
+ const block = blocks[b]!;
263
+ const prefix = (lines[block.startLine] ?? "").slice(0, block.startCol);
264
+ const suffix = (lines[block.endLine] ?? "").slice(block.endCol);
265
+ // Canonical single-line marker (base format), so downstream code and
266
+ // older sessions see the usual form.
267
+ const canonical =
268
+ block.record.lines > 10
269
+ ? `[paste #${block.id} +${block.record.lines} lines]`
270
+ : `[paste #${block.id} ${block.record.chars} chars]`;
271
+ lines.splice(block.startLine, block.endLine - block.startLine + 1, prefix + canonical + suffix);
272
+ }
273
+ return lines.join("\n");
274
+ }
275
+
276
+ /** Replace collapsed canonical markers with their original content. */
277
+ private expand(text: string): string {
278
+ let result = text;
279
+ for (const [id, rec] of this.pasteRecords) {
280
+ result = result
281
+ .split(`[paste #${id} ${rec.chars} chars]`)
282
+ .join(rec.content)
283
+ .split(`[paste #${id} +${rec.lines} lines]`)
284
+ .join(rec.content);
285
+ }
286
+ return result;
287
+ }
288
+
289
+ override getText(): string {
290
+ return this.collapse(super.getText());
291
+ }
292
+
293
+ override getLines(): string[] {
294
+ return this.collapse(super.getText()).split("\n");
295
+ }
296
+
297
+ override getExpandedText(): string {
298
+ return this.expand(this.collapse(super.getText()));
299
+ }
300
+
301
+ /**
302
+ * Plain Enter submits through the base editor's private `submitValue()`,
303
+ * which expands paste markers with this method — not with
304
+ * `getExpandedText()`. Shadowing it keeps our blocks from being submitted
305
+ * as raw three-line preview text (the base registry never holds them).
306
+ */
307
+ override expandPasteMarkers(text: string): string {
308
+ return this.expand(this.collapse(super.expandPasteMarkers(text)));
309
+ }
310
+ }
311
+
312
+ /** First-line preview: keep the head, add `...` only when something was cut. */
313
+ function previewHead(text: string): string {
314
+ return text.length <= PREVIEW_MAX_CHARS ? text : `${text.slice(0, PREVIEW_MAX_CHARS - 3)}...`;
315
+ }
316
+
317
+ /** Last-line preview: keep the tail, add `...` only when something was cut. */
318
+ function previewTail(text: string): string {
319
+ return text.length <= PREVIEW_MAX_CHARS ? text : `...${text.slice(text.length - (PREVIEW_MAX_CHARS - 3))}`;
320
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-better-paste-markers",
3
+ "version": "0.1.0",
4
+ "description": "Richer collapsed paste markers for pi: first/last line preview around the canonical paste marker",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi",
10
+ "pi-extension",
11
+ "pi-coding-agent",
12
+ "paste",
13
+ "tui"
14
+ ],
15
+ "author": "acidnik",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/acidnik/pi-better-paste-markers.git"
19
+ },
20
+ "homepage": "https://github.com/acidnik/pi-better-paste-markers#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/acidnik/pi-better-paste-markers/issues"
23
+ },
24
+ "files": [
25
+ "index.ts",
26
+ "README.md"
27
+ ],
28
+ "pi": {
29
+ "extensions": [
30
+ "./index.ts"
31
+ ]
32
+ },
33
+ "peerDependencies": {
34
+ "@earendil-works/pi-coding-agent": "*",
35
+ "@earendil-works/pi-tui": "*"
36
+ }
37
+ }