@oh-my-pi/pi-coding-agent 3.5.1337 → 3.8.1337

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.
@@ -8,11 +8,10 @@ import { uriToFile } from "./utils";
8
8
  // =============================================================================
9
9
 
10
10
  /**
11
- * Apply text edits to a file.
11
+ * Apply text edits to a string in-memory.
12
12
  * Edits are applied in reverse order (bottom-to-top) to preserve line/character indices.
13
13
  */
14
- export async function applyTextEdits(filePath: string, edits: TextEdit[]): Promise<void> {
15
- const content = await Bun.file(filePath).text();
14
+ export function applyTextEditsToString(content: string, edits: TextEdit[]): string {
16
15
  const lines = content.split("\n");
17
16
 
18
17
  // Sort edits in reverse order (bottom-to-top, right-to-left)
@@ -39,7 +38,17 @@ export async function applyTextEdits(filePath: string, edits: TextEdit[]): Promi
39
38
  }
40
39
  }
41
40
 
42
- await Bun.write(filePath, lines.join("\n"));
41
+ return lines.join("\n");
42
+ }
43
+
44
+ /**
45
+ * Apply text edits to a file.
46
+ * Edits are applied in reverse order (bottom-to-top) to preserve line/character indices.
47
+ */
48
+ export async function applyTextEdits(filePath: string, edits: TextEdit[]): Promise<void> {
49
+ const content = await Bun.file(filePath).text();
50
+ const result = applyTextEditsToString(content, edits);
51
+ await Bun.write(filePath, result);
43
52
  }
44
53
 
45
54
  // =============================================================================
@@ -1,20 +1,24 @@
1
1
  import * as fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
+ import type { BunFile } from "bun";
4
5
  import type { Theme } from "../../../modes/interactive/theme/theme";
5
6
  import { logger } from "../../logger";
7
+ import { once, untilAborted } from "../../utils";
6
8
  import { resolveToCwd } from "../path-utils";
7
9
  import {
8
10
  ensureFileOpen,
9
11
  getActiveClients,
10
12
  getOrCreateClient,
11
13
  type LspServerStatus,
14
+ notifySaved,
12
15
  refreshFile,
13
16
  sendRequest,
14
17
  setIdleTimeout,
18
+ syncContent,
15
19
  } from "./client";
16
20
  import { getServerForFile, getServersForFile, hasCapability, type LspConfig, loadConfig } from "./config";
17
- import { applyTextEdits, applyWorkspaceEdit } from "./edits";
21
+ import { applyTextEditsToString, applyWorkspaceEdit } from "./edits";
18
22
  import { renderCall, renderResult } from "./render";
19
23
  import * as rustAnalyzer from "./rust-analyzer";
