@smoose/pi-beautify 0.1.1 → 0.1.2
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 +247 -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,98 @@ function displayChip(token: string, theme: Theme): string {
|
|
|
30
30
|
return theme.fg("toolDiffAdded", theme.inverse(token));
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
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 {
|
|
34
42
|
private nextId = 1;
|
|
43
|
+
|
|
44
|
+
constructor(private readonly attachments: Map<string, Attachment>) {}
|
|
45
|
+
|
|
46
|
+
renderChips(lines: string[], theme: Theme, width: number): string[] {
|
|
47
|
+
let rendered = lines;
|
|
48
|
+
for (const attachment of this.attachments.values()) {
|
|
49
|
+
rendered = rendered.map((line) => line.replaceAll(attachment.token, displayChip(attachment.token, theme)));
|
|
50
|
+
}
|
|
51
|
+
return rendered.map((line) => truncateToWidth(line, width, ""));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
replaceClipboardPathsInText(text: string): string {
|
|
55
|
+
return text.replace(CLIPBOARD_PATH_RE, (path) => this.createImageToken(path));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
replaceClipboardPathsInEditor(editor: EditorComponent, tui: TUI): void {
|
|
59
|
+
const current = editor.getText();
|
|
60
|
+
let changed = false;
|
|
61
|
+
const next = current.replace(CLIPBOARD_PATH_RE, (path) => {
|
|
62
|
+
changed = true;
|
|
63
|
+
return this.createImageToken(path);
|
|
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 createImageToken(path: string): string {
|
|
111
|
+
const token = imageChip(this.nextId++);
|
|
112
|
+
this.attachments.set(token, { token, path, mimeType: mimeTypeForPath(path) });
|
|
113
|
+
return `${token} `;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
class BeautifyEditor extends CustomEditor {
|
|
35
118
|
private scanTimers: Array<ReturnType<typeof setTimeout>> = [];
|
|
36
119
|
|
|
37
120
|
constructor(
|
|
38
121
|
tui: TUI,
|
|
39
122
|
theme: EditorTheme,
|
|
40
123
|
private readonly appKeybindings: KeybindingsManager,
|
|
41
|
-
private readonly
|
|
124
|
+
private readonly imageTokens: ImageTokenController,
|
|
42
125
|
private readonly getTheme: () => Theme,
|
|
43
126
|
) {
|
|
44
127
|
super(tui, theme, appKeybindings);
|
|
@@ -46,98 +129,188 @@ class BeautifyEditor extends CustomEditor {
|
|
|
46
129
|
|
|
47
130
|
handleInput(data: string): void {
|
|
48
131
|
const isImagePaste = this.appKeybindings.matches(data, "app.clipboard.pasteImage");
|
|
49
|
-
if (this.deleteImageTokenAtCursor(data)) return;
|
|
132
|
+
if (this.imageTokens.deleteImageTokenAtCursor(this, data, this.tui)) return;
|
|
50
133
|
super.handleInput(data);
|
|
51
134
|
if (isImagePaste) this.scheduleClipboardPathScan();
|
|
52
135
|
}
|
|
53
136
|
|
|
54
137
|
insertTextAtCursor(text: string): void {
|
|
55
|
-
super.insertTextAtCursor(this.replaceClipboardPathsInText(text));
|
|
138
|
+
super.insertTextAtCursor(this.imageTokens.replaceClipboardPathsInText(text));
|
|
56
139
|
}
|
|
57
140
|
|
|
58
141
|
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, ""));
|
|
142
|
+
return this.imageTokens.renderChips(super.render(width), this.getTheme(), width);
|
|
65
143
|
}
|
|
66
144
|
|
|
67
145
|
private scheduleClipboardPathScan(): void {
|
|
68
146
|
for (const timer of this.scanTimers) clearTimeout(timer);
|
|
69
147
|
this.scanTimers = [80, 250, 600].map((delay) =>
|
|
70
148
|
setTimeout(() => {
|
|
71
|
-
this.
|
|
149
|
+
this.imageTokens.replaceClipboardPathsInEditor(this, this.tui);
|
|
72
150
|
}, delay),
|
|
73
151
|
);
|
|
74
152
|
}
|
|
153
|
+
}
|
|
75
154
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
155
|
+
class BeautifyEditorWrapper implements EditorComponent {
|
|
156
|
+
actionHandlers = new Map<AppKeybinding, () => void>();
|
|
157
|
+
private scanTimers: Array<ReturnType<typeof setTimeout>> = [];
|
|
158
|
+
private _onSubmit: ((text: string) => void) | undefined;
|
|
159
|
+
private _onChange: ((text: string) => void) | undefined;
|
|
160
|
+
onEscape: (() => void) | undefined;
|
|
161
|
+
onCtrlD: (() => void) | undefined;
|
|
162
|
+
onPasteImage: (() => void) | undefined;
|
|
163
|
+
onExtensionShortcut: ((data: string) => boolean) | undefined;
|
|
81
164
|
|
|
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;
|
|
165
|
+
constructor(
|
|
166
|
+
private readonly inner: EditorComponent,
|
|
167
|
+
private readonly tui: TUI,
|
|
168
|
+
private readonly appKeybindings: KeybindingsManager,
|
|
169
|
+
private readonly imageTokens: ImageTokenController,
|
|
170
|
+
private readonly getTheme: () => Theme,
|
|
171
|
+
) {}
|
|
92
172
|
|
|
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;
|
|
173
|
+
get focused(): boolean {
|
|
174
|
+
return Boolean((this.inner as EditorComponent & { focused?: boolean }).focused);
|
|
102
175
|
}
|
|
103
176
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
177
|
+
set focused(value: boolean) {
|
|
178
|
+
(this.inner as EditorComponent & { focused?: boolean }).focused = value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
get borderColor(): ((str: string) => string) | undefined {
|
|
182
|
+
return this.inner.borderColor;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
set borderColor(value: ((str: string) => string) | undefined) {
|
|
186
|
+
this.inner.borderColor = value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get onSubmit(): ((text: string) => void) | undefined {
|
|
190
|
+
return this._onSubmit;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
set onSubmit(handler: ((text: string) => void) | undefined) {
|
|
194
|
+
this._onSubmit = handler;
|
|
195
|
+
this.inner.onSubmit = handler;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get onChange(): ((text: string) => void) | undefined {
|
|
199
|
+
return this._onChange;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
set onChange(handler: ((text: string) => void) | undefined) {
|
|
203
|
+
this._onChange = handler;
|
|
204
|
+
this.inner.onChange = handler;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
getText(): string {
|
|
208
|
+
return this.inner.getText();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
setText(text: string): void {
|
|
212
|
+
this.inner.setText(text);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
getExpandedText(): string {
|
|
216
|
+
return this.inner.getExpandedText?.() ?? this.inner.getText();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
addToHistory(text: string): void {
|
|
220
|
+
this.inner.addToHistory?.(text);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
insertTextAtCursor(text: string): void {
|
|
224
|
+
const next = this.imageTokens.replaceClipboardPathsInText(text);
|
|
225
|
+
if (this.inner.insertTextAtCursor) {
|
|
226
|
+
this.inner.insertTextAtCursor(next);
|
|
227
|
+
return;
|
|
116
228
|
}
|
|
117
|
-
|
|
229
|
+
this.inner.setText(this.inner.getText() + next);
|
|
230
|
+
this.inner.onChange?.(this.inner.getText());
|
|
118
231
|
}
|
|
119
232
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
233
|
+
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
234
|
+
this.inner.setAutocompleteProvider?.(provider);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
setPaddingX(padding: number): void {
|
|
238
|
+
this.inner.setPaddingX?.(padding);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
setAutocompleteMaxVisible(maxVisible: number): void {
|
|
242
|
+
this.inner.setAutocompleteMaxVisible?.(maxVisible);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
onAction(action: AppKeybinding, handler: () => void): void {
|
|
246
|
+
this.actionHandlers.set(action, handler);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
invalidate(): void {
|
|
250
|
+
this.inner.invalidate?.();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
render(width: number): string[] {
|
|
254
|
+
return this.imageTokens.renderChips(this.inner.render(width), this.getTheme(), width);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
handleInput(data: string): void {
|
|
258
|
+
const isImagePaste = this.appKeybindings.matches(data, "app.clipboard.pasteImage");
|
|
259
|
+
if (this.onExtensionShortcut?.(data)) return;
|
|
260
|
+
if (this.imageTokens.deleteImageTokenAtCursor(this.inner, data, this.tui)) return;
|
|
261
|
+
if (isImagePaste) {
|
|
262
|
+
this.onPasteImage?.();
|
|
263
|
+
this.scheduleClipboardPathScan();
|
|
264
|
+
return;
|
|
130
265
|
}
|
|
266
|
+
if (this.handleAppAction(data)) return;
|
|
267
|
+
this.inner.handleInput(data);
|
|
131
268
|
}
|
|
132
269
|
|
|
133
|
-
private
|
|
134
|
-
|
|
270
|
+
private handleAppAction(data: string): boolean {
|
|
271
|
+
if (this.appKeybindings.matches(data, "app.interrupt")) {
|
|
272
|
+
if (!this.isShowingAutocomplete()) {
|
|
273
|
+
const handler = this.onEscape ?? this.actionHandlers.get("app.interrupt");
|
|
274
|
+
if (handler) {
|
|
275
|
+
handler();
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (this.appKeybindings.matches(data, "app.exit")) {
|
|
283
|
+
if (this.getText().length === 0) {
|
|
284
|
+
const handler = this.onCtrlD ?? this.actionHandlers.get("app.exit");
|
|
285
|
+
if (handler) {
|
|
286
|
+
handler();
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
for (const [action, handler] of this.actionHandlers) {
|
|
293
|
+
if (action !== "app.interrupt" && action !== "app.exit" && this.appKeybindings.matches(data, action)) {
|
|
294
|
+
handler();
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return false;
|
|
135
300
|
}
|
|
136
301
|
|
|
137
|
-
private
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
302
|
+
private isShowingAutocomplete(): boolean {
|
|
303
|
+
const inner = this.inner as EditorComponent & { isShowingAutocomplete?: () => boolean };
|
|
304
|
+
return inner.isShowingAutocomplete?.() ?? false;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private scheduleClipboardPathScan(): void {
|
|
308
|
+
for (const timer of this.scanTimers) clearTimeout(timer);
|
|
309
|
+
this.scanTimers = [80, 250, 600].map((delay) =>
|
|
310
|
+
setTimeout(() => {
|
|
311
|
+
this.imageTokens.replaceClipboardPathsInEditor(this.inner, this.tui);
|
|
312
|
+
}, delay),
|
|
313
|
+
);
|
|
141
314
|
}
|
|
142
315
|
}
|
|
143
316
|
|
|
@@ -170,7 +343,14 @@ export default function piBeautify(pi: ExtensionAPI) {
|
|
|
170
343
|
pi.on("session_start", (_event, ctx) => {
|
|
171
344
|
if (!ctx.hasUI) return;
|
|
172
345
|
attachments.clear();
|
|
173
|
-
|
|
346
|
+
const previousEditorFactory = ctx.ui.getEditorComponent();
|
|
347
|
+
const imageTokens = new ImageTokenController(attachments);
|
|
348
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
349
|
+
if (!previousEditorFactory) {
|
|
350
|
+
return new BeautifyEditor(tui, theme, keybindings, imageTokens, () => ctx.ui.theme);
|
|
351
|
+
}
|
|
352
|
+
return new BeautifyEditorWrapper(previousEditorFactory(tui, theme, keybindings), tui, keybindings, imageTokens, () => ctx.ui.theme);
|
|
353
|
+
});
|
|
174
354
|
ctx.ui.setStatus("pi-beautify", ctx.ui.theme.fg("dim", "beautify"));
|
|
175
355
|
});
|
|
176
356
|
|