@xynogen/pix-pretty 1.6.3 → 1.7.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 +4 -0
- package/package.json +5 -9
- package/src/confirm.ts +174 -0
- package/src/gate-overlay.test.ts +163 -0
- package/src/gate-overlay.ts +310 -0
- package/src/index.test.ts +5 -25
- package/src/index.ts +8 -20
- package/src/progress.ts +116 -0
- package/src/paste-chips.test.ts +0 -162
- package/src/paste-chips.ts +0 -217
- package/src/thinking.test.ts +0 -251
- package/src/thinking.ts +0 -207
package/src/index.test.ts
CHANGED
|
@@ -1,33 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Smoke tests for pix-pretty (pure lib).
|
|
3
|
+
* UI extension tests moved to pix-display.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
import { describe, expect, it } from "bun:test";
|
|
6
7
|
|
|
7
8
|
describe("pix-pretty", () => {
|
|
8
|
-
it("
|
|
9
|
-
|
|
10
|
-
expect(
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
describe("tool rendering extension", () => {
|
|
14
|
-
it("main extension exports a function", async () => {
|
|
15
|
-
const mainModule = await import("./index");
|
|
16
|
-
expect(mainModule.default).toBeFunction();
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
describe("paste-chips extension", () => {
|
|
21
|
-
it("paste-chips extension exports a function", async () => {
|
|
22
|
-
const pasteChipsModule = await import("./paste-chips");
|
|
23
|
-
expect(pasteChipsModule.default).toBeFunction();
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
describe("thinking extension", () => {
|
|
28
|
-
it("thinking extension exports a function", async () => {
|
|
29
|
-
const thinkingModule = await import("./thinking");
|
|
30
|
-
expect(thinkingModule.default).toBeFunction();
|
|
31
|
-
});
|
|
9
|
+
it("main module exports a function", async () => {
|
|
10
|
+
const mod = await import("./index");
|
|
11
|
+
expect(mod.default).toBeFunction();
|
|
32
12
|
});
|
|
33
13
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,26 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-pretty — Pretty terminal output for pi built-in tools.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* standalone pix-{read,bash,ls,find,grep,edit,write} packages — each
|
|
7
|
-
* self-registers via its own pi extension entry point.
|
|
8
|
-
*
|
|
9
|
-
* Modules:
|
|
10
|
-
* types.ts shared interfaces/types
|
|
11
|
-
* config.ts theme + thresholds
|
|
12
|
-
* ansi.ts ANSI codes, low-contrast fix
|
|
13
|
-
* utils.ts helpers + renderToolError
|
|
14
|
-
* lang.ts language detection
|
|
15
|
-
* icons.ts Nerd Font file-type icons
|
|
16
|
-
* highlight.ts cli-highlight engine + ANSI cache
|
|
17
|
-
* renderers.ts renderFileContent/Bash/Tree/Find/Grep
|
|
18
|
-
* fff.ts Fast File Finder + cursor store + module singleton
|
|
19
|
-
* diff.ts unified diff parser
|
|
20
|
-
* diff-render.ts split/word-level diff renderer
|
|
21
|
-
* resize.ts terminal resize invalidation registry
|
|
22
|
-
* tools/ per-tool registrar helpers (context type)
|
|
23
|
-
* commands/ slash command registrars (fff)
|
|
4
|
+
* Pure rendering library — no Pi lifecycle hooks or extensions.
|
|
5
|
+
* UI features (paste chips, thinking blocks) live in pix-display.
|
|
24
6
|
*/
|
|
25
7
|
|
|
26
8
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
@@ -48,3 +30,9 @@ export default function piPrettyExtension(pi: PiPrettyApi): void {
|
|
|
48
30
|
// Commands become available once pix-grep initialises the finder.
|
|
49
31
|
registerFffCommands(pi, fffState);
|
|
50
32
|
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* piPrettyExtension still exports a default function for packages that
|
|
36
|
+
* import it as an extension (pix-core activates it for theme + FFF).
|
|
37
|
+
* UI extensions (paste-chips, thinking) moved to pix-display.
|
|
38
|
+
*/
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pix-pretty/progress — modal progress overlay that owns input.
|
|
3
|
+
*
|
|
4
|
+
* A focused, non-dismissable spinner overlay. While open it holds input
|
|
5
|
+
* ownership, so keystrokes are swallowed instead of reaching the editor —
|
|
6
|
+
* this prevents out-of-order echo when a long-running command (e.g. an
|
|
7
|
+
* update) competes with the TUI for the event loop.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* const p = openProgress(ctx.ui, "Updating Pi…");
|
|
11
|
+
* p.setLabel("Updating extensions…"); // live status line
|
|
12
|
+
* await doWork();
|
|
13
|
+
* p.close(); // releases input, removes overlay
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
17
|
+
|
|
18
|
+
interface ProgressTheme {
|
|
19
|
+
fg(color: string, text: string): string;
|
|
20
|
+
bg(color: string, text: string): string;
|
|
21
|
+
bold(text: string): string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ProgressComponent {
|
|
25
|
+
render(width: number): string[];
|
|
26
|
+
invalidate(): void;
|
|
27
|
+
handleInput(data: string): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ProgressUI {
|
|
31
|
+
custom<T>(
|
|
32
|
+
cb: (
|
|
33
|
+
tui: { requestRender(): void },
|
|
34
|
+
theme: ProgressTheme,
|
|
35
|
+
kb: unknown,
|
|
36
|
+
done: (v: T) => void,
|
|
37
|
+
) => ProgressComponent,
|
|
38
|
+
opts?: { overlay?: boolean },
|
|
39
|
+
): Promise<T | undefined>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ProgressHandle {
|
|
43
|
+
/** Update the status line shown under the title. */
|
|
44
|
+
setLabel(label: string): void;
|
|
45
|
+
/** Close the overlay and release input ownership. */
|
|
46
|
+
close(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
50
|
+
// 120ms: smooth enough to read as motion, slow enough to barely touch the
|
|
51
|
+
// render queue. The overlay owns input so this isn't competing with echo.
|
|
52
|
+
const SPINNER_INTERVAL_MS = 120;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Open a modal progress overlay. Returns a handle to update the label and
|
|
56
|
+
* close it. The overlay swallows all keystrokes until closed.
|
|
57
|
+
*/
|
|
58
|
+
export function openProgress(
|
|
59
|
+
ui: ProgressUI,
|
|
60
|
+
title: string,
|
|
61
|
+
accent = "accent",
|
|
62
|
+
): ProgressHandle {
|
|
63
|
+
let setLabelImpl: (label: string) => void = () => {};
|
|
64
|
+
let closeImpl: () => void = () => {};
|
|
65
|
+
|
|
66
|
+
ui.custom<void>(
|
|
67
|
+
(tui, theme, _kb, done) => {
|
|
68
|
+
let frame = 0;
|
|
69
|
+
const titleText = new Text(theme.fg(accent, theme.bold(title)), 1, 0);
|
|
70
|
+
const statusText = new Text("", 1, 0);
|
|
71
|
+
|
|
72
|
+
const render = () => {
|
|
73
|
+
statusText.setText(
|
|
74
|
+
`${theme.fg(accent, SPINNER[frame])} ${theme.fg("muted", labelValue)}`,
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
let labelValue = "Working…";
|
|
79
|
+
render();
|
|
80
|
+
|
|
81
|
+
const ticker = setInterval(() => {
|
|
82
|
+
frame = (frame + 1) % SPINNER.length;
|
|
83
|
+
render();
|
|
84
|
+
tui.requestRender();
|
|
85
|
+
}, SPINNER_INTERVAL_MS);
|
|
86
|
+
|
|
87
|
+
const container = new Box(2, 1, (s) => theme.bg("customMessageBg", s));
|
|
88
|
+
container.addChild(titleText);
|
|
89
|
+
container.addChild(statusText);
|
|
90
|
+
|
|
91
|
+
setLabelImpl = (label: string) => {
|
|
92
|
+
labelValue = label;
|
|
93
|
+
render();
|
|
94
|
+
tui.requestRender();
|
|
95
|
+
};
|
|
96
|
+
closeImpl = () => {
|
|
97
|
+
clearInterval(ticker);
|
|
98
|
+
done();
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
render: (w: number) => container.render(w),
|
|
103
|
+
invalidate: () => container.invalidate(),
|
|
104
|
+
// Swallow every keystroke: a focused overlay owns input, so nothing
|
|
105
|
+
// reaches the editor while the update runs.
|
|
106
|
+
handleInput: () => {},
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
{ overlay: true },
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
setLabel: (label) => setLabelImpl(label),
|
|
114
|
+
close: () => closeImpl(),
|
|
115
|
+
};
|
|
116
|
+
}
|
package/src/paste-chips.test.ts
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for paste-chips marker restyling and width safety.
|
|
3
|
-
*
|
|
4
|
-
* Regression: restyling markers can widen lines (e.g. "#1" → "text"),
|
|
5
|
-
* which caused a TUI crash when the restyled line exceeded terminal width.
|
|
6
|
-
* See: pi-crash.log — "Rendered line 38 exceeds terminal width (285 > 283)"
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { describe, expect, it } from "bun:test";
|
|
10
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
-
import { endsWithMarker, restyleMarkers } from "./paste-chips";
|
|
12
|
-
|
|
13
|
-
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
14
|
-
|
|
15
|
-
// ─── restyleMarkers ──────────────────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
describe("paste-chips restyleMarkers", () => {
|
|
18
|
-
describe("text markers", () => {
|
|
19
|
-
it("restyles chars marker to colored icon text chip", () => {
|
|
20
|
-
const result = restyleMarkers("[paste #1 2232 chars]", new Set());
|
|
21
|
-
expect(result).toContain("\x1b[");
|
|
22
|
-
expect(stripAnsi(result)).toBe(" text 2.2k chars");
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("restyles lines marker to colored icon text chip", () => {
|
|
26
|
-
const result = restyleMarkers("[paste #2 +42 lines]", new Set());
|
|
27
|
-
expect(stripAnsi(result)).toBe(" text 42 lines");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("restyles bare marker (no size info) to text chip with id", () => {
|
|
31
|
-
const result = restyleMarkers("[paste #3]", new Set());
|
|
32
|
-
expect(stripAnsi(result)).toBe(" text #3");
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe("image markers", () => {
|
|
37
|
-
it("restyles marker to colored icon image chip when ID is in imageIds", () => {
|
|
38
|
-
const imageIds = new Set([1]);
|
|
39
|
-
const result = restyleMarkers("[paste #1 58 chars]", imageIds);
|
|
40
|
-
expect(result).toContain("\x1b[");
|
|
41
|
-
expect(stripAnsi(result)).toBe(" image #1");
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("does not restyle non-image ID as image", () => {
|
|
45
|
-
const imageIds = new Set([1]);
|
|
46
|
-
const result = restyleMarkers("[paste #2 100 chars]", imageIds);
|
|
47
|
-
expect(stripAnsi(result)).toBe(" text 100 chars");
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
describe("multiple markers in one line", () => {
|
|
52
|
-
it("restyles all markers in a single line", () => {
|
|
53
|
-
const imageIds = new Set([1]);
|
|
54
|
-
const line =
|
|
55
|
-
"before [paste #1 58 chars] middle [paste #2 +10 lines] after";
|
|
56
|
-
const result = restyleMarkers(line, imageIds);
|
|
57
|
-
expect(stripAnsi(result)).toBe(
|
|
58
|
-
"before image #1 middle text 10 lines after",
|
|
59
|
-
);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe("no markers", () => {
|
|
64
|
-
it("returns line unchanged when no markers present", () => {
|
|
65
|
-
const line = "just regular text with no paste markers";
|
|
66
|
-
expect(restyleMarkers(line, new Set())).toBe(line);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("returns empty string unchanged", () => {
|
|
70
|
-
expect(restyleMarkers("", new Set())).toBe("");
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
describe("ANSI-styled markers", () => {
|
|
75
|
-
it("restyles markers embedded in ANSI sequences", () => {
|
|
76
|
-
const line = "\x1b[38;2;84;92;126m[paste #1 500 chars]\x1b[0m";
|
|
77
|
-
const result = restyleMarkers(line, new Set());
|
|
78
|
-
expect(stripAnsi(result)).toBe(" text 500 chars");
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
// ─── Width safety (regression for crash) ─────────────────────────────────────
|
|
84
|
-
|
|
85
|
-
describe("paste-chips width safety", () => {
|
|
86
|
-
it("restyling a chars marker can decrease visible width", () => {
|
|
87
|
-
const original = "[paste #1 2232 chars]";
|
|
88
|
-
const restyled = restyleMarkers(original, new Set());
|
|
89
|
-
expect(visibleWidth(restyled)).toBeLessThan(visibleWidth(original));
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("restyling a lines marker can decrease visible width", () => {
|
|
93
|
-
// "[paste #2 +42 lines]" (20 chars) → "[paste text +42]" (16 chars) = -4 width
|
|
94
|
-
const original = "[paste #2 +42 lines]";
|
|
95
|
-
const restyled = restyleMarkers(original, new Set());
|
|
96
|
-
expect(visibleWidth(restyled)).toBeLessThan(visibleWidth(original));
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("restyling to image label changes width", () => {
|
|
100
|
-
// "[paste #1 58 chars]" (19 chars) → "[paste image #1]" (16 chars)
|
|
101
|
-
const imageIds = new Set([1]);
|
|
102
|
-
const original = "[paste #1 58 chars]";
|
|
103
|
-
const restyled = restyleMarkers(original, imageIds);
|
|
104
|
-
expect(visibleWidth(restyled)).not.toBe(visibleWidth(original));
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("chars marker with large char count compacts metadata", () => {
|
|
108
|
-
const original = "[paste #1 9999 chars]";
|
|
109
|
-
const restyled = restyleMarkers(original, new Set());
|
|
110
|
-
expect(stripAnsi(restyled)).toBe(" text 10k chars");
|
|
111
|
-
expect(visibleWidth(restyled)).toBeLessThan(visibleWidth(original));
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("chars marker with multi-digit ID omits id from text chip", () => {
|
|
115
|
-
const original = "[paste #10 9999 chars]";
|
|
116
|
-
const restyled = restyleMarkers(original, new Set());
|
|
117
|
-
expect(stripAnsi(restyled)).toBe(" text 10k chars");
|
|
118
|
-
expect(visibleWidth(restyled)).toBeLessThan(visibleWidth(original));
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
describe("width behavior", () => {
|
|
122
|
-
it("icon chip stays within terminal width when marker line fits", () => {
|
|
123
|
-
const terminalWidth = 283;
|
|
124
|
-
const marker = "[paste #1 2232 chars]";
|
|
125
|
-
const padding = " ".repeat(terminalWidth - visibleWidth(marker));
|
|
126
|
-
const line = marker + padding;
|
|
127
|
-
|
|
128
|
-
expect(visibleWidth(line)).toBe(terminalWidth);
|
|
129
|
-
|
|
130
|
-
const restyled = restyleMarkers(line, new Set());
|
|
131
|
-
expect(visibleWidth(restyled)).toBeLessThanOrEqual(terminalWidth);
|
|
132
|
-
});
|
|
133
|
-
});
|
|
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
DELETED
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* paste-chips — collapse pasted image paths into Pi paste markers, and
|
|
3
|
-
* re-render all paste markers with type-aware labels.
|
|
4
|
-
*
|
|
5
|
-
* /tmp/pi-clipboard-abc.png → buffer: [paste #1 58 chars]
|
|
6
|
-
* display: [paste image #1]
|
|
7
|
-
*
|
|
8
|
-
* long pasted text → buffer: [paste #2 +42 lines]
|
|
9
|
-
* display: [paste text +42]
|
|
10
|
-
*
|
|
11
|
-
* Atomic deletion + cursor handling come from Pi's marker grammar.
|
|
12
|
-
* On submit, getExpandedText() restores the real path/text for the model.
|
|
13
|
-
* The display rewrite is purely visual (render layer); buffer is untouched.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import type {
|
|
17
|
-
ExtensionAPI,
|
|
18
|
-
KeybindingsManager,
|
|
19
|
-
} from "@earendil-works/pi-coding-agent";
|
|
20
|
-
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
21
|
-
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
22
|
-
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
23
|
-
import { BOLD, FG_BLUE, FG_DIM, FG_GREEN, RST } from "./ansi.js";
|
|
24
|
-
|
|
25
|
-
// Upstream stopped re-exporting `EditorFactory` from the package entry point,
|
|
26
|
-
// so we reconstruct its signature locally from the still-exported primitives.
|
|
27
|
-
// This matches ctx.ui.setEditorComponent's expected factory shape.
|
|
28
|
-
type EditorFactory = (
|
|
29
|
-
tui: TUI,
|
|
30
|
-
theme: EditorTheme,
|
|
31
|
-
keybindings: KeybindingsManager,
|
|
32
|
-
) => CustomEditor;
|
|
33
|
-
|
|
34
|
-
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
35
|
-
|
|
36
|
-
const IMAGE_EXTS = new Set([
|
|
37
|
-
".png",
|
|
38
|
-
".jpg",
|
|
39
|
-
".jpeg",
|
|
40
|
-
".gif",
|
|
41
|
-
".webp",
|
|
42
|
-
".bmp",
|
|
43
|
-
".tif",
|
|
44
|
-
".tiff",
|
|
45
|
-
".heic",
|
|
46
|
-
".heif",
|
|
47
|
-
]);
|
|
48
|
-
|
|
49
|
-
// Group 1 = prefix char (or empty at start), Group 2 = path
|
|
50
|
-
const PATH_RE = /(^|[^\w/])((?:~|\/)[^\s,;'"(){}[\]]+)/g;
|
|
51
|
-
|
|
52
|
-
// Pi's marker grammar — must match exactly for atomic segmentation.
|
|
53
|
-
// e.g. `[paste #1 58 chars]` or `[paste #2 +42 lines]`
|
|
54
|
-
const MARKER_RE = /\[paste #(\d+)( (\+(\d+) lines|(\d+) chars))?\]/g;
|
|
55
|
-
|
|
56
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
57
|
-
|
|
58
|
-
function extOf(p: string): string {
|
|
59
|
-
const dot = p.lastIndexOf(".");
|
|
60
|
-
return dot >= 0 ? p.slice(dot).toLowerCase() : "";
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function isImagePath(p: string): boolean {
|
|
64
|
-
return IMAGE_EXTS.has(extOf(p));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function makeMarker(id: number, charCount: number): string {
|
|
68
|
-
return `[paste #${id} ${charCount} chars]`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ─── Editor internals (Pi's TS-private fields are runtime-public JS) ─────────
|
|
72
|
-
|
|
73
|
-
type EditorInternals = {
|
|
74
|
-
pastes: Map<number, string>;
|
|
75
|
-
pasteCounter: number;
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
// ─── Path → marker rewriter ──────────────────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Walk `text`, for each image path: allocate a new paste ID, register the
|
|
82
|
-
* real path in editor.pastes, remember the ID as an image, and emit a
|
|
83
|
-
* Pi-format marker so deletion is atomic.
|
|
84
|
-
*/
|
|
85
|
-
function replaceImagePaths(
|
|
86
|
-
text: string,
|
|
87
|
-
internals: EditorInternals,
|
|
88
|
-
imageIds: Set<number>,
|
|
89
|
-
): string {
|
|
90
|
-
return text.replace(PATH_RE, (_, prefix: string, rawPath: string) => {
|
|
91
|
-
if (!isImagePath(rawPath)) return prefix + rawPath;
|
|
92
|
-
internals.pasteCounter += 1;
|
|
93
|
-
const id = internals.pasteCounter;
|
|
94
|
-
internals.pastes.set(id, rawPath);
|
|
95
|
-
imageIds.add(id);
|
|
96
|
-
return prefix + makeMarker(id, rawPath.length);
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ─── Display rewriter ────────────────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Re-style every paste marker in a rendered line:
|
|
104
|
-
* image → ` image #N` with blue icon/label
|
|
105
|
-
* text → ` text Lines lines` or ` text Chars chars` with green icon/label
|
|
106
|
-
*
|
|
107
|
-
* Width-preserving is not required — Pi re-wraps each render call.
|
|
108
|
-
*/
|
|
109
|
-
export function restyleMarkers(line: string, imageIds: Set<number>): string {
|
|
110
|
-
return line.replace(
|
|
111
|
-
MARKER_RE,
|
|
112
|
-
(_full, idStr, _g2, _g3, linesStr, charsStr) => {
|
|
113
|
-
const id = Number.parseInt(idStr, 10);
|
|
114
|
-
if (imageIds.has(id)) {
|
|
115
|
-
return chip(FG_BLUE, "", "image", `#${id}`);
|
|
116
|
-
}
|
|
117
|
-
if (linesStr) {
|
|
118
|
-
return chip(FG_GREEN, "", "text", `${linesStr} lines`);
|
|
119
|
-
}
|
|
120
|
-
if (charsStr) {
|
|
121
|
-
return chip(FG_GREEN, "", "text", `${compactNumber(charsStr)} chars`);
|
|
122
|
-
}
|
|
123
|
-
return chip(FG_GREEN, "", "text", `#${id}`);
|
|
124
|
-
},
|
|
125
|
-
);
|
|
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
|
-
|
|
150
|
-
// ─── Custom editor ────────────────────────────────────────────────────────────
|
|
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
|
-
|
|
157
|
-
class ChipEditor extends CustomEditor {
|
|
158
|
-
private readonly imageIds = new Set<number>();
|
|
159
|
-
|
|
160
|
-
constructor(...args: ConstructorParameters<typeof CustomEditor>) {
|
|
161
|
-
super(...args);
|
|
162
|
-
this.patchHandlePaste();
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
override insertTextAtCursor(text: string): void {
|
|
166
|
-
const internals = this as unknown as EditorInternals;
|
|
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
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
override render(width: number): string[] {
|
|
191
|
-
const raw = super.render(width);
|
|
192
|
-
return raw.map((line) => {
|
|
193
|
-
const restyled = restyleMarkers(line, this.imageIds);
|
|
194
|
-
// Restyling may widen lines (e.g. "#1" → "text"), so clamp to width.
|
|
195
|
-
if (visibleWidth(restyled) > width) {
|
|
196
|
-
return truncateToWidth(restyled, width, "");
|
|
197
|
-
}
|
|
198
|
-
return restyled;
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// ─── Extension ────────────────────────────────────────────────────────────────
|
|
204
|
-
|
|
205
|
-
export default function (pi: ExtensionAPI) {
|
|
206
|
-
pi.on("session_start", (_event, ctx) => {
|
|
207
|
-
if (!ctx.hasUI) return;
|
|
208
|
-
const factory: EditorFactory = (tui, theme, keybindings) =>
|
|
209
|
-
new ChipEditor(tui, theme, keybindings);
|
|
210
|
-
ctx.ui.setEditorComponent(factory);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
pi.on("session_shutdown", (_event, ctx) => {
|
|
214
|
-
if (!ctx.hasUI) return;
|
|
215
|
-
ctx.ui.setEditorComponent(undefined);
|
|
216
|
-
});
|
|
217
|
-
}
|