20
24
  import {
@@ -113,6 +117,50 @@ export function getLspStatus(): LspServerStatus[] {
113
117
  return getActiveClients();
114
118
  }
115
119
 
120
+ /**
121
+ * Sync in-memory file content to all applicable LSP servers.
122
+ * Sends didOpen (if new) or didChange (if already open).
123
+ *
124
+ * @param absolutePath - Absolute path to the file
125
+ * @param content - The new file content
126
+ * @param cwd - Working directory for LSP config resolution
127
+ * @param servers - Servers to sync to
128
+ */
129
+ async function syncFileContent(
130
+ absolutePath: string,
131
+ content: string,
132
+ cwd: string,
133
+ servers: Array<[string, ServerConfig]>,
134
+ ): Promise<void> {
135
+ await Promise.allSettled(
136
+ servers.map(async ([_serverName, serverConfig]) => {
137
+ const client = await getOrCreateClient(serverConfig, cwd);
138
+ await syncContent(client, absolutePath, content);
139
+ }),
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Notify all LSP servers that a file was saved.
145
+ * Assumes content was already synced via syncFileContent.
146
+ *
147
+ * @param absolutePath - Absolute path to the file
148
+ * @param cwd - Working directory for LSP config resolution
149
+ * @param servers - Servers to notify
150
+ */
151
+ async function notifyFileSaved(
152
+ absolutePath: string,
153
+ cwd: string,
154
+ servers: Array<[string, ServerConfig]>,
155
+ ): Promise<void> {
156
+ await Promise.allSettled(
157
+ servers.map(async ([_serverName, serverConfig]) => {
158
+ const client = await getOrCreateClient(serverConfig, cwd);
159
+ await notifySaved(client, absolutePath);
160
+ }),
161
+ );
162
+ }
163
+
116
164
  // Cache config per cwd to avoid repeated file I/O
117
165
  const configCache = new Map<string, LspConfig>();
118
166
 
@@ -318,45 +366,34 @@ async function runWorkspaceDiagnostics(
318
366
 
319
367
  /** Result from getDiagnosticsForFile */
320
368
  export interface FileDiagnosticsResult {
321
- /** Whether an LSP server was available for the file type */
322
- available: boolean;
323
369
  /** Name of the LSP server used (if available) */
324
- serverName?: string;
370
+ server?: string;
325
371
  /** Formatted diagnostic messages */
326
- diagnostics: string[];
372
+ messages: string[];
327
373
  /** Summary string (e.g., "2 error(s), 1 warning(s)") */
328
374
  summary: string;
329
375
  /** Whether there are any errors (severity 1) */
330
- hasErrors: boolean;
331
- /** Whether there are any warnings (severity 2) */
332
- hasWarnings: boolean;
376
+ errored: boolean;
377
+ /** Whether the file was formatted */
378
+ formatter?: FileFormatResult;
333
379
  }
334
380
 
335
381
  /**
336
- * Get LSP diagnostics for a file after it has been written.
337
- * Queries all applicable language servers (e.g., TypeScript + Biome) and merges results.
382
+ * Get LSP diagnostics for a file.
383
+ * Assumes content was synced and didSave was sent - just waits for diagnostics.
338
384
  *
339
385
  * @param absolutePath - Absolute path to the file
340
386
  * @param cwd - Working directory for LSP config resolution
341
- * @param timeoutMs - Timeout for waiting for diagnostics (default: 5000ms)
342
- * @returns Diagnostic results or null if no LSP server available
387
+ * @param servers - Servers to query diagnostics for
388
+ * @returns Diagnostic results or undefined if no servers
343
389
  */
344
- export async function getDiagnosticsForFile(
390
+ async function getDiagnosticsForFile(
345
391
  absolutePath: string,
346
392
  cwd: string,
347
- timeoutMs = 5000,
348
- ): Promise<FileDiagnosticsResult> {
349
- const config = getConfig(cwd);
350
- const servers = getServersForFile(config, absolutePath);
351
-
393
+ servers: Array<[string, ServerConfig]>,
394
+ ): Promise<FileDiagnosticsResult | undefined> {
352
395
  if (servers.length === 0) {
353
- return {
354
- available: false,
355
- diagnostics: [],
356
- summary: "",
357
- hasErrors: false,
358
- hasWarnings: false,
359
- };
396
+ return undefined;
360
397
  }
361
398
 
362
399
  const uri = fileToUri(absolutePath);
@@ -364,12 +401,12 @@ export async function getDiagnosticsForFile(
364
401
  const allDiagnostics: Diagnostic[] = [];
365
402
  const serverNames: string[] = [];
366
403
 
367
- // Query all applicable servers in parallel
404
+ // Wait for diagnostics from all servers in parallel
368
405
  const results = await Promise.allSettled(
369
406
  servers.map(async ([serverName, serverConfig]) => {
370
407
  const client = await getOrCreateClient(serverConfig, cwd);
371
- await refreshFile(client, absolutePath);
372
- const diagnostics = await waitForDiagnostics(client, uri, timeoutMs);
408
+ // Content already synced + didSave sent, just wait for diagnostics
409
+ const diagnostics = await waitForDiagnostics(client, uri);
373
410
  return { serverName, diagnostics };
374
411
  }),
375
412
  );
@@ -382,24 +419,15 @@ export async function getDiagnosticsForFile(
382
419
  }
383
420
 
384
421
  if (serverNames.length === 0) {
385
- // All servers failed
386
- return {
387
- available: false,
388
- diagnostics: [],
389
- summary: "",
390
- hasErrors: false,
391
- hasWarnings: false,
392
- };
422
+ return undefined;
393
423
  }
394
424
 
395
425
  if (allDiagnostics.length === 0) {
396
426
  return {
397
- available: true,
398
- serverName: serverNames.join(", "),
399
- diagnostics: [],
400
- summary: "No issues",
401
- hasErrors: false,
402
- hasWarnings: false,
427
+ server: serverNames.join(", "),
428
+ messages: [],
429
+ summary: "OK",
430
+ errored: false,
403
431
  };
404
432
  }
405
433
 
@@ -417,28 +445,18 @@ export async function getDiagnosticsForFile(
417
445
  const formatted = uniqueDiagnostics.map((d) => formatDiagnostic(d, relPath));
418
446
  const summary = formatDiagnosticsSummary(uniqueDiagnostics);
419
447
  const hasErrors = uniqueDiagnostics.some((d) => d.severity === 1);
420
- const hasWarnings = uniqueDiagnostics.some((d) => d.severity === 2);
421
448
 
422
449
  return {
423
- available: true,
424
- serverName: serverNames.join(", "),
425
- diagnostics: formatted,
450
+ server: serverNames.join(", "),
451
+ messages: formatted,
426
452
  summary,
427
- hasErrors,
428
- hasWarnings,
453
+ errored: hasErrors,
429
454
  };
430
455
  }
431
456
 
432
- /** Result from formatFile */
433
- export interface FileFormatResult {
434
- /** Whether an LSP server with formatting support was available */
435
- available: boolean;
436
- /** Name of the LSP server used (if available) */
437
- serverName?: string;
438
- /** Whether formatting was applied */
439
- formatted: boolean;
440
- /** Error message if formatting failed */
441
- error?: string;
457
+ export enum FileFormatResult {
458
+ UNCHANGED = "unchanged",
459
+ FORMATTED = "formatted",
442
460
  }
443
461
 
444
462
  /** Default formatting options for LSP */
@@ -451,63 +469,151 @@ const DEFAULT_FORMAT_OPTIONS = {
451
469
  };
452
470
 
453
471
  /**
454
- * Format a file using LSP.
455
- * Uses the first available server that supports formatting.
472
+ * Format content in-memory using LSP.
473
+ * Assumes content was already synced to all servers via syncFileContent.
474
+ * Requests formatting from first capable server, applies edits in-memory.
456
475
  *
457
- * @param absolutePath - Absolute path to the file
476
+ * @param absolutePath - Absolute path (for URI)
477
+ * @param content - Content to format
458
478
  * @param cwd - Working directory for LSP config resolution
459
- * @returns Format result indicating success/failure
479
+ * @param servers - Servers to try formatting with
480
+ * @returns Formatted content, or original if no formatter available
460
481
  */
461
- export async function formatFile(absolutePath: string, cwd: string): Promise<FileFormatResult> {
462
- const config = getConfig(cwd);
463
- const servers = getServersForFile(config, absolutePath);
464
-
482
+ async function formatContent(
483
+ absolutePath: string,
484
+ content: string,
485
+ cwd: string,
486
+ servers: Array<[string, ServerConfig]>,
487
+ ): Promise<string> {
465
488
  if (servers.length === 0) {
466
- return { available: false, formatted: false };
489
+ return content;
467
490
  }
468
491
 
469
492
  const uri = fileToUri(absolutePath);
470
493
 
471
- // Try each server until one successfully formats
472
- for (const [serverName, serverConfig] of servers) {
494
+ for (const [_serverName, serverConfig] of servers) {
473
495
  try {
474
496
  const client = await getOrCreateClient(serverConfig, cwd);
475
497
 
476
- // Check if server supports formatting
477
498
  const caps = client.serverCapabilities;
478
499
  if (!caps?.documentFormattingProvider) {
479
500
  continue;
480
501
  }
481
502
 
482
- // Ensure file is open and synced
483
- await ensureFileOpen(client, absolutePath);
484
- await refreshFile(client, absolutePath);
485
-
486
- // Request formatting
503
+ // Request formatting (content already synced)
487
504
  const edits = (await sendRequest(client, "textDocument/formatting", {
488
505
  textDocument: { uri },
489
506
  options: DEFAULT_FORMAT_OPTIONS,
490
507
  })) as TextEdit[] | null;
491
508
 
492
509
  if (!edits || edits.length === 0) {
493
- // No changes needed - file already formatted
494
- return { available: true, serverName, formatted: false };
510
+ return content;
495
511
  }
496
512
 
497
- // Apply the formatting edits
498
- await applyTextEdits(absolutePath, edits);
513
+ // Apply edits in-memory and return
514
+ return applyTextEditsToString(content, edits);
515
+ } catch {}
516
+ }
499
517
 
500
- // Notify LSP of the change so diagnostics update
501
- await refreshFile(client, absolutePath);
518
+ return content;
519
+ }
502
520
 
503
- return { available: true, serverName, formatted: true };
504
- } catch {}
521
+ /** Options for creating the LSP writethrough callback */
522
+ export interface WritethroughOptions {
523
+ /** Whether to format the file using LSP after writing */
524
+ enableFormat?: boolean;
525
+ /** Whether to get LSP diagnostics after writing */
526
+ enableDiagnostics?: boolean;
527
+ }
528
+
529
+ /** Callback type for the LSP writethrough */
530
+ export type WritethroughCallback = (
531
+ dst: string,
532
+ content: string,
533
+ signal?: AbortSignal,
534
+ file?: BunFile,
535
+ ) => Promise<FileDiagnosticsResult | undefined>;
536
+
537
+ /** No-op writethrough callback */
538
+ export async function writethroughNoop(
539
+ dst: string,
540
+ content: string,
541
+ _signal?: AbortSignal,
542
+ file?: BunFile,
543
+ ): Promise<FileDiagnosticsResult | undefined> {
544
+ if (file) {
545
+ await file.write(content);
546
+ } else {
547
+ await Bun.write(dst, content);
505
548
  }
549
+ return undefined;
550
+ }
551
+
552
+ /** Create a writethrough callback for LSP aware write operations */
553
+ export function createLspWritethrough(cwd: string, options?: WritethroughOptions): WritethroughCallback {
554
+ const { enableFormat = false, enableDiagnostics = false } = options ?? {};
555
+ if (!enableFormat && !enableDiagnostics) {
556
+ return writethroughNoop;
557
+ }
558
+ return async (dst: string, content: string, signal?: AbortSignal, file?: BunFile) => {
559
+ const config = getConfig(cwd);
560
+ const servers = getServersForFile(config, dst);
561
+ if (servers.length === 0) {
562
+ return writethroughNoop(dst, content, signal, file);
563
+ }
564
+
565
+ let finalContent = content;
566
+ const getWritePromise = once(() => (file ? file.write(finalContent) : Bun.write(dst, finalContent)));
506
567
 
507
- // No server could format
508
- return { available: false, formatted: false };
568
+ let formatter: FileFormatResult | undefined;
569
+ let diagnostics: FileDiagnosticsResult | undefined;
570
+ try {
571
+ signal ??= AbortSignal.timeout(10_000);
572
+ await untilAborted(signal, async () => {
573
+ // 1. Sync original content to ALL servers
574
+ await syncFileContent(dst, content, cwd, servers);
575
+
576
+ // 2. Format in-memory (servers already have content)
577
+ if (enableFormat) {
578
+ finalContent = await formatContent(dst, content, cwd, servers);
579
+ formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
580
+ }
581
+
582
+ // 3. If formatted, sync formatted content to ALL servers
583
+ if (finalContent !== content) {
584
+ await syncFileContent(dst, finalContent, cwd, servers);
585
+ }
586
+
587
+ // 4. Write to disk
588
+ await getWritePromise();
589
+
590
+ // 5. Notify saved to ALL servers
591
+ await notifyFileSaved(dst, cwd, servers);
592
+
593
+ // 6. Get diagnostics from ALL servers
594
+ if (enableDiagnostics) {
595
+ diagnostics = await getDiagnosticsForFile(dst, cwd, servers);
596
+ }
597
+ });
598
+ } catch {
599
+ await getWritePromise();
600
+ }
601
+
602
+ if (formatter !== undefined) {
603
+ diagnostics ??= {
604
+ server: servers.map(([name]) => name).join(", "),
605
+ messages: [],
606
+ summary: "OK",
607
+ errored: false,
608
+ };
609
+ diagnostics.formatter = formatter;
610
+ }
611
+
612
+ return diagnostics;
613
+ };
509
614
  }
510
615
 
616
+ /** Create an LSP tool */
511
617
  export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolDetails, Theme> {
512
618
  return {
513
619
  name: "lsp",
@@ -1,5 +1,6 @@
1
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
2
  import { Type } from "@sinclair/typebox";
3
+ import { untilAborted } from "../utils";
3
4
  import { resolveToCwd } from "./path-utils";
4
5
 
5
6
  const notebookSchema = Type.Object({
@@ -66,160 +67,104 @@ export function createNotebookTool(cwd: string): AgentTool<typeof notebookSchema
66
67
  ) => {
67
68
  const absolutePath = resolveToCwd(notebook_path, cwd);
68
69
 
69
- return new Promise<{
70
- content: Array<{ type: "text"; text: string }>;
71
- details: NotebookToolDetails | undefined;
72
- }>((resolve, reject) => {
73
- if (signal?.aborted) {
74
- reject(new Error("Operation aborted"));
75
- return;
70
+ return untilAborted(signal, async () => {
71
+ // Check if file exists
72
+ const file = Bun.file(absolutePath);
73
+ if (!(await file.exists())) {
74
+ throw new Error(`Notebook not found: ${notebook_path}`);
76
75
  }
77
76
 
78
- let aborted = false;
79
-
80
- const onAbort = () => {
81
- aborted = true;
82
- reject(new Error("Operation aborted"));
83
- };
84
-
85
- if (signal) {
86
- signal.addEventListener("abort", onAbort, { once: true });
77
+ // Read and parse notebook
78
+ let notebook: Notebook;
79
+ try {
80
+ notebook = await file.json();
81
+ } catch {
82
+ throw new Error(`Invalid JSON in notebook: ${notebook_path}`);
87
83
  }
88
84
 
89
- (async () => {
90
- try {
91
- // Check if file exists
92
- const file = Bun.file(absolutePath);
93
- if (!(await file.exists())) {
94
- if (signal) signal.removeEventListener("abort", onAbort);
95
- reject(new Error(`Notebook not found: ${notebook_path}`));
96
- return;
97
- }
98
-
99
- if (aborted) return;
100
-
101
- // Read and parse notebook
102
- let notebook: Notebook;
103
- try {
104
- notebook = await file.json();
105
- } catch {
106
- if (signal) signal.removeEventListener("abort", onAbort);
107
- reject(new Error(`Invalid JSON in notebook: ${notebook_path}`));
108
- return;
109
- }
110
-
111
- if (aborted) return;
85
+ // Validate notebook structure
86
+ if (!notebook.cells || !Array.isArray(notebook.cells)) {
87
+ throw new Error(`Invalid notebook structure (missing cells array): ${notebook_path}`);
88
+ }
112
89
 
113
- // Validate notebook structure
114
- if (!notebook.cells || !Array.isArray(notebook.cells)) {
115
- if (signal) signal.removeEventListener("abort", onAbort);
116
- reject(new Error(`Invalid notebook structure (missing cells array): ${notebook_path}`));
117
- return;
118
- }
90
+ const cellCount = notebook.cells.length;
119
91
 
120
- const cellCount = notebook.cells.length;
121
-
122
- // Validate cell_index based on action
123
- if (action === "insert") {
124
- if (cell_index < 0 || cell_index > cellCount) {
125
- if (signal) signal.removeEventListener("abort", onAbort);
126
- reject(
127
- new Error(
128
- `Cell index ${cell_index} out of range for insert (0-${cellCount}) in ${notebook_path}`,
129
- ),
130
- );
131
- return;
132
- }
133
- } else {
134
- if (cell_index < 0 || cell_index >= cellCount) {
135
- if (signal) signal.removeEventListener("abort", onAbort);
136
- reject(
137
- new Error(`Cell index ${cell_index} out of range (0-${cellCount - 1}) in ${notebook_path}`),
138
- );
139
- return;
140
- }
141
- }
92
+ // Validate cell_index based on action
93
+ if (action === "insert") {
94
+ if (cell_index < 0 || cell_index > cellCount) {
95
+ throw new Error(
96
+ `Cell index ${cell_index} out of range for insert (0-${cellCount}) in ${notebook_path}`,
97
+ );
98
+ }
99
+ } else {
100
+ if (cell_index < 0 || cell_index >= cellCount) {
101
+ throw new Error(`Cell index ${cell_index} out of range (0-${cellCount - 1}) in ${notebook_path}`);
102
+ }
103
+ }
142
104
 
143
- // Validate content for edit/insert
144
- if ((action === "edit" || action === "insert") && content === undefined) {
145
- if (signal) signal.removeEventListener("abort", onAbort);
146
- reject(new Error(`Content is required for ${action} action`));
147
- return;
148
- }
105
+ // Validate content for edit/insert
106
+ if ((action === "edit" || action === "insert") && content === undefined) {
107
+ throw new Error(`Content is required for ${action} action`);
108
+ }
149
109
 
150
- if (aborted) return;
151
-
152
- // Perform the action
153
- let resultMessage: string;
154
- let finalCellType: string | undefined;
155
-
156
- switch (action) {
157
- case "edit": {
158
- const sourceLines = splitIntoLines(content!);
159
- notebook.cells[cell_index].source = sourceLines;
160
- finalCellType = notebook.cells[cell_index].cell_type;
161
- resultMessage = `Replaced cell ${cell_index} (${finalCellType})`;
162
- break;
163
- }
164
- case "insert": {
165
- const sourceLines = splitIntoLines(content!);
166
- const newCellType = (cell_type as "code" | "markdown") || "code";
167
- const newCell: NotebookCell = {
168
- cell_type: newCellType,
169
- source: sourceLines,
170
- metadata: {},
171
- };
172
- if (newCellType === "code") {
173
- newCell.execution_count = null;
174
- newCell.outputs = [];
175
- }
176
- notebook.cells.splice(cell_index, 0, newCell);
177
- finalCellType = newCellType;
178
- resultMessage = `Inserted ${newCellType} cell at position ${cell_index}`;
179
- break;
180
- }
181
- case "delete": {
182
- finalCellType = notebook.cells[cell_index].cell_type;
183
- notebook.cells.splice(cell_index, 1);
184
- resultMessage = `Deleted cell ${cell_index} (${finalCellType})`;
185
- break;
186
- }
187
- default: {
188
- if (signal) signal.removeEventListener("abort", onAbort);
189
- reject(new Error(`Invalid action: ${action}`));
190
- return;
191
- }
110
+ // Perform the action
111
+ let resultMessage: string;
112
+ let finalCellType: string | undefined;
113
+
114
+ switch (action) {
115
+ case "edit": {
116
+ const sourceLines = splitIntoLines(content!);
117
+ notebook.cells[cell_index].source = sourceLines;
118
+ finalCellType = notebook.cells[cell_index].cell_type;
119
+ resultMessage = `Replaced cell ${cell_index} (${finalCellType})`;
120
+ break;
121
+ }
122
+ case "insert": {
123
+ const sourceLines = splitIntoLines(content!);
124
+ const newCellType = (cell_type as "code" | "markdown") || "code";
125
+ const newCell: NotebookCell = {
126
+ cell_type: newCellType,
127
+ source: sourceLines,
128
+ metadata: {},
129
+ };
130
+ if (newCellType === "code") {
131
+ newCell.execution_count = null;
132
+ newCell.outputs = [];
192
133
  }
193
-
194
- if (aborted) return;
195
-
196
- // Write back with single-space indentation
197
- await Bun.write(absolutePath, JSON.stringify(notebook, null, 1));
198
-
199
- if (aborted) return;
200
-
201
- if (signal) signal.removeEventListener("abort", onAbort);
202
-
203
- const newCellCount = notebook.cells.length;
204
- resolve({
205
- content: [
206
- {
207
- type: "text",
208
- text: `${resultMessage}. Notebook now has ${newCellCount} cells.`,
209
- },
210
- ],
211
- details: {
212
- action: action as "edit" | "insert" | "delete",
213
- cellIndex: cell_index,
214
- cellType: finalCellType,
215
- totalCells: newCellCount,
216
- },
217
- });
218
- } catch (error: any) {
219
- if (signal) signal.removeEventListener("abort", onAbort);
220
- if (!aborted) reject(error);
134
+ notebook.cells.splice(cell_index, 0, newCell);
135
+ finalCellType = newCellType;
136
+ resultMessage = `Inserted ${newCellType} cell at position ${cell_index}`;
137
+ break;
221
138
  }
222
- })();
139
+ case "delete": {
140
+ finalCellType = notebook.cells[cell_index].cell_type;
141
+ notebook.cells.splice(cell_index, 1);
142
+ resultMessage = `Deleted cell ${cell_index} (${finalCellType})`;
143
+ break;
144
+ }
145
+ default: {
146
+ throw new Error(`Invalid action: ${action}`);
147
+ }
148
+ }
149
+
150
+ // Write back with single-space indentation
151
+ await Bun.write(absolutePath, JSON.stringify(notebook, null, 1));
152
+
153
+ const newCellCount = notebook.cells.length;
154
+ return {
155
+ content: [
156
+ {
157
+ type: "text",
158
+ text: `${resultMessage}. Notebook now has ${newCellCount} cells.`,
159
+ },
160
+ ],
161
+ details: {
162
+ action: action as "edit" | "insert" | "delete",
163
+ cellIndex: cell_index,
164
+ cellType: finalCellType,
165
+ totalCells: newCellCount,
166
+ },
167
+ };
223
168
  });
224
169
  },
225
170
  };