@fresh-editor/fresh-editor 0.1.10 → 0.1.12

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.
@@ -23,7 +23,7 @@
23
23
  "hasInstallScript": true,
24
24
  "license": "GPL-2.0",
25
25
  "name": "@fresh-editor/fresh-editor",
26
- "version": "0.1.10"
26
+ "version": "0.1.12"
27
27
  },
28
28
  "node_modules/@isaacs/balanced-match": {
29
29
  "engines": {
@@ -896,5 +896,5 @@
896
896
  }
897
897
  },
898
898
  "requires": true,
899
- "version": "0.1.10"
899
+ "version": "0.1.12"
900
900
  }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "artifactDownloadUrl": "https://github.com/sinelaw/fresh/releases/download/v0.1.10",
2
+ "artifactDownloadUrl": "https://github.com/sinelaw/fresh/releases/download/v0.1.12",
3
3
  "author": "Noam Lewis",
4
4
  "bin": {
5
5
  "fresh": "run-fresh.js"
@@ -92,7 +92,7 @@
92
92
  "zipExt": ".tar.xz"
93
93
  }
94
94
  },
95
- "version": "0.1.10",
95
+ "version": "0.1.12",
96
96
  "volta": {
97
97
  "node": "18.14.1",
98
98
  "npm": "9.5.0"
@@ -0,0 +1,768 @@
1
+ /// <reference path="../types/fresh.d.ts" />
2
+
3
+ /**
4
+ * Calculator Plugin for Fresh Editor
5
+ *
6
+ * A sleek visual calculator with:
7
+ * - Mouse-clickable buttons (anywhere in button area)
8
+ * - Keyboard input support
9
+ * - Expression parsing with parentheses and basic arithmetic
10
+ * - Modern calculator styling with ANSI colors
11
+ * - Compact fixed-size layout centered in view
12
+ */
13
+
14
+ // ANSI color codes
15
+ const C = {
16
+ RESET: "\x1b[0m",
17
+ BOLD: "\x1b[1m",
18
+ DIM: "\x1b[2m",
19
+ // Colors
20
+ RED: "\x1b[31m",
21
+ GREEN: "\x1b[32m",
22
+ YELLOW: "\x1b[33m",
23
+ BLUE: "\x1b[34m",
24
+ MAGENTA: "\x1b[35m",
25
+ CYAN: "\x1b[36m",
26
+ WHITE: "\x1b[37m",
27
+ BRIGHT_RED: "\x1b[91m",
28
+ BRIGHT_GREEN: "\x1b[92m",
29
+ BRIGHT_YELLOW: "\x1b[93m",
30
+ BRIGHT_BLUE: "\x1b[94m",
31
+ BRIGHT_MAGENTA: "\x1b[95m",
32
+ BRIGHT_CYAN: "\x1b[96m",
33
+ // Backgrounds
34
+ BG_BLACK: "\x1b[40m",
35
+ BG_RED: "\x1b[41m",
36
+ BG_GREEN: "\x1b[42m",
37
+ BG_YELLOW: "\x1b[43m",
38
+ BG_BLUE: "\x1b[44m",
39
+ BG_MAGENTA: "\x1b[45m",
40
+ BG_CYAN: "\x1b[46m",
41
+ BG_WHITE: "\x1b[47m",
42
+ BG_BRIGHT_BLACK: "\x1b[100m",
43
+ };
44
+
45
+ // Calculator state
46
+ interface CalculatorState {
47
+ expression: string;
48
+ result: string;
49
+ error: string;
50
+ bufferId: number;
51
+ splitId: number;
52
+ lastViewport: ViewportInfo | null;
53
+ }
54
+
55
+ const state: CalculatorState = {
56
+ expression: "",
57
+ result: "0",
58
+ error: "",
59
+ bufferId: 0,
60
+ splitId: 0,
61
+ lastViewport: null,
62
+ };
63
+
64
+ // Cache the layout so it doesn't jump around
65
+ let cachedLayout: LayoutMetrics | null = null;
66
+
67
+ // Track hovered button for visual feedback
68
+ let hoveredButton: { row: number; col: number } | null = null;
69
+
70
+ // Track if copy button is hovered
71
+ let copyButtonHovered = false;
72
+
73
+ // Button definitions
74
+ interface Button {
75
+ label: string;
76
+ action: string;
77
+ type: "number" | "operator" | "function" | "clear" | "equals";
78
+ }
79
+
80
+ const BUTTON_LAYOUT: Button[][] = [
81
+ [
82
+ { label: "C", action: "clear", type: "clear" },
83
+ { label: "(", action: "(", type: "function" },
84
+ { label: ")", action: ")", type: "function" },
85
+ { label: "^", action: "^", type: "operator" },
86
+ { label: "÷", action: "/", type: "operator" },
87
+ ],
88
+ [
89
+ { label: "sqrt", action: "sqrt(", type: "function" },
90
+ { label: "ln", action: "ln(", type: "function" },
91
+ { label: "log", action: "log(", type: "function" },
92
+ { label: "π", action: "pi", type: "number" },
93
+ { label: "×", action: "*", type: "operator" },
94
+ ],
95
+ [
96
+ { label: "sin", action: "sin(", type: "function" },
97
+ { label: "cos", action: "cos(", type: "function" },
98
+ { label: "tan", action: "tan(", type: "function" },
99
+ { label: "e", action: "e", type: "number" },
100
+ { label: "-", action: "-", type: "operator" },
101
+ ],
102
+ [
103
+ { label: "7", action: "7", type: "number" },
104
+ { label: "8", action: "8", type: "number" },
105
+ { label: "9", action: "9", type: "number" },
106
+ { label: "⌫", action: "backspace", type: "clear" },
107
+ { label: "+", action: "+", type: "operator" },
108
+ ],
109
+ [
110
+ { label: "4", action: "4", type: "number" },
111
+ { label: "5", action: "5", type: "number" },
112
+ { label: "6", action: "6", type: "number" },
113
+ { label: "±", action: "negate", type: "function" },
114
+ { label: "=", action: "equals", type: "equals" },
115
+ ],
116
+ [
117
+ { label: "1", action: "1", type: "number" },
118
+ { label: "2", action: "2", type: "number" },
119
+ { label: "3", action: "3", type: "number" },
120
+ { label: "0", action: "0", type: "number" },
121
+ { label: ".", action: ".", type: "number" },
122
+ ],
123
+ ];
124
+
125
+ // Fixed layout constants
126
+ const BUTTON_WIDTH = 5;
127
+ const NUM_COLS = 5;
128
+ const NUM_ROWS = 6;
129
+ const CALC_WIDTH = BUTTON_WIDTH * NUM_COLS + 1; // 26 chars
130
+ const DISPLAY_LINES = 2;
131
+
132
+ // Get color for button type (with optional hover highlight)
133
+ function getButtonColor(type: Button["type"], isHovered: boolean): string {
134
+ if (isHovered) {
135
+ // Bright/inverted colors for hover
136
+ return C.BG_WHITE + "\x1b[30m"; // White background, black text
137
+ }
138
+ switch (type) {
139
+ case "number": return C.WHITE;
140
+ case "operator": return C.BRIGHT_YELLOW;
141
+ case "function": return C.BRIGHT_CYAN;
142
+ case "clear": return C.BRIGHT_RED;
143
+ case "equals": return C.BRIGHT_GREEN;
144
+ default: return C.WHITE;
145
+ }
146
+ }
147
+
148
+ // Layout metrics
149
+ interface LayoutMetrics {
150
+ startX: number;
151
+ startY: number;
152
+ }
153
+
154
+ function calculateLayout(_viewport: ViewportInfo): LayoutMetrics {
155
+ // Position at top-left with 1 row/column gap
156
+ const startX = 1;
157
+ const startY = 1;
158
+
159
+ return { startX, startY };
160
+ }
161
+
162
+ // Render the calculator with ANSI colors
163
+ function renderCalculator(): TextPropertyEntry[] {
164
+ const viewport = editor.getViewport();
165
+ if (!viewport) {
166
+ return [{ text: "No viewport\n", properties: {} }];
167
+ }
168
+
169
+ state.lastViewport = viewport;
170
+
171
+ // Use cached layout to prevent jumping, or calculate new one
172
+ if (!cachedLayout) {
173
+ cachedLayout = calculateLayout(viewport);
174
+ }
175
+ const layout = cachedLayout;
176
+ const entries: TextPropertyEntry[] = [];
177
+
178
+ const addLine = (text: string): void => {
179
+ entries.push({ text: text + "\n", properties: {} });
180
+ };
181
+
182
+ // Top margin
183
+ for (let i = 0; i < layout.startY; i++) {
184
+ addLine("");
185
+ }
186
+
187
+ const pad = " ".repeat(layout.startX);
188
+
189
+ // Unicode box drawing chars
190
+ const TL = "╭", TR = "╮", BL = "╰", BR = "╯";
191
+ const V = "│";
192
+ const LT = "├", RT = "┤", X = "┼";
193
+
194
+ // Generate border patterns dynamically
195
+ const cellWidth = BUTTON_WIDTH - 1; // 4 dashes per cell
196
+ const topBorder = TL + "─".repeat(CALC_WIDTH - 2) + TR;
197
+ const sepTop = LT + Array(NUM_COLS).fill("─".repeat(cellWidth)).join("┬") + RT;
198
+ const sepMid = LT + Array(NUM_COLS).fill("─".repeat(cellWidth)).join("┼") + RT;
199
+ const sepBot = BL + Array(NUM_COLS).fill("─".repeat(cellWidth)).join("┴") + BR;
200
+
201
+ // Display - top border
202
+ addLine(`${pad}${C.CYAN}${topBorder}${C.RESET}`);
203
+
204
+ // Expression line
205
+ let expr = state.expression || "";
206
+ const maxLen = CALC_WIDTH - 4;
207
+ if (expr.length > maxLen) expr = expr.slice(-maxLen);
208
+ addLine(`${pad}${C.CYAN}${V}${C.RESET} ${C.BRIGHT_GREEN}${expr.padStart(maxLen)}${C.RESET} ${C.CYAN}${V}${C.RESET}`);
209
+
210
+ // Result line with copy button on left - slightly different background
211
+ let result = state.error || state.result;
212
+ const copyBtnWidth = 6; // "Copy" + 2 spaces
213
+ const resultMaxLen = maxLen - copyBtnWidth;
214
+ if (result.length > resultMaxLen) result = result.slice(0, resultMaxLen);
215
+ const resultColor = state.error ? C.BRIGHT_RED : C.BRIGHT_GREEN;
216
+ const copyBtnColor = copyButtonHovered ? (C.BG_WHITE + "\x1b[30m") : (C.BG_BRIGHT_BLACK + C.BRIGHT_MAGENTA);
217
+ const resultBg = C.BG_BRIGHT_BLACK;
218
+ addLine(`${pad}${C.CYAN}${V}${C.RESET}${copyBtnColor}Copy${C.RESET}${resultBg} ${C.BOLD}${resultColor}${result.padStart(resultMaxLen)}${C.RESET}${resultBg} ${C.RESET}${C.CYAN}${V}${C.RESET}`);
219
+
220
+ // Separator between display and buttons
221
+ addLine(`${pad}${C.CYAN}${sepTop}${C.RESET}`);
222
+
223
+ // Button rows
224
+ for (let rowIdx = 0; rowIdx < BUTTON_LAYOUT.length; rowIdx++) {
225
+ const buttonRow = BUTTON_LAYOUT[rowIdx];
226
+ let line = `${pad}${C.CYAN}${V}${C.RESET}`;
227
+
228
+ for (let colIdx = 0; colIdx < buttonRow.length; colIdx++) {
229
+ const btn = buttonRow[colIdx];
230
+ const isHovered = hoveredButton?.row === rowIdx && hoveredButton?.col === colIdx;
231
+ const color = getButtonColor(btn.type, isHovered);
232
+ const label = btn.label;
233
+ const innerWidth = BUTTON_WIDTH - 1;
234
+ const leftSpace = Math.floor((innerWidth - label.length) / 2);
235
+ const rightSpace = innerWidth - label.length - leftSpace;
236
+ line += `${color}${C.BOLD}${" ".repeat(leftSpace)}${label}${" ".repeat(rightSpace)}${C.RESET}${C.CYAN}${V}${C.RESET}`;
237
+ }
238
+
239
+ addLine(line);
240
+
241
+ // Row separator (except after last row)
242
+ if (rowIdx < BUTTON_LAYOUT.length - 1) {
243
+ addLine(`${pad}${C.CYAN}${sepMid}${C.RESET}`);
244
+ }
245
+ }
246
+
247
+ // Bottom border
248
+ addLine(`${pad}${C.CYAN}${sepBot}${C.RESET}`);
249
+
250
+ // Help line
251
+ addLine("");
252
+ addLine(`${pad}${C.DIM} Esc:close =/Enter:calc Del:clear${C.RESET}`);
253
+
254
+ return entries;
255
+ }
256
+
257
+ // Check if click is on copy button (returns true if on copy button)
258
+ function isCopyButtonAt(contentCol: number, contentRow: number): boolean {
259
+ if (!cachedLayout) return false;
260
+
261
+ // Copy button is on result line (row 2 after top margin)
262
+ const resultLineY = cachedLayout.startY + 2; // top border + expression line
263
+ const copyBtnStartX = cachedLayout.startX + 1; // after left border
264
+ const copyBtnEndX = copyBtnStartX + 4; // "Copy" is 4 chars
265
+
266
+ return contentRow === resultLineY &&
267
+ contentCol >= copyBtnStartX &&
268
+ contentCol < copyBtnEndX;
269
+ }
270
+
271
+ // Copy result to clipboard
272
+ function copyResultToClipboard(): void {
273
+ const textToCopy = state.error || state.result;
274
+ editor.copyToClipboard(textToCopy);
275
+ editor.setStatus(`Copied: ${textToCopy}`);
276
+ }
277
+
278
+ // Get button position at content-relative coordinates
279
+ function getButtonPosition(contentCol: number, contentRow: number): { row: number; col: number } | null {
280
+ if (!cachedLayout) return null;
281
+
282
+ // Button area starts after: marginY + display(2 lines) + borders(2)
283
+ const buttonAreaStartY = cachedLayout.startY + DISPLAY_LINES + 2;
284
+ const buttonAreaStartX = cachedLayout.startX + 1; // +1 for left border
285
+
286
+ const relY = contentRow - buttonAreaStartY;
287
+ const relX = contentCol - buttonAreaStartX;
288
+
289
+ if (relX < 0 || relY < 0) return null;
290
+ if (relX >= BUTTON_WIDTH * NUM_COLS) return null;
291
+
292
+ // Check if on horizontal separator line (odd rows are separators)
293
+ if (relY % 2 === 1) return null;
294
+
295
+ // Check if on vertical border (every BUTTON_WIDTH chars, minus 1 for the separator)
296
+ const posInButton = relX % BUTTON_WIDTH;
297
+ if (posInButton === BUTTON_WIDTH - 1) return null; // On the | border
298
+
299
+ // Each button row = 2 lines (content + separator)
300
+ const buttonRowIdx = Math.floor(relY / 2);
301
+ if (buttonRowIdx < 0 || buttonRowIdx >= NUM_ROWS) return null;
302
+
303
+ // Column
304
+ const buttonColIdx = Math.floor(relX / BUTTON_WIDTH);
305
+ if (buttonColIdx < 0 || buttonColIdx >= NUM_COLS) return null;
306
+
307
+ return { row: buttonRowIdx, col: buttonColIdx };
308
+ }
309
+
310
+ // Get button at content-relative position
311
+ function getButtonAt(contentCol: number, contentRow: number): Button | null {
312
+ const pos = getButtonPosition(contentCol, contentRow);
313
+ if (!pos) return null;
314
+ return BUTTON_LAYOUT[pos.row][pos.col];
315
+ }
316
+
317
+ // Expression parser
318
+ interface Token {
319
+ type: "number" | "operator" | "lparen" | "rparen" | "function" | "constant";
320
+ value: string | number;
321
+ }
322
+
323
+ // Known functions and constants
324
+ const FUNCTIONS = ["sqrt", "ln", "log", "sin", "cos", "tan", "asin", "acos", "atan", "abs"];
325
+ const CONSTANTS: Record<string, number> = {
326
+ pi: Math.PI,
327
+ e: Math.E,
328
+ };
329
+
330
+ function tokenize(expr: string): Token[] {
331
+ const tokens: Token[] = [];
332
+ let i = 0;
333
+
334
+ while (i < expr.length) {
335
+ const ch = expr[i];
336
+
337
+ if (/\s/.test(ch)) { i++; continue; }
338
+
339
+ // Numbers
340
+ if (/[0-9.]/.test(ch)) {
341
+ let num = "";
342
+ while (i < expr.length && /[0-9.]/.test(expr[i])) {
343
+ num += expr[i];
344
+ i++;
345
+ }
346
+ tokens.push({ type: "number", value: parseFloat(num) });
347
+ continue;
348
+ }
349
+
350
+ // Identifiers (functions and constants)
351
+ if (/[a-zA-Z]/.test(ch)) {
352
+ let ident = "";
353
+ while (i < expr.length && /[a-zA-Z0-9]/.test(expr[i])) {
354
+ ident += expr[i];
355
+ i++;
356
+ }
357
+ if (FUNCTIONS.includes(ident)) {
358
+ tokens.push({ type: "function", value: ident });
359
+ } else if (ident in CONSTANTS) {
360
+ tokens.push({ type: "constant", value: ident });
361
+ } else {
362
+ throw new Error(`Unknown: ${ident}`);
363
+ }
364
+ continue;
365
+ }
366
+
367
+ if (ch === "(") { tokens.push({ type: "lparen", value: "(" }); i++; continue; }
368
+ if (ch === ")") { tokens.push({ type: "rparen", value: ")" }); i++; continue; }
369
+ if (/[+\-*/^]/.test(ch)) { tokens.push({ type: "operator", value: ch }); i++; continue; }
370
+
371
+ i++;
372
+ }
373
+
374
+ return tokens;
375
+ }
376
+
377
+ // Precedence: + - < * / < ^ < unary - < functions
378
+ function parseExpression(tokens: Token[], pos: { idx: number }): number {
379
+ let left = parseTerm(tokens, pos);
380
+
381
+ while (pos.idx < tokens.length) {
382
+ const token = tokens[pos.idx];
383
+ if (token.type === "operator" && (token.value === "+" || token.value === "-")) {
384
+ pos.idx++;
385
+ const right = parseTerm(tokens, pos);
386
+ left = token.value === "+" ? left + right : left - right;
387
+ } else {
388
+ break;
389
+ }
390
+ }
391
+
392
+ return left;
393
+ }
394
+
395
+ function parseTerm(tokens: Token[], pos: { idx: number }): number {
396
+ let left = parsePower(tokens, pos);
397
+
398
+ while (pos.idx < tokens.length) {
399
+ const token = tokens[pos.idx];
400
+ if (token.type === "operator" && (token.value === "*" || token.value === "/")) {
401
+ pos.idx++;
402
+ const right = parsePower(tokens, pos);
403
+ if (token.value === "*") {
404
+ left = left * right;
405
+ } else {
406
+ if (right === 0) throw new Error("Div by 0");
407
+ left = left / right;
408
+ }
409
+ } else {
410
+ break;
411
+ }
412
+ }
413
+
414
+ return left;
415
+ }
416
+
417
+ function parsePower(tokens: Token[], pos: { idx: number }): number {
418
+ const base = parseUnary(tokens, pos);
419
+
420
+ if (pos.idx < tokens.length && tokens[pos.idx].type === "operator" && tokens[pos.idx].value === "^") {
421
+ pos.idx++;
422
+ const exp = parsePower(tokens, pos); // Right associative
423
+ return Math.pow(base, exp);
424
+ }
425
+
426
+ return base;
427
+ }
428
+
429
+ function parseUnary(tokens: Token[], pos: { idx: number }): number {
430
+ if (pos.idx >= tokens.length) throw new Error("Unexpected end");
431
+
432
+ const token = tokens[pos.idx];
433
+
434
+ if (token.type === "operator" && token.value === "-") {
435
+ pos.idx++;
436
+ return -parseUnary(tokens, pos);
437
+ }
438
+
439
+ return parsePrimary(tokens, pos);
440
+ }
441
+
442
+ function parsePrimary(tokens: Token[], pos: { idx: number }): number {
443
+ if (pos.idx >= tokens.length) throw new Error("Unexpected end");
444
+
445
+ const token = tokens[pos.idx];
446
+
447
+ // Function call
448
+ if (token.type === "function") {
449
+ const fname = token.value as string;
450
+ pos.idx++;
451
+ if (pos.idx >= tokens.length || tokens[pos.idx].type !== "lparen") {
452
+ throw new Error(`Expected ( after ${fname}`);
453
+ }
454
+ pos.idx++; // skip (
455
+ const arg = parseExpression(tokens, pos);
456
+ if (pos.idx >= tokens.length || tokens[pos.idx].type !== "rparen") {
457
+ throw new Error("Missing )");
458
+ }
459
+ pos.idx++; // skip )
460
+
461
+ switch (fname) {
462
+ case "sqrt": return Math.sqrt(arg);
463
+ case "ln": return Math.log(arg);
464
+ case "log": return Math.log10(arg);
465
+ case "sin": return Math.sin(arg);
466
+ case "cos": return Math.cos(arg);
467
+ case "tan": return Math.tan(arg);
468
+ case "asin": return Math.asin(arg);
469
+ case "acos": return Math.acos(arg);
470
+ case "atan": return Math.atan(arg);
471
+ case "abs": return Math.abs(arg);
472
+ default: throw new Error(`Unknown function: ${fname}`);
473
+ }
474
+ }
475
+
476
+ // Constant
477
+ if (token.type === "constant") {
478
+ pos.idx++;
479
+ return CONSTANTS[token.value as string];
480
+ }
481
+
482
+ // Number
483
+ if (token.type === "number") {
484
+ pos.idx++;
485
+ return token.value as number;
486
+ }
487
+
488
+ // Parenthesized expression
489
+ if (token.type === "lparen") {
490
+ pos.idx++;
491
+ const result = parseExpression(tokens, pos);
492
+ if (pos.idx >= tokens.length || tokens[pos.idx].type !== "rparen") {
493
+ throw new Error("Missing )");
494
+ }
495
+ pos.idx++;
496
+ return result;
497
+ }
498
+
499
+ throw new Error("Syntax error");
500
+ }
501
+
502
+ function evaluateExpression(expr: string): string {
503
+ if (!expr.trim()) return "0";
504
+
505
+ const tokens = tokenize(expr);
506
+ if (tokens.length === 0) return "0";
507
+
508
+ const pos = { idx: 0 };
509
+ const result = parseExpression(tokens, pos);
510
+
511
+ if (pos.idx < tokens.length) throw new Error("Syntax error");
512
+
513
+ if (Number.isInteger(result)) {
514
+ return result.toString();
515
+ } else {
516
+ return parseFloat(result.toFixed(10)).toString();
517
+ }
518
+ }
519
+
520
+ // Handle button press
521
+ function handleButton(button: Button): void {
522
+ state.error = "";
523
+
524
+ switch (button.action) {
525
+ case "clear":
526
+ state.expression = "";
527
+ state.result = "0";
528
+ break;
529
+ case "backspace":
530
+ if (state.expression.length > 0) {
531
+ state.expression = state.expression.slice(0, -1);
532
+ }
533
+ break;
534
+ case "negate":
535
+ // Toggle sign: if expression is empty, negate last result; otherwise toggle current number
536
+ if (state.expression === "") {
537
+ // Use negated result as new expression
538
+ if (state.result !== "0") {
539
+ const num = parseFloat(state.result);
540
+ state.expression = (-num).toString();
541
+ state.result = state.expression;
542
+ } else {
543
+ state.expression = "-";
544
+ }
545
+ } else {
546
+ // Try to toggle sign of last number in expression
547
+ const match = state.expression.match(/(-?\d+\.?\d*)$/);
548
+ if (match) {
549
+ const numStr = match[1];
550
+ const prefix = state.expression.slice(0, state.expression.length - numStr.length);
551
+ const num = parseFloat(numStr);
552
+ state.expression = prefix + (-num).toString();
553
+ } else {
554
+ // No number at end, just add minus
555
+ state.expression += "-";
556
+ }
557
+ }
558
+ break;
559
+ case "equals":
560
+ try {
561
+ state.result = evaluateExpression(state.expression);
562
+ } catch (e) {
563
+ state.error = e instanceof Error ? e.message : "Error";
564
+ }
565
+ break;
566
+ default:
567
+ state.expression += button.action;
568
+ break;
569
+ }
570
+
571
+ updateDisplay();
572
+ }
573
+
574
+ function updateDisplay(): void {
575
+ if (state.bufferId) {
576
+ const entries = renderCalculator();
577
+ editor.setVirtualBufferContent(state.bufferId, entries);
578
+ }
579
+ }
580
+
581
+ // Mouse click handler
582
+ globalThis.onCalculatorMouseClick = function (data: {
583
+ column: number;
584
+ row: number;
585
+ button: string;
586
+ modifiers: string;
587
+ content_x: number;
588
+ content_y: number;
589
+ }): boolean {
590
+ if (data.button !== "left") return true;
591
+
592
+ const activeBuffer = editor.getActiveBufferId();
593
+ if (activeBuffer !== state.bufferId || state.bufferId === 0) return true;
594
+
595
+ // Convert screen coordinates to content-relative coordinates
596
+ const relCol = data.column - data.content_x;
597
+ const relRow = data.row - data.content_y;
598
+
599
+ // Check for copy button click
600
+ if (isCopyButtonAt(relCol, relRow)) {
601
+ copyResultToClipboard();
602
+ return false;
603
+ }
604
+
605
+ const button = getButtonAt(relCol, relRow);
606
+ if (button) {
607
+ handleButton(button);
608
+ return false;
609
+ }
610
+
611
+ return true;
612
+ };
613
+
614
+ // Keyboard handlers
615
+ globalThis.calc_digit_0 = function (): void { handleButton({ label: "0", action: "0", type: "number" }); };
616
+ globalThis.calc_digit_1 = function (): void { handleButton({ label: "1", action: "1", type: "number" }); };
617
+ globalThis.calc_digit_2 = function (): void { handleButton({ label: "2", action: "2", type: "number" }); };
618
+ globalThis.calc_digit_3 = function (): void { handleButton({ label: "3", action: "3", type: "number" }); };
619
+ globalThis.calc_digit_4 = function (): void { handleButton({ label: "4", action: "4", type: "number" }); };
620
+ globalThis.calc_digit_5 = function (): void { handleButton({ label: "5", action: "5", type: "number" }); };
621
+ globalThis.calc_digit_6 = function (): void { handleButton({ label: "6", action: "6", type: "number" }); };
622
+ globalThis.calc_digit_7 = function (): void { handleButton({ label: "7", action: "7", type: "number" }); };
623
+ globalThis.calc_digit_8 = function (): void { handleButton({ label: "8", action: "8", type: "number" }); };
624
+ globalThis.calc_digit_9 = function (): void { handleButton({ label: "9", action: "9", type: "number" }); };
625
+
626
+ globalThis.calc_add = function (): void { handleButton({ label: "+", action: "+", type: "operator" }); };
627
+ globalThis.calc_subtract = function (): void { handleButton({ label: "-", action: "-", type: "operator" }); };
628
+ globalThis.calc_multiply = function (): void { handleButton({ label: "×", action: "*", type: "operator" }); };
629
+ globalThis.calc_divide = function (): void { handleButton({ label: "÷", action: "/", type: "operator" }); };
630
+ globalThis.calc_lparen = function (): void { handleButton({ label: "(", action: "(", type: "function" }); };
631
+ globalThis.calc_rparen = function (): void { handleButton({ label: ")", action: ")", type: "function" }); };
632
+ globalThis.calc_dot = function (): void { handleButton({ label: ".", action: ".", type: "number" }); };
633
+ globalThis.calc_equals = function (): void { handleButton({ label: "=", action: "equals", type: "equals" }); };
634
+ globalThis.calc_clear = function (): void { handleButton({ label: "C", action: "clear", type: "clear" }); };
635
+ globalThis.calc_backspace = function (): void { handleButton({ label: "⌫", action: "backspace", type: "clear" }); };
636
+ globalThis.calc_power = function (): void { handleButton({ label: "^", action: "^", type: "operator" }); };
637
+
638
+ // Letter handlers for typing function names
639
+ const letterHandler = (ch: string) => () => {
640
+ state.error = "";
641
+ state.expression += ch;
642
+ updateDisplay();
643
+ };
644
+ for (const ch of "abcdefghijklmnopqrstuvwxyz") {
645
+ (globalThis as Record<string, unknown>)[`calc_letter_${ch}`] = letterHandler(ch);
646
+ }
647
+
648
+ globalThis.calc_close = function (): void {
649
+ if (state.bufferId) {
650
+ editor.closeBuffer(state.bufferId);
651
+ state.bufferId = 0;
652
+ }
653
+ };
654
+
655
+ // Open calculator
656
+ globalThis.calculator_open = async function (): Promise<void> {
657
+ if (state.bufferId) {
658
+ const bufferInfo = editor.getBufferInfo(state.bufferId);
659
+ if (bufferInfo) {
660
+ editor.showBuffer(state.bufferId);
661
+ return;
662
+ }
663
+ state.bufferId = 0;
664
+ }
665
+
666
+ state.expression = "";
667
+ state.result = "0";
668
+ state.error = "";
669
+ cachedLayout = null; // Reset layout for fresh calculation
670
+ hoveredButton = null; // Reset hover state
671
+ copyButtonHovered = false; // Reset copy button hover state
672
+
673
+ const modeBindings: [string, string][] = [
674
+ ["0", "calc_digit_0"], ["1", "calc_digit_1"], ["2", "calc_digit_2"],
675
+ ["3", "calc_digit_3"], ["4", "calc_digit_4"], ["5", "calc_digit_5"],
676
+ ["6", "calc_digit_6"], ["7", "calc_digit_7"], ["8", "calc_digit_8"],
677
+ ["9", "calc_digit_9"],
678
+ ["+", "calc_add"], ["-", "calc_subtract"], ["*", "calc_multiply"],
679
+ ["/", "calc_divide"], ["(", "calc_lparen"], [")", "calc_rparen"],
680
+ [".", "calc_dot"], ["^", "calc_power"],
681
+ ["Return", "calc_equals"], ["=", "calc_equals"],
682
+ ["Delete", "calc_clear"],
683
+ ["Backspace", "calc_backspace"],
684
+ ["Escape", "calc_close"],
685
+ ];
686
+ // Add letter bindings for typing function names
687
+ for (const ch of "abcdefghijklmnopqrstuvwxyz") {
688
+ modeBindings.push([ch, `calc_letter_${ch}`]);
689
+ }
690
+ editor.defineMode("calculator", "special", modeBindings, true);
691
+
692
+ const cmds = [
693
+ ["calc_digit_0", "0"], ["calc_digit_1", "1"], ["calc_digit_2", "2"],
694
+ ["calc_digit_3", "3"], ["calc_digit_4", "4"], ["calc_digit_5", "5"],
695
+ ["calc_digit_6", "6"], ["calc_digit_7", "7"], ["calc_digit_8", "8"],
696
+ ["calc_digit_9", "9"], ["calc_add", "+"], ["calc_subtract", "-"],
697
+ ["calc_multiply", "*"], ["calc_divide", "/"], ["calc_lparen", "("],
698
+ ["calc_rparen", ")"], ["calc_dot", "."], ["calc_equals", "="],
699
+ ["calc_clear", "C"], ["calc_backspace", "BS"], ["calc_close", "close"],
700
+ ["calc_power", "^"],
701
+ ];
702
+ for (const [name, desc] of cmds) {
703
+ editor.registerCommand(name, `Calc: ${desc}`, name, "calculator");
704
+ }
705
+ // Register letter commands
706
+ for (const ch of "abcdefghijklmnopqrstuvwxyz") {
707
+ editor.registerCommand(`calc_letter_${ch}`, `Calc: ${ch}`, `calc_letter_${ch}`, "calculator");
708
+ }
709
+
710
+ const entries = renderCalculator();
711
+
712
+ state.bufferId = await editor.createVirtualBuffer({
713
+ name: "*Calculator*",
714
+ mode: "calculator",
715
+ read_only: true,
716
+ entries,
717
+ show_line_numbers: false,
718
+ show_cursors: false,
719
+ editing_disabled: true,
720
+ });
721
+
722
+ state.splitId = editor.getActiveSplitId();
723
+
724
+ editor.setStatus("Calculator opened");
725
+ };
726
+
727
+ // Mouse move handler for hover effect
728
+ globalThis.onCalculatorMouseMove = function (data: {
729
+ column: number;
730
+ row: number;
731
+ content_x: number;
732
+ content_y: number;
733
+ }): boolean {
734
+ const activeBuffer = editor.getActiveBufferId();
735
+ if (activeBuffer !== state.bufferId || state.bufferId === 0) return true;
736
+
737
+ // Convert screen coordinates to content-relative coordinates
738
+ const relCol = data.column - data.content_x;
739
+ const relRow = data.row - data.content_y;
740
+
741
+ const newHover = getButtonPosition(relCol, relRow);
742
+ const newCopyHover = isCopyButtonAt(relCol, relRow);
743
+
744
+ // Check if hover changed
745
+ const buttonChanged =
746
+ (newHover === null && hoveredButton !== null) ||
747
+ (newHover !== null && hoveredButton === null) ||
748
+ (newHover !== null && hoveredButton !== null &&
749
+ (newHover.row !== hoveredButton.row || newHover.col !== hoveredButton.col));
750
+ const copyChanged = newCopyHover !== copyButtonHovered;
751
+
752
+ if (buttonChanged || copyChanged) {
753
+ hoveredButton = newHover;
754
+ copyButtonHovered = newCopyHover;
755
+ updateDisplay();
756
+ }
757
+
758
+ return true;
759
+ };
760
+
761
+ // Register hooks
762
+ editor.on("mouse_click", "onCalculatorMouseClick");
763
+ editor.on("mouse_move", "onCalculatorMouseMove");
764
+
765
+ // Register main command
766
+ editor.registerCommand("Calculator", "Open calculator", "calculator_open", "normal");
767
+
768
+ editor.setStatus("Calculator plugin loaded");
@@ -183,6 +183,12 @@ interface TextPropertyEntry {
183
183
  properties: Record<string, unknown>;
184
184
  }
185
185
 
186
+ /** Result from createVirtualBufferInSplit */
187
+ interface CreateVirtualBufferResult {
188
+ buffer_id: number;
189
+ split_id?: number | null;
190
+ }
191
+
186
192
  /** Configuration for createVirtualBufferInSplit */
187
193
  interface CreateVirtualBufferOptions {
188
194
  /** Buffer name shown in status bar (convention: "*Name*") */
@@ -380,6 +386,14 @@ interface EditorAPI {
380
386
  setPromptSuggestions(suggestions: PromptSuggestion[]): boolean;
381
387
 
382
388
  // === Buffer Mutations ===
389
+ /**
390
+ * Copy text to the system clipboard
391
+ *
392
+ * Copies the provided text to both the internal and system clipboard.
393
+ * Uses OSC 52 and arboard for cross-platform compatibility.
394
+ * @param text - Text to copy to clipboard
395
+ */
396
+ setClipboard(text: string): void;
383
397
  /**
384
398
  * Insert text at a byte position in a buffer
385
399
  *
@@ -462,9 +476,10 @@ interface EditorAPI {
462
476
  * @param description - Human-readable description
463
477
  * @param action - JavaScript function name to call when command is triggered
464
478
  * @param contexts - Comma-separated list of contexts (e.g., "normal,prompt")
479
+ * @param source - Plugin source name (empty string for builtin)
465
480
  * @returns true if command was registered
466
481
  */
467
- registerCommand(name: string, description: string, action: string, contexts: string): boolean;
482
+ registerCommand(name: string, description: string, action: string, contexts: string, source: string): boolean;
468
483
  /**
469
484
  * Unregister a custom command by name
470
485
  * @param name - The name of the command to unregister
@@ -829,7 +844,7 @@ interface EditorAPI {
829
844
  * panel_id: "search"
830
845
  * });
831
846
  */
832
- createVirtualBufferInSplit(options: CreateVirtualBufferOptions): Promise<number>;
847
+ createVirtualBufferInSplit(options: CreateVirtualBufferOptions): Promise<CreateVirtualBufferResult>;
833
848
  /**
834
849
  * Create a virtual buffer in an existing split
835
850
  * @param options - Configuration for the virtual buffer
@@ -0,0 +1,342 @@
1
+ /// <reference path="./lib/fresh.d.ts" />
2
+
3
+ /**
4
+ * Live Grep Plugin
5
+ *
6
+ * Project-wide search with ripgrep and live preview.
7
+ * - Type to search across all files
8
+ * - Navigate results with Up/Down to see preview
9
+ * - Press Enter to open file at location
10
+ */
11
+
12
+ interface GrepMatch {
13
+ file: string;
14
+ line: number;
15
+ column: number;
16
+ content: string;
17
+ }
18
+
19
+ // State management
20
+ let grepResults: GrepMatch[] = [];
21
+ let previewBufferId: number | null = null;
22
+ let previewSplitId: number | null = null;
23
+ let originalSplitId: number | null = null;
24
+ let lastQuery: string = "";
25
+ let searchDebounceTimer: number | null = null;
26
+ let previewCreated: boolean = false;
27
+
28
+ // Parse ripgrep output line
29
+ // Format: file:line:column:content
30
+ function parseRipgrepLine(line: string): GrepMatch | null {
31
+ const match = line.match(/^([^:]+):(\d+):(\d+):(.*)$/);
32
+ if (match) {
33
+ return {
34
+ file: match[1],
35
+ line: parseInt(match[2], 10),
36
+ column: parseInt(match[3], 10),
37
+ content: match[4],
38
+ };
39
+ }
40
+ return null;
41
+ }
42
+
43
+ // Parse ripgrep output into suggestions
44
+ function parseRipgrepOutput(stdout: string): {
45
+ results: GrepMatch[];
46
+ suggestions: PromptSuggestion[];
47
+ } {
48
+ const results: GrepMatch[] = [];
49
+ const suggestions: PromptSuggestion[] = [];
50
+
51
+ for (const line of stdout.split("\n")) {
52
+ if (!line.trim()) continue;
53
+ const match = parseRipgrepLine(line);
54
+ if (match) {
55
+ results.push(match);
56
+
57
+ // Truncate long content for display
58
+ const displayContent =
59
+ match.content.length > 60
60
+ ? match.content.substring(0, 57) + "..."
61
+ : match.content;
62
+
63
+ suggestions.push({
64
+ text: `${match.file}:${match.line}`,
65
+ description: displayContent.trim(),
66
+ value: `${results.length - 1}`, // Store index as value
67
+ disabled: false,
68
+ });
69
+
70
+ // Limit to 100 results for performance
71
+ if (results.length >= 100) {
72
+ break;
73
+ }
74
+ }
75
+ }
76
+
77
+ return { results, suggestions };
78
+ }
79
+
80
+ // Create or update preview buffer with file content
81
+ async function updatePreview(match: GrepMatch): Promise<void> {
82
+ try {
83
+ // Read the file content
84
+ const content = await editor.readFile(match.file);
85
+ const lines = content.split("\n");
86
+
87
+ // Calculate context window (5 lines before and after)
88
+ const contextBefore = 5;
89
+ const contextAfter = 5;
90
+ const startLine = Math.max(0, match.line - 1 - contextBefore);
91
+ const endLine = Math.min(lines.length, match.line + contextAfter);
92
+
93
+ // Build preview entries with highlighting
94
+ const entries: TextPropertyEntry[] = [];
95
+
96
+ // Header
97
+ entries.push({
98
+ text: ` ${match.file}:${match.line}:${match.column}\n`,
99
+ properties: { type: "header" },
100
+ });
101
+ entries.push({
102
+ text: "─".repeat(60) + "\n",
103
+ properties: { type: "separator" },
104
+ });
105
+
106
+ // Content lines with line numbers
107
+ for (let i = startLine; i < endLine; i++) {
108
+ const lineNum = i + 1;
109
+ const lineContent = lines[i] || "";
110
+ const isMatchLine = lineNum === match.line;
111
+ const prefix = isMatchLine ? "▶ " : " ";
112
+ const lineNumStr = String(lineNum).padStart(4, " ");
113
+
114
+ entries.push({
115
+ text: `${prefix}${lineNumStr} │ ${lineContent}\n`,
116
+ properties: {
117
+ type: isMatchLine ? "match" : "context",
118
+ line: lineNum,
119
+ },
120
+ });
121
+ }
122
+
123
+ // Create or update the preview buffer
124
+ if (previewBufferId === null) {
125
+ // Define mode for preview buffer
126
+ editor.defineMode("live-grep-preview", "special", [["q", "close_buffer"]], true);
127
+
128
+ // Create preview in a split on the right
129
+ const result = await editor.createVirtualBufferInSplit({
130
+ name: "*Preview*",
131
+ mode: "live-grep-preview",
132
+ read_only: true,
133
+ entries,
134
+ ratio: 0.5,
135
+ direction: "vertical",
136
+ panel_id: "live-grep-preview",
137
+ show_line_numbers: false,
138
+ editing_disabled: true,
139
+ });
140
+
141
+ // Extract buffer and split IDs from result
142
+ previewBufferId = result.buffer_id;
143
+ previewSplitId = result.split_id ?? null;
144
+
145
+ // Return focus to original split so prompt stays active
146
+ if (originalSplitId !== null) {
147
+ editor.focusSplit(originalSplitId);
148
+ }
149
+ } else {
150
+ // Update existing buffer content
151
+ editor.setVirtualBufferContent(previewBufferId, entries);
152
+ }
153
+ } catch (e) {
154
+ editor.debug(`Failed to update preview: ${e}`);
155
+ }
156
+ }
157
+
158
+ // Close preview buffer and its split
159
+ function closePreview(): void {
160
+ // Close the buffer first
161
+ if (previewBufferId !== null) {
162
+ editor.closeBuffer(previewBufferId);
163
+ previewBufferId = null;
164
+ }
165
+ // Then close the split
166
+ if (previewSplitId !== null) {
167
+ editor.closeSplit(previewSplitId);
168
+ previewSplitId = null;
169
+ }
170
+ }
171
+
172
+ // Run ripgrep search
173
+ async function runSearch(query: string): Promise<void> {
174
+ if (!query || query.trim().length < 2) {
175
+ editor.setPromptSuggestions([]);
176
+ grepResults = [];
177
+ return;
178
+ }
179
+
180
+ // Avoid duplicate searches
181
+ if (query === lastQuery) {
182
+ return;
183
+ }
184
+ lastQuery = query;
185
+
186
+ try {
187
+ const result = await editor.spawnProcess("rg", [
188
+ "--line-number",
189
+ "--column",
190
+ "--no-heading",
191
+ "--color=never",
192
+ "--smart-case",
193
+ "--max-count=100",
194
+ "-g", "!.git",
195
+ "-g", "!node_modules",
196
+ "-g", "!target",
197
+ "-g", "!*.lock",
198
+ "--",
199
+ query,
200
+ ]);
201
+
202
+ if (result.exit_code === 0) {
203
+ const { results, suggestions } = parseRipgrepOutput(result.stdout);
204
+ grepResults = results;
205
+ editor.setPromptSuggestions(suggestions);
206
+
207
+ if (results.length > 0) {
208
+ editor.setStatus(`Found ${results.length} matches`);
209
+ // Show preview of first result
210
+ await updatePreview(results[0]);
211
+ } else {
212
+ editor.setStatus("No matches found");
213
+ }
214
+ } else if (result.exit_code === 1) {
215
+ // No matches
216
+ grepResults = [];
217
+ editor.setPromptSuggestions([]);
218
+ editor.setStatus("No matches found");
219
+ } else {
220
+ editor.setStatus(`Search error: ${result.stderr}`);
221
+ }
222
+ } catch (e) {
223
+ editor.setStatus(`Search error: ${e}`);
224
+ }
225
+ }
226
+
227
+ // Start live grep
228
+ globalThis.start_live_grep = function (): void {
229
+ // Clear previous state
230
+ grepResults = [];
231
+ lastQuery = "";
232
+ previewBufferId = null;
233
+
234
+ // Remember original split to keep focus
235
+ originalSplitId = editor.getActiveSplitId();
236
+
237
+ // Start the prompt
238
+ editor.startPrompt("Live grep: ", "live-grep");
239
+ editor.setStatus("Type to search (min 2 chars)...");
240
+ };
241
+
242
+ // Handle prompt input changes
243
+ globalThis.onLiveGrepPromptChanged = function (args: {
244
+ prompt_type: string;
245
+ input: string;
246
+ }): boolean {
247
+ if (args.prompt_type !== "live-grep") {
248
+ return true;
249
+ }
250
+
251
+ // Debounce search to avoid too many requests while typing
252
+ if (searchDebounceTimer !== null) {
253
+ // Can't actually cancel in this runtime, but we track it
254
+ }
255
+
256
+ // Run search (with small delay effect via async)
257
+ runSearch(args.input);
258
+
259
+ return true;
260
+ };
261
+
262
+ // Handle selection changes - update preview
263
+ globalThis.onLiveGrepSelectionChanged = function (args: {
264
+ prompt_type: string;
265
+ selected_index: number;
266
+ }): boolean {
267
+ if (args.prompt_type !== "live-grep") {
268
+ return true;
269
+ }
270
+
271
+ const match = grepResults[args.selected_index];
272
+ if (match) {
273
+ updatePreview(match);
274
+ }
275
+
276
+ return true;
277
+ };
278
+
279
+ // Handle prompt confirmation - open file
280
+ globalThis.onLiveGrepPromptConfirmed = function (args: {
281
+ prompt_type: string;
282
+ selected_index: number | null;
283
+ input: string;
284
+ }): boolean {
285
+ if (args.prompt_type !== "live-grep") {
286
+ return true;
287
+ }
288
+
289
+ // Close preview first
290
+ closePreview();
291
+
292
+ // Open selected file
293
+ if (args.selected_index !== null && grepResults[args.selected_index]) {
294
+ const selected = grepResults[args.selected_index];
295
+ editor.openFile(selected.file, selected.line, selected.column);
296
+ editor.setStatus(`Opened ${selected.file}:${selected.line}`);
297
+ } else {
298
+ editor.setStatus("No file selected");
299
+ }
300
+
301
+ // Clear state
302
+ grepResults = [];
303
+ originalSplitId = null;
304
+ previewSplitId = null;
305
+
306
+ return true;
307
+ };
308
+
309
+ // Handle prompt cancellation
310
+ globalThis.onLiveGrepPromptCancelled = function (args: {
311
+ prompt_type: string;
312
+ }): boolean {
313
+ if (args.prompt_type !== "live-grep") {
314
+ return true;
315
+ }
316
+
317
+ // Close preview and cleanup
318
+ closePreview();
319
+ grepResults = [];
320
+ originalSplitId = null;
321
+ previewSplitId = null;
322
+ editor.setStatus("Live grep cancelled");
323
+
324
+ return true;
325
+ };
326
+
327
+ // Register event handlers
328
+ editor.on("prompt_changed", "onLiveGrepPromptChanged");
329
+ editor.on("prompt_selection_changed", "onLiveGrepSelectionChanged");
330
+ editor.on("prompt_confirmed", "onLiveGrepPromptConfirmed");
331
+ editor.on("prompt_cancelled", "onLiveGrepPromptCancelled");
332
+
333
+ // Register command
334
+ editor.registerCommand(
335
+ "Live Grep (Find in Files)",
336
+ "Search for text across project with live preview",
337
+ "start_live_grep",
338
+ "normal"
339
+ );
340
+
341
+ editor.debug("Live Grep plugin loaded");
342
+ editor.setStatus("Live Grep ready - use command palette or bind 'start_live_grep'");