@smoose/pi-beautify 0.1.1 → 0.1.3
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/index.ts +256 -67
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { CustomEditor, type ExtensionAPI, type KeybindingsManager, type Theme } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { CustomEditor, type AppKeybinding, type ExtensionAPI, type KeybindingsManager, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
3
|
-
import { getKeybindings, matchesKey, truncateToWidth, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { getKeybindings, matchesKey, truncateToWidth, type AutocompleteProvider, type EditorComponent, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
|
|
4
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
5
|
import { extname } from "node:path";
|
|
6
6
|
|
|
@@ -30,15 +30,107 @@ function displayChip(token: string, theme: Theme): string {
|
|
|
30
30
|
return theme.fg("toolDiffAdded", theme.inverse(token));
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
interface EditorInternals {
|
|
34
|
+
state: { lines: string[]; cursorLine: number; cursorCol: number };
|
|
35
|
+
historyIndex: number;
|
|
36
|
+
lastAction: string | null;
|
|
37
|
+
pushUndoSnapshot: () => void;
|
|
38
|
+
setCursorCol: (col: number) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class ImageTokenController {
|
|
42
|
+
constructor(private readonly attachments: Map<string, Attachment>) {}
|
|
43
|
+
|
|
44
|
+
renderChips(lines: string[], theme: Theme, width: number): string[] {
|
|
45
|
+
let rendered = lines;
|
|
46
|
+
for (const attachment of this.attachments.values()) {
|
|
47
|
+
rendered = rendered.map((line) => line.replaceAll(attachment.token, displayChip(attachment.token, theme)));
|
|
48
|
+
}
|
|
49
|
+
return rendered.map((line) => truncateToWidth(line, width, ""));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
replaceClipboardPathsInText(text: string, existingText = ""): string {
|
|
53
|
+
const usedIds = this.collectUsedIds(`${existingText}\n${text}`);
|
|
54
|
+
return text.replace(CLIPBOARD_PATH_RE, (path) => this.createImageToken(path, usedIds));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
replaceClipboardPathsInEditor(editor: EditorComponent, tui: TUI): void {
|
|
58
|
+
const current = editor.getText();
|
|
59
|
+
const usedIds = this.collectUsedIds(current);
|
|
60
|
+
let changed = false;
|
|
61
|
+
const next = current.replace(CLIPBOARD_PATH_RE, (path) => {
|
|
62
|
+
changed = true;
|
|
63
|
+
return this.createImageToken(path, usedIds);
|
|
64
|
+
});
|
|
65
|
+
if (!changed) return;
|
|
66
|
+
editor.setText(next);
|
|
67
|
+
tui.requestRender();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
deleteImageTokenAtCursor(editor: EditorComponent, data: string, tui: TUI): boolean {
|
|
71
|
+
const keybindings = getKeybindings();
|
|
72
|
+
const backward = keybindings.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace");
|
|
73
|
+
const forward = keybindings.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete");
|
|
74
|
+
if (!backward && !forward) return false;
|
|
75
|
+
|
|
76
|
+
const writableEditor = editor as unknown as Partial<EditorInternals>;
|
|
77
|
+
if (!writableEditor.state || !writableEditor.pushUndoSnapshot || !writableEditor.setCursorCol) return false;
|
|
78
|
+
|
|
79
|
+
const line = writableEditor.state.lines[writableEditor.state.cursorLine] || "";
|
|
80
|
+
const range = this.findImageTokenDeleteRange(line, writableEditor.state.cursorCol, backward);
|
|
81
|
+
if (!range) return false;
|
|
82
|
+
|
|
83
|
+
writableEditor.historyIndex = -1;
|
|
84
|
+
writableEditor.lastAction = null;
|
|
85
|
+
writableEditor.pushUndoSnapshot();
|
|
86
|
+
writableEditor.state.lines[writableEditor.state.cursorLine] = line.slice(0, range.start) + line.slice(range.end);
|
|
87
|
+
writableEditor.setCursorCol(range.start);
|
|
88
|
+
this.attachments.delete(range.token);
|
|
89
|
+
editor.onChange?.(editor.getText());
|
|
90
|
+
tui.requestRender();
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private findImageTokenDeleteRange(line: string, cursorCol: number, backward: boolean): { start: number; end: number; token: string } | undefined {
|
|
95
|
+
for (const match of line.matchAll(TOKEN_LINE_RE)) {
|
|
96
|
+
const token = match[0];
|
|
97
|
+
const start = match.index;
|
|
98
|
+
let end = start + token.length;
|
|
99
|
+
if (backward) {
|
|
100
|
+
if (start < cursorCol && cursorCol <= end) return { start, end, token };
|
|
101
|
+
if (cursorCol === end + 1 && line[end] === " ") return { start, end: end + 1, token };
|
|
102
|
+
} else if (start <= cursorCol && cursorCol < end) {
|
|
103
|
+
if (line[end] === " ") end += 1;
|
|
104
|
+
return { start, end, token };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private collectUsedIds(text: string): Set<number> {
|
|
111
|
+
const usedIds = new Set<number>();
|
|
112
|
+
for (const match of text.matchAll(TOKEN_RE)) usedIds.add(Number(match[1]));
|
|
113
|
+
return usedIds;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private createImageToken(path: string, usedIds: Set<number>): string {
|
|
117
|
+
let id = 1;
|
|
118
|
+
while (usedIds.has(id)) id++;
|
|
119
|
+
usedIds.add(id);
|
|
120
|
+
const token = imageChip(id);
|
|
121
|
+
this.attachments.set(token, { token, path, mimeType: mimeTypeForPath(path) });
|
|
122
|
+
return `${token} `;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
33
126
|
class BeautifyEditor extends CustomEditor {
|
|
34
|
-
private nextId = 1;
|
|
35
127
|
private scanTimers: Array<ReturnType<typeof setTimeout>> = [];
|
|
36
128
|
|
|
37
129
|
constructor(
|
|
38
130
|
tui: TUI,
|
|
39
131
|
theme: EditorTheme,
|
|
40
132
|
private readonly appKeybindings: KeybindingsManager,
|
|
41
|
-
private readonly
|
|
133
|
+
private readonly imageTokens: ImageTokenController,
|
|
42
134
|
private readonly getTheme: () => Theme,
|
|
43
135
|
) {
|
|
44
136
|
super(tui, theme, appKeybindings);
|
|
@@ -46,98 +138,188 @@ class BeautifyEditor extends CustomEditor {
|
|
|
46
138
|
|
|
47
139
|
handleInput(data: string): void {
|
|
48
140
|
const isImagePaste = this.appKeybindings.matches(data, "app.clipboard.pasteImage");
|
|
49
|
-
if (this.deleteImageTokenAtCursor(data)) return;
|
|
141
|
+
if (this.imageTokens.deleteImageTokenAtCursor(this, data, this.tui)) return;
|
|
50
142
|
super.handleInput(data);
|
|
51
143
|
if (isImagePaste) this.scheduleClipboardPathScan();
|
|
52
144
|
}
|
|
53
145
|
|
|
54
146
|
insertTextAtCursor(text: string): void {
|
|
55
|
-
super.insertTextAtCursor(this.replaceClipboardPathsInText(text));
|
|
147
|
+
super.insertTextAtCursor(this.imageTokens.replaceClipboardPathsInText(text, this.getText()));
|
|
56
148
|
}
|
|
57
149
|
|
|
58
150
|
render(width: number): string[] {
|
|
59
|
-
|
|
60
|
-
const currentTheme = this.getTheme();
|
|
61
|
-
for (const attachment of this.attachments.values()) {
|
|
62
|
-
lines = lines.map((line) => line.replaceAll(attachment.token, displayChip(attachment.token, currentTheme)));
|
|
63
|
-
}
|
|
64
|
-
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
151
|
+
return this.imageTokens.renderChips(super.render(width), this.getTheme(), width);
|
|
65
152
|
}
|
|
66
153
|
|
|
67
154
|
private scheduleClipboardPathScan(): void {
|
|
68
155
|
for (const timer of this.scanTimers) clearTimeout(timer);
|
|
69
156
|
this.scanTimers = [80, 250, 600].map((delay) =>
|
|
70
157
|
setTimeout(() => {
|
|
71
|
-
this.
|
|
158
|
+
this.imageTokens.replaceClipboardPathsInEditor(this, this.tui);
|
|
72
159
|
}, delay),
|
|
73
160
|
);
|
|
74
161
|
}
|
|
162
|
+
}
|
|
75
163
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
164
|
+
class BeautifyEditorWrapper implements EditorComponent {
|
|
165
|
+
actionHandlers = new Map<AppKeybinding, () => void>();
|
|
166
|
+
private scanTimers: Array<ReturnType<typeof setTimeout>> = [];
|
|
167
|
+
private _onSubmit: ((text: string) => void) | undefined;
|
|
168
|
+
private _onChange: ((text: string) => void) | undefined;
|
|
169
|
+
onEscape: (() => void) | undefined;
|
|
170
|
+
onCtrlD: (() => void) | undefined;
|
|
171
|
+
onPasteImage: (() => void) | undefined;
|
|
172
|
+
onExtensionShortcut: ((data: string) => boolean) | undefined;
|
|
81
173
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const line = editor.state.lines[editor.state.cursorLine] || "";
|
|
90
|
-
const range = this.findImageTokenDeleteRange(line, editor.state.cursorCol, backward);
|
|
91
|
-
if (!range) return false;
|
|
174
|
+
constructor(
|
|
175
|
+
private readonly inner: EditorComponent,
|
|
176
|
+
private readonly tui: TUI,
|
|
177
|
+
private readonly appKeybindings: KeybindingsManager,
|
|
178
|
+
private readonly imageTokens: ImageTokenController,
|
|
179
|
+
private readonly getTheme: () => Theme,
|
|
180
|
+
) {}
|
|
92
181
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
editor.pushUndoSnapshot();
|
|
96
|
-
editor.state.lines[editor.state.cursorLine] = line.slice(0, range.start) + line.slice(range.end);
|
|
97
|
-
editor.setCursorCol(range.start);
|
|
98
|
-
this.attachments.delete(range.token);
|
|
99
|
-
if (this.onChange) this.onChange(this.getText());
|
|
100
|
-
this.tui.requestRender();
|
|
101
|
-
return true;
|
|
182
|
+
get focused(): boolean {
|
|
183
|
+
return Boolean((this.inner as EditorComponent & { focused?: boolean }).focused);
|
|
102
184
|
}
|
|
103
185
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
186
|
+
set focused(value: boolean) {
|
|
187
|
+
(this.inner as EditorComponent & { focused?: boolean }).focused = value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
get borderColor(): ((str: string) => string) | undefined {
|
|
191
|
+
return this.inner.borderColor;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
set borderColor(value: ((str: string) => string) | undefined) {
|
|
195
|
+
this.inner.borderColor = value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get onSubmit(): ((text: string) => void) | undefined {
|
|
199
|
+
return this._onSubmit;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
set onSubmit(handler: ((text: string) => void) | undefined) {
|
|
203
|
+
this._onSubmit = handler;
|
|
204
|
+
this.inner.onSubmit = handler;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
get onChange(): ((text: string) => void) | undefined {
|
|
208
|
+
return this._onChange;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
set onChange(handler: ((text: string) => void) | undefined) {
|
|
212
|
+
this._onChange = handler;
|
|
213
|
+
this.inner.onChange = handler;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
getText(): string {
|
|
217
|
+
return this.inner.getText();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
setText(text: string): void {
|
|
221
|
+
this.inner.setText(text);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
getExpandedText(): string {
|
|
225
|
+
return this.inner.getExpandedText?.() ?? this.inner.getText();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
addToHistory(text: string): void {
|
|
229
|
+
this.inner.addToHistory?.(text);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
insertTextAtCursor(text: string): void {
|
|
233
|
+
const next = this.imageTokens.replaceClipboardPathsInText(text, this.inner.getText());
|
|
234
|
+
if (this.inner.insertTextAtCursor) {
|
|
235
|
+
this.inner.insertTextAtCursor(next);
|
|
236
|
+
return;
|
|
116
237
|
}
|
|
117
|
-
|
|
238
|
+
this.inner.setText(this.inner.getText() + next);
|
|
239
|
+
this.inner.onChange?.(this.inner.getText());
|
|
118
240
|
}
|
|
119
241
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
242
|
+
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
243
|
+
this.inner.setAutocompleteProvider?.(provider);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
setPaddingX(padding: number): void {
|
|
247
|
+
this.inner.setPaddingX?.(padding);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
setAutocompleteMaxVisible(maxVisible: number): void {
|
|
251
|
+
this.inner.setAutocompleteMaxVisible?.(maxVisible);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
onAction(action: AppKeybinding, handler: () => void): void {
|
|
255
|
+
this.actionHandlers.set(action, handler);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
invalidate(): void {
|
|
259
|
+
this.inner.invalidate?.();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
render(width: number): string[] {
|
|
263
|
+
return this.imageTokens.renderChips(this.inner.render(width), this.getTheme(), width);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
handleInput(data: string): void {
|
|
267
|
+
const isImagePaste = this.appKeybindings.matches(data, "app.clipboard.pasteImage");
|
|
268
|
+
if (this.onExtensionShortcut?.(data)) return;
|
|
269
|
+
if (this.imageTokens.deleteImageTokenAtCursor(this.inner, data, this.tui)) return;
|
|
270
|
+
if (isImagePaste) {
|
|
271
|
+
this.onPasteImage?.();
|
|
272
|
+
this.scheduleClipboardPathScan();
|
|
273
|
+
return;
|
|
130
274
|
}
|
|
275
|
+
if (this.handleAppAction(data)) return;
|
|
276
|
+
this.inner.handleInput(data);
|
|
131
277
|
}
|
|
132
278
|
|
|
133
|
-
private
|
|
134
|
-
|
|
279
|
+
private handleAppAction(data: string): boolean {
|
|
280
|
+
if (this.appKeybindings.matches(data, "app.interrupt")) {
|
|
281
|
+
if (!this.isShowingAutocomplete()) {
|
|
282
|
+
const handler = this.onEscape ?? this.actionHandlers.get("app.interrupt");
|
|
283
|
+
if (handler) {
|
|
284
|
+
handler();
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (this.appKeybindings.matches(data, "app.exit")) {
|
|
292
|
+
if (this.getText().length === 0) {
|
|
293
|
+
const handler = this.onCtrlD ?? this.actionHandlers.get("app.exit");
|
|
294
|
+
if (handler) {
|
|
295
|
+
handler();
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const [action, handler] of this.actionHandlers) {
|
|
302
|
+
if (action !== "app.interrupt" && action !== "app.exit" && this.appKeybindings.matches(data, action)) {
|
|
303
|
+
handler();
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return false;
|
|
135
309
|
}
|
|
136
310
|
|
|
137
|
-
private
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
311
|
+
private isShowingAutocomplete(): boolean {
|
|
312
|
+
const inner = this.inner as EditorComponent & { isShowingAutocomplete?: () => boolean };
|
|
313
|
+
return inner.isShowingAutocomplete?.() ?? false;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private scheduleClipboardPathScan(): void {
|
|
317
|
+
for (const timer of this.scanTimers) clearTimeout(timer);
|
|
318
|
+
this.scanTimers = [80, 250, 600].map((delay) =>
|
|
319
|
+
setTimeout(() => {
|
|
320
|
+
this.imageTokens.replaceClipboardPathsInEditor(this.inner, this.tui);
|
|
321
|
+
}, delay),
|
|
322
|
+
);
|
|
141
323
|
}
|
|
142
324
|
}
|
|
143
325
|
|
|
@@ -170,7 +352,14 @@ export default function piBeautify(pi: ExtensionAPI) {
|
|
|
170
352
|
pi.on("session_start", (_event, ctx) => {
|
|
171
353
|
if (!ctx.hasUI) return;
|
|
172
354
|
attachments.clear();
|
|
173
|
-
|
|
355
|
+
const previousEditorFactory = ctx.ui.getEditorComponent();
|
|
356
|
+
const imageTokens = new ImageTokenController(attachments);
|
|
357
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
358
|
+
if (!previousEditorFactory) {
|
|
359
|
+
return new BeautifyEditor(tui, theme, keybindings, imageTokens, () => ctx.ui.theme);
|
|
360
|
+
}
|
|
361
|
+
return new BeautifyEditorWrapper(previousEditorFactory(tui, theme, keybindings), tui, keybindings, imageTokens, () => ctx.ui.theme);
|
|
362
|
+
});
|
|
174
363
|
ctx.ui.setStatus("pi-beautify", ctx.ui.theme.fg("dim", "beautify"));
|
|
175
364
|
});
|
|
176
365
|
|