@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +247 -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.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 { 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
- 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 {
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 attachments: Map<string, Attachment>,
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
- 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, ""));
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.replaceClipboardPaths();
149
+ this.imageTokens.replaceClipboardPathsInEditor(this, this.tui);
72
150
  }, delay),
73
151
  );
74
152
  }
153
+ }
75
154
 
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;
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
- 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;
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
- 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;
173
+ get focused(): boolean {
174
+ return Boolean((this.inner as EditorComponent & { focused?: boolean }).focused);
102
175
  }
103
176
 
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
- }
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
- return undefined;
229
+ this.inner.setText(this.inner.getText() + next);
230
+ this.inner.onChange?.(this.inner.getText());
118
231
  }
119
232
 
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();
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 replaceClipboardPathsInText(text: string): string {
134
- return text.replace(CLIPBOARD_PATH_RE, (path) => this.createImageToken(path));
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 createImageToken(path: string): string {
138
- const token = imageChip(this.nextId++);
139
- this.attachments.set(token, { token, path, mimeType: mimeTypeForPath(path) });
140
- return `${token} `;
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
- 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
+ });
174
354
  ctx.ui.setStatus("pi-beautify", ctx.ui.theme.fg("dim", "beautify"));
175
355
  });
176
356