@smoose/pi-beautify 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 +13 -0
  2. package/package.json +43 -0
  3. package/src/index.ts +143 -0
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # pi-beautify
2
+
3
+ A small pi extension for visual polish.
4
+
5
+ ## Clipboard image chips
6
+
7
+ Pi currently pastes clipboard images as long temporary file paths. This extension replaces newly pasted `pi-clipboard-*` paths in the editor with compact chips like `[image:1]`, then turns those chips back into attached image content before the prompt is sent.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pi install npm:@smoose/pi-beautify
13
+ ```
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@smoose/pi-beautify",
3
+ "version": "0.1.0",
4
+ "description": "Small visual polish extensions for pi coding agent",
5
+ "type": "module",
6
+ "files": [
7
+ "src/**/*.ts",
8
+ "README.md",
9
+ "package.json"
10
+ ],
11
+ "keywords": [
12
+ "pi-package",
13
+ "pi",
14
+ "extension",
15
+ "beautify"
16
+ ],
17
+ "license": "MIT",
18
+ "pi": {
19
+ "extensions": [
20
+ "./src/index.ts"
21
+ ]
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/smoosex/pi-beautify.git"
26
+ },
27
+ "homepage": "https://github.com/smoosex/pi-beautify",
28
+ "bugs": {
29
+ "url": "https://github.com/smoosex/pi-beautify/issues"
30
+ },
31
+ "peerDependencies": {
32
+ "@earendil-works/pi-ai": "*",
33
+ "@earendil-works/pi-coding-agent": "*",
34
+ "@earendil-works/pi-tui": "*"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^25.9.1",
38
+ "typescript": "^6.0.3"
39
+ },
40
+ "scripts": {
41
+ "typecheck": "tsc --noEmit"
42
+ }
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { CustomEditor, type ExtensionAPI, type KeybindingsManager, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { ImageContent } from "@earendil-works/pi-ai";
3
+ import { truncateToWidth, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { extname } from "node:path";
6
+
7
+ interface Attachment {
8
+ token: string;
9
+ path: string;
10
+ mimeType: string;
11
+ }
12
+
13
+ const CLIPBOARD_PATH_RE = /(?:[^\s"'`<>]+[\\/])?pi-clipboard-[0-9a-f-]+\.(?:png|jpe?g|webp|gif)/gi;
14
+ const TOKEN_RE = /\[image(\d+)\]/g;
15
+
16
+ function mimeTypeForPath(path: string): string {
17
+ const ext = extname(path).toLowerCase();
18
+ if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
19
+ if (ext === ".webp") return "image/webp";
20
+ if (ext === ".gif") return "image/gif";
21
+ return "image/png";
22
+ }
23
+
24
+ function imageChip(id: number): string {
25
+ return `[image${id}]`;
26
+ }
27
+
28
+ function displayChip(token: string, theme: Theme): string {
29
+ return theme.fg("toolDiffAdded", theme.inverse(token));
30
+ }
31
+
32
+ class BeautifyEditor extends CustomEditor {
33
+ private nextId = 1;
34
+ private scanTimers: Array<ReturnType<typeof setTimeout>> = [];
35
+
36
+ constructor(
37
+ tui: TUI,
38
+ theme: EditorTheme,
39
+ private readonly appKeybindings: KeybindingsManager,
40
+ private readonly attachments: Map<string, Attachment>,
41
+ private readonly getTheme: () => Theme,
42
+ ) {
43
+ super(tui, theme, appKeybindings);
44
+ }
45
+
46
+ handleInput(data: string): void {
47
+ const isImagePaste = this.appKeybindings.matches(data, "app.clipboard.pasteImage");
48
+ super.handleInput(data);
49
+ if (isImagePaste) this.scheduleClipboardPathScan();
50
+ }
51
+
52
+ render(width: number): string[] {
53
+ let lines = super.render(width);
54
+ const currentTheme = this.getTheme();
55
+ for (const attachment of this.attachments.values()) {
56
+ lines = lines.map((line) => line.replaceAll(attachment.token, displayChip(attachment.token, currentTheme)));
57
+ }
58
+ return lines.map((line) => truncateToWidth(line, width, ""));
59
+ }
60
+
61
+ private scheduleClipboardPathScan(): void {
62
+ for (const timer of this.scanTimers) clearTimeout(timer);
63
+ this.scanTimers = [80, 250, 600].map((delay) =>
64
+ setTimeout(() => {
65
+ this.replaceClipboardPaths();
66
+ }, delay),
67
+ );
68
+ }
69
+
70
+ private replaceClipboardPaths(): void {
71
+ const current = this.getText();
72
+ let changed = false;
73
+ const next = current.replace(CLIPBOARD_PATH_RE, (path) => {
74
+ const token = imageChip(this.nextId++);
75
+ this.attachments.set(token, { token, path, mimeType: mimeTypeForPath(path) });
76
+ changed = true;
77
+ return `${token} `;
78
+ });
79
+ if (changed) {
80
+ this.setText(next);
81
+ this.tui.requestRender();
82
+ }
83
+ }
84
+ }
85
+
86
+ function collectImageAttachments(text: string, attachments: Map<string, Attachment>): Attachment[] {
87
+ const selected: Attachment[] = [];
88
+ const seen = new Set<string>();
89
+ for (const match of text.matchAll(TOKEN_RE)) {
90
+ const token = imageChip(Number(match[1]));
91
+ if (seen.has(token)) continue;
92
+ const attachment = attachments.get(token);
93
+ if (!attachment) continue;
94
+ seen.add(token);
95
+ selected.push(attachment);
96
+ }
97
+ return selected;
98
+ }
99
+
100
+ function toImageContent(attachment: Attachment): ImageContent | undefined {
101
+ if (!existsSync(attachment.path)) return undefined;
102
+ return {
103
+ type: "image",
104
+ data: readFileSync(attachment.path).toString("base64"),
105
+ mimeType: attachment.mimeType,
106
+ };
107
+ }
108
+
109
+ export default function piBeautify(pi: ExtensionAPI) {
110
+ const attachments = new Map<string, Attachment>();
111
+
112
+ pi.on("session_start", (_event, ctx) => {
113
+ if (!ctx.hasUI) return;
114
+ attachments.clear();
115
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => new BeautifyEditor(tui, theme, keybindings, attachments, () => ctx.ui.theme));
116
+ ctx.ui.setStatus("pi-beautify", ctx.ui.theme.fg("dim", "beautify"));
117
+ });
118
+
119
+ pi.on("session_shutdown", (_event, ctx) => {
120
+ attachments.clear();
121
+ if (ctx.hasUI) ctx.ui.setStatus("pi-beautify", undefined);
122
+ });
123
+
124
+ pi.on("input", async (event, ctx) => {
125
+ const selected = collectImageAttachments(event.text, attachments);
126
+ if (selected.length === 0) return { action: "continue" };
127
+
128
+ const converted = selected.map(toImageContent).filter((image): image is ImageContent => image !== undefined);
129
+ if (converted.length === 0) {
130
+ if (ctx.hasUI) ctx.ui.notify("pi-beautify: image file disappeared before submit", "warning");
131
+ return { action: "continue" };
132
+ }
133
+
134
+ for (const attachment of selected) attachments.delete(attachment.token);
135
+
136
+ const text = event.text.replace(TOKEN_RE, (_full, id) => `[attached image ${id}]`);
137
+ return {
138
+ action: "transform",
139
+ text,
140
+ images: [...(event.images ?? []), ...converted],
141
+ };
142
+ });
143
+ }