@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +256 -67
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoose/pi-beautify",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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 { 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 attachments: Map<string, Attachment>,
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
- let lines = super.render(width);
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.replaceClipboardPaths();
158
+ this.imageTokens.replaceClipboardPathsInEditor(this, this.tui);
72
159
  }, delay),
73
160
  );
74
161
  }
162
+ }
75
163
 
76
- private deleteImageTokenAtCursor(data: string): boolean {
77
- const keybindings = getKeybindings();
78
- const backward = keybindings.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace");
79
- const forward = keybindings.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete");
80
- if (!backward && !forward) return false;
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
- const editor = this as unknown as {
83
- state: { lines: string[]; cursorLine: number; cursorCol: number };
84
- historyIndex: number;
85
- lastAction: string | null;
86
- pushUndoSnapshot: () => void;
87
- setCursorCol: (col: number) => void;
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
- editor.historyIndex = -1;
94
- editor.lastAction = null;
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
- private findImageTokenDeleteRange(line: string, cursorCol: number, backward: boolean): { start: number; end: number; token: string } | undefined {
105
- for (const match of line.matchAll(TOKEN_LINE_RE)) {
106
- const token = match[0];
107
- const start = match.index;
108
- let end = start + token.length;
109
- if (backward) {
110
- if (start < cursorCol && cursorCol <= end) return { start, end, token };
111
- if (cursorCol === end + 1 && line[end] === " ") return { start, end: end + 1, token };
112
- } else if (start <= cursorCol && cursorCol < end) {
113
- if (line[end] === " ") end += 1;
114
- return { start, end, token };
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
- return undefined;
238
+ this.inner.setText(this.inner.getText() + next);
239
+ this.inner.onChange?.(this.inner.getText());
118
240
  }
119
241
 
120
- private replaceClipboardPaths(): void {
121
- const current = this.getText();
122
- let changed = false;
123
- const next = current.replace(CLIPBOARD_PATH_RE, (path) => {
124
- changed = true;
125
- return this.createImageToken(path);
126
- });
127
- if (changed) {
128
- this.setText(next);
129
- this.tui.requestRender();
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 replaceClipboardPathsInText(text: string): string {
134
- return text.replace(CLIPBOARD_PATH_RE, (path) => this.createImageToken(path));
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 createImageToken(path: string): string {
138
- const token = imageChip(this.nextId++);
139
- this.attachments.set(token, { token, path, mimeType: mimeTypeForPath(path) });
140
- return `${token} `;
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
- ctx.ui.setEditorComponent((tui, theme, keybindings) => new BeautifyEditor(tui, theme, keybindings, attachments, () => ctx.ui.theme));
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