@smoose/pi-beautify 0.1.0 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +262 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoose/pi-beautify",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Small visual polish extensions for pi coding agent",
5
5
  "type": "module",
6
6
  "files": [
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 { 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
 
@@ -12,6 +12,7 @@ interface Attachment {
12
12
 
13
13
  const CLIPBOARD_PATH_RE = /(?:[^\s"'`<>]+[\\/])?pi-clipboard-[0-9a-f-]+\.(?:png|jpe?g|webp|gif)/gi;
14
14
  const TOKEN_RE = /\[image(\d+)\]/g;
15
+ const TOKEN_LINE_RE = /\[image\d+\]/g;
15
16
 
16
17
  function mimeTypeForPath(path: string): string {
17
18
  const ext = extname(path).toLowerCase();
@@ -29,15 +30,98 @@ function displayChip(token: string, theme: Theme): string {
29
30
  return theme.fg("toolDiffAdded", theme.inverse(token));
30
31
  }
31
32
 
32
- class BeautifyEditor extends CustomEditor {
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 {
33
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 {
34
118
  private scanTimers: Array<ReturnType<typeof setTimeout>> = [];
35
119
 
36
120
  constructor(
37
121
  tui: TUI,
38
122
  theme: EditorTheme,
39
123
  private readonly appKeybindings: KeybindingsManager,
40
- private readonly attachments: Map<string, Attachment>,
124
+ private readonly imageTokens: ImageTokenController,
41
125
  private readonly getTheme: () => Theme,
42
126
  ) {
43
127
  super(tui, theme, appKeybindings);
@@ -45,41 +129,188 @@ class BeautifyEditor extends CustomEditor {
45
129
 
46
130
  handleInput(data: string): void {
47
131
  const isImagePaste = this.appKeybindings.matches(data, "app.clipboard.pasteImage");
132
+ if (this.imageTokens.deleteImageTokenAtCursor(this, data, this.tui)) return;
48
133
  super.handleInput(data);
49
134
  if (isImagePaste) this.scheduleClipboardPathScan();
50
135
  }
51
136
 
137
+ insertTextAtCursor(text: string): void {
138
+ super.insertTextAtCursor(this.imageTokens.replaceClipboardPathsInText(text));
139
+ }
140
+
52
141
  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, ""));
142
+ return this.imageTokens.renderChips(super.render(width), this.getTheme(), width);
59
143
  }
60
144
 
61
145
  private scheduleClipboardPathScan(): void {
62
146
  for (const timer of this.scanTimers) clearTimeout(timer);
63
147
  this.scanTimers = [80, 250, 600].map((delay) =>
64
148
  setTimeout(() => {
65
- this.replaceClipboardPaths();
149
+ this.imageTokens.replaceClipboardPathsInEditor(this, this.tui);
66
150
  }, delay),
67
151
  );
68
152
  }
153
+ }
69
154
 
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();
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;
164
+
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
+ ) {}
172
+
173
+ get focused(): boolean {
174
+ return Boolean((this.inner as EditorComponent & { focused?: boolean }).focused);
175
+ }
176
+
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;
228
+ }
229
+ this.inner.setText(this.inner.getText() + next);
230
+ this.inner.onChange?.(this.inner.getText());
231
+ }
232
+
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;
265
+ }
266
+ if (this.handleAppAction(data)) return;
267
+ this.inner.handleInput(data);
268
+ }
269
+
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
+ }
82
297
  }
298
+
299
+ return false;
300
+ }
301
+
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
+ );
83
314
  }
84
315
  }
85
316
 
@@ -112,7 +343,14 @@ export default function piBeautify(pi: ExtensionAPI) {
112
343
  pi.on("session_start", (_event, ctx) => {
113
344
  if (!ctx.hasUI) return;
114
345
  attachments.clear();
115
- ctx.ui.setEditorComponent((tui, theme, keybindings) => new BeautifyEditor(tui, theme, keybindings, attachments, () => ctx.ui.theme));
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
+ });
116
354
  ctx.ui.setStatus("pi-beautify", ctx.ui.theme.fg("dim", "beautify"));
117
355
  });
118
356