@xynogen/pix-pretty 1.4.0 → 1.5.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/package.json +1 -1
- package/src/paste-chips.test.ts +57 -33
- package/src/paste-chips.ts +60 -7
package/package.json
CHANGED
package/src/paste-chips.test.ts
CHANGED
|
@@ -8,39 +8,43 @@
|
|
|
8
8
|
|
|
9
9
|
import { describe, expect, it } from "bun:test";
|
|
10
10
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
-
import { restyleMarkers } from "./paste-chips";
|
|
11
|
+
import { endsWithMarker, restyleMarkers } from "./paste-chips";
|
|
12
|
+
|
|
13
|
+
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
12
14
|
|
|
13
15
|
// ─── restyleMarkers ──────────────────────────────────────────────────────────
|
|
14
16
|
|
|
15
17
|
describe("paste-chips restyleMarkers", () => {
|
|
16
18
|
describe("text markers", () => {
|
|
17
|
-
it("restyles chars marker to text
|
|
19
|
+
it("restyles chars marker to colored icon text chip", () => {
|
|
18
20
|
const result = restyleMarkers("[paste #1 2232 chars]", new Set());
|
|
19
|
-
expect(result).
|
|
21
|
+
expect(result).toContain("\x1b[");
|
|
22
|
+
expect(stripAnsi(result)).toBe(" text 2.2k chars");
|
|
20
23
|
});
|
|
21
24
|
|
|
22
|
-
it("restyles lines marker to text
|
|
25
|
+
it("restyles lines marker to colored icon text chip", () => {
|
|
23
26
|
const result = restyleMarkers("[paste #2 +42 lines]", new Set());
|
|
24
|
-
expect(result).toBe("
|
|
27
|
+
expect(stripAnsi(result)).toBe(" text 42 lines");
|
|
25
28
|
});
|
|
26
29
|
|
|
27
|
-
it("restyles bare marker (no size info) to text
|
|
30
|
+
it("restyles bare marker (no size info) to text chip with id", () => {
|
|
28
31
|
const result = restyleMarkers("[paste #3]", new Set());
|
|
29
|
-
expect(result).toBe("
|
|
32
|
+
expect(stripAnsi(result)).toBe(" text #3");
|
|
30
33
|
});
|
|
31
34
|
});
|
|
32
35
|
|
|
33
36
|
describe("image markers", () => {
|
|
34
|
-
it("restyles marker to image
|
|
37
|
+
it("restyles marker to colored icon image chip when ID is in imageIds", () => {
|
|
35
38
|
const imageIds = new Set([1]);
|
|
36
39
|
const result = restyleMarkers("[paste #1 58 chars]", imageIds);
|
|
37
|
-
expect(result).
|
|
40
|
+
expect(result).toContain("\x1b[");
|
|
41
|
+
expect(stripAnsi(result)).toBe(" image #1");
|
|
38
42
|
});
|
|
39
43
|
|
|
40
44
|
it("does not restyle non-image ID as image", () => {
|
|
41
45
|
const imageIds = new Set([1]);
|
|
42
46
|
const result = restyleMarkers("[paste #2 100 chars]", imageIds);
|
|
43
|
-
expect(result).toBe("
|
|
47
|
+
expect(stripAnsi(result)).toBe(" text 100 chars");
|
|
44
48
|
});
|
|
45
49
|
});
|
|
46
50
|
|
|
@@ -50,8 +54,8 @@ describe("paste-chips restyleMarkers", () => {
|
|
|
50
54
|
const line =
|
|
51
55
|
"before [paste #1 58 chars] middle [paste #2 +10 lines] after";
|
|
52
56
|
const result = restyleMarkers(line, imageIds);
|
|
53
|
-
expect(result).toBe(
|
|
54
|
-
"before
|
|
57
|
+
expect(stripAnsi(result)).toBe(
|
|
58
|
+
"before image #1 middle text 10 lines after",
|
|
55
59
|
);
|
|
56
60
|
});
|
|
57
61
|
});
|
|
@@ -71,7 +75,7 @@ describe("paste-chips restyleMarkers", () => {
|
|
|
71
75
|
it("restyles markers embedded in ANSI sequences", () => {
|
|
72
76
|
const line = "\x1b[38;2;84;92;126m[paste #1 500 chars]\x1b[0m";
|
|
73
77
|
const result = restyleMarkers(line, new Set());
|
|
74
|
-
expect(result).toBe("
|
|
78
|
+
expect(stripAnsi(result)).toBe(" text 500 chars");
|
|
75
79
|
});
|
|
76
80
|
});
|
|
77
81
|
});
|
|
@@ -79,11 +83,10 @@ describe("paste-chips restyleMarkers", () => {
|
|
|
79
83
|
// ─── Width safety (regression for crash) ─────────────────────────────────────
|
|
80
84
|
|
|
81
85
|
describe("paste-chips width safety", () => {
|
|
82
|
-
it("restyling a chars marker can
|
|
83
|
-
// This is the core issue: "#1" (2 chars) → "text" (4 chars) = +2 width
|
|
86
|
+
it("restyling a chars marker can decrease visible width", () => {
|
|
84
87
|
const original = "[paste #1 2232 chars]";
|
|
85
88
|
const restyled = restyleMarkers(original, new Set());
|
|
86
|
-
expect(visibleWidth(restyled)).
|
|
89
|
+
expect(visibleWidth(restyled)).toBeLessThan(visibleWidth(original));
|
|
87
90
|
});
|
|
88
91
|
|
|
89
92
|
it("restyling a lines marker can decrease visible width", () => {
|
|
@@ -101,38 +104,59 @@ describe("paste-chips width safety", () => {
|
|
|
101
104
|
expect(visibleWidth(restyled)).not.toBe(visibleWidth(original));
|
|
102
105
|
});
|
|
103
106
|
|
|
104
|
-
it("chars marker with large char count
|
|
105
|
-
// "[paste #N CCCC chars]" → "[paste text CCCC chars]"
|
|
106
|
-
// "#N" (2 chars for single digit) → "text" (4 chars) = exactly +2
|
|
107
|
+
it("chars marker with large char count compacts metadata", () => {
|
|
107
108
|
const original = "[paste #1 9999 chars]";
|
|
108
109
|
const restyled = restyleMarkers(original, new Set());
|
|
109
|
-
expect(
|
|
110
|
+
expect(stripAnsi(restyled)).toBe(" text 10k chars");
|
|
111
|
+
expect(visibleWidth(restyled)).toBeLessThan(visibleWidth(original));
|
|
110
112
|
});
|
|
111
113
|
|
|
112
|
-
it("chars marker with multi-digit ID
|
|
113
|
-
// "#10" (3 chars) → "text" (4 chars) = +1
|
|
114
|
+
it("chars marker with multi-digit ID omits id from text chip", () => {
|
|
114
115
|
const original = "[paste #10 9999 chars]";
|
|
115
116
|
const restyled = restyleMarkers(original, new Set());
|
|
116
|
-
expect(
|
|
117
|
+
expect(stripAnsi(restyled)).toBe(" text 10k chars");
|
|
118
|
+
expect(visibleWidth(restyled)).toBeLessThan(visibleWidth(original));
|
|
117
119
|
});
|
|
118
120
|
|
|
119
|
-
describe("
|
|
120
|
-
it("
|
|
121
|
-
// Simulate the crash: a line at exactly terminal width (283)
|
|
122
|
-
// containing a marker that widens by 2 after restyling.
|
|
121
|
+
describe("width behavior", () => {
|
|
122
|
+
it("icon chip stays within terminal width when marker line fits", () => {
|
|
123
123
|
const terminalWidth = 283;
|
|
124
|
-
const marker = "[paste #1 2232 chars]";
|
|
124
|
+
const marker = "[paste #1 2232 chars]";
|
|
125
125
|
const padding = " ".repeat(terminalWidth - visibleWidth(marker));
|
|
126
126
|
const line = marker + padding;
|
|
127
127
|
|
|
128
|
-
// Verify the original line fits
|
|
129
128
|
expect(visibleWidth(line)).toBe(terminalWidth);
|
|
130
129
|
|
|
131
|
-
// Restyle it — this WOULD exceed width without the fix
|
|
132
130
|
const restyled = restyleMarkers(line, new Set());
|
|
133
|
-
expect(visibleWidth(restyled)).
|
|
134
|
-
// Specifically: "[paste text 2232 chars]" is 23 chars, +2 over original 21
|
|
135
|
-
expect(visibleWidth(restyled)).toBe(terminalWidth + 2);
|
|
131
|
+
expect(visibleWidth(restyled)).toBeLessThanOrEqual(terminalWidth);
|
|
136
132
|
});
|
|
137
133
|
});
|
|
138
134
|
});
|
|
135
|
+
|
|
136
|
+
// ─── endsWithMarker (trailing-space gate) ──────────────────────────────
|
|
137
|
+
|
|
138
|
+
describe("paste-chips endsWithMarker", () => {
|
|
139
|
+
it("matches a chars marker at end of string", () => {
|
|
140
|
+
expect(endsWithMarker("[paste #1 2232 chars]")).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("matches a lines marker at end of string", () => {
|
|
144
|
+
expect(endsWithMarker("hello [paste #2 +42 lines]")).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("matches a bare marker at end of string", () => {
|
|
148
|
+
expect(endsWithMarker("[paste #3]")).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("does not match when marker is not at the end", () => {
|
|
152
|
+
expect(endsWithMarker("[paste #1 58 chars] trailing")).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("does not match plain text", () => {
|
|
156
|
+
expect(endsWithMarker("just some text")).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("does not match a lone trailing space", () => {
|
|
160
|
+
expect(endsWithMarker(" ")).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
package/src/paste-chips.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
22
22
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
23
|
+
import { BOLD, FG_BLUE, FG_DIM, FG_GREEN, RST } from "./ansi.js";
|
|
23
24
|
|
|
24
25
|
// Upstream stopped re-exporting `EditorFactory` from the package entry point,
|
|
25
26
|
// so we reconstruct its signature locally from the still-exported primitives.
|
|
@@ -100,8 +101,8 @@ function replaceImagePaths(
|
|
|
100
101
|
|
|
101
102
|
/**
|
|
102
103
|
* Re-style every paste marker in a rendered line:
|
|
103
|
-
* image →
|
|
104
|
-
* text →
|
|
104
|
+
* image → ` image #N` with blue icon/label
|
|
105
|
+
* text → ` text Lines lines` or ` text Chars chars` with green icon/label
|
|
105
106
|
*
|
|
106
107
|
* Width-preserving is not required — Pi re-wraps each render call.
|
|
107
108
|
*/
|
|
@@ -111,27 +112,79 @@ export function restyleMarkers(line: string, imageIds: Set<number>): string {
|
|
|
111
112
|
(_full, idStr, _g2, _g3, linesStr, charsStr) => {
|
|
112
113
|
const id = Number.parseInt(idStr, 10);
|
|
113
114
|
if (imageIds.has(id)) {
|
|
114
|
-
return
|
|
115
|
+
return chip(FG_BLUE, "", "image", `#${id}`);
|
|
115
116
|
}
|
|
116
117
|
if (linesStr) {
|
|
117
|
-
return
|
|
118
|
+
return chip(FG_GREEN, "", "text", `${linesStr} lines`);
|
|
118
119
|
}
|
|
119
120
|
if (charsStr) {
|
|
120
|
-
return
|
|
121
|
+
return chip(FG_GREEN, "", "text", `${compactNumber(charsStr)} chars`);
|
|
121
122
|
}
|
|
122
|
-
return
|
|
123
|
+
return chip(FG_GREEN, "", "text", `#${id}`);
|
|
123
124
|
},
|
|
124
125
|
);
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
function chip(
|
|
129
|
+
color: string,
|
|
130
|
+
icon: string,
|
|
131
|
+
label: string,
|
|
132
|
+
meta: string,
|
|
133
|
+
): string {
|
|
134
|
+
return `${color}${BOLD}${icon} ${label}${RST}${FG_DIM} ${meta}${RST}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** True when `text` ends with a Pi paste marker (chip), e.g. `[paste #1 58 chars]`. */
|
|
138
|
+
export function endsWithMarker(text: string): boolean {
|
|
139
|
+
return /\[paste #\d+[^\]]*\]$/.test(text);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function compactNumber(raw: string): string {
|
|
143
|
+
const n = Number.parseInt(raw, 10);
|
|
144
|
+
if (!Number.isFinite(n)) return raw;
|
|
145
|
+
if (n < 1_000) return `${n}`;
|
|
146
|
+
if (n < 1_000_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
147
|
+
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
|
|
148
|
+
}
|
|
149
|
+
|
|
127
150
|
// ─── Custom editor ────────────────────────────────────────────────────────────
|
|
128
151
|
|
|
152
|
+
// handlePaste is TS-private in CustomEditor but runtime-public JS. We patch it
|
|
153
|
+
// per-instance, capturing the base implementation, so large text pastes get the
|
|
154
|
+
// same trailing-space treatment as image chips.
|
|
155
|
+
type PasteHandler = { handlePaste(text: string): void };
|
|
156
|
+
|
|
129
157
|
class ChipEditor extends CustomEditor {
|
|
130
158
|
private readonly imageIds = new Set<number>();
|
|
131
159
|
|
|
160
|
+
constructor(...args: ConstructorParameters<typeof CustomEditor>) {
|
|
161
|
+
super(...args);
|
|
162
|
+
this.patchHandlePaste();
|
|
163
|
+
}
|
|
164
|
+
|
|
132
165
|
override insertTextAtCursor(text: string): void {
|
|
133
166
|
const internals = this as unknown as EditorInternals;
|
|
134
|
-
|
|
167
|
+
const replaced = replaceImagePaths(text, internals, this.imageIds);
|
|
168
|
+
// Append a trailing space when the insertion ends with a paste marker so
|
|
169
|
+
// the cursor lands after the chip rather than inside it.
|
|
170
|
+
super.insertTextAtCursor(
|
|
171
|
+
endsWithMarker(replaced) ? `${replaced} ` : replaced,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private patchHandlePaste(): void {
|
|
176
|
+
const self = this as unknown as PasteHandler;
|
|
177
|
+
const base = self.handlePaste.bind(self);
|
|
178
|
+
self.handlePaste = (pastedText: string) => {
|
|
179
|
+
const before = this.getText();
|
|
180
|
+
base(pastedText);
|
|
181
|
+
const after = this.getText();
|
|
182
|
+
// Only nudge a space when the paste collapsed into a marker chip;
|
|
183
|
+
// inline small pastes (no marker) are left untouched.
|
|
184
|
+
if (endsWithMarker(after) && after !== before) {
|
|
185
|
+
super.insertTextAtCursor(" ");
|
|
186
|
+
}
|
|
187
|
+
};
|
|
135
188
|
}
|
|
136
189
|
|
|
137
190
|
override render(width: number): string[] {
|