@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.
- package/CHANGELOG.md +29 -0
- package/package.json +5 -4
- package/src/core/bash-executor.ts +115 -154
- package/src/core/index.ts +2 -0
- package/src/core/session-manager.ts +113 -74
- package/src/core/tools/edit-diff.ts +45 -33
- package/src/core/tools/edit.ts +70 -182
- package/src/core/tools/find.ts +141 -160
- package/src/core/tools/index.ts +10 -9
- package/src/core/tools/ls.ts +64 -82
- package/src/core/tools/lsp/client.ts +63 -0
- package/src/core/tools/lsp/edits.ts +13 -4
- package/src/core/tools/lsp/index.ts +191 -85
- package/src/core/tools/notebook.ts +89 -144
- package/src/core/tools/read.ts +110 -158
- package/src/core/tools/write.ts +22 -115
- package/src/core/utils.ts +187 -0
- package/src/modes/interactive/components/tool-execution.ts +14 -14
- package/src/modes/interactive/interactive-mode.ts +23 -54
- package/src/modes/rpc/rpc-mode.ts +8 -7
|
@@ -8,11 +8,10 @@ import { uriToFile } from "./utils";
|
|
|
8
8
|
// =============================================================================
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Apply text edits to a
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
370
|
+
server?: string;
|
|
325
371
|
/** Formatted diagnostic messages */
|
|
326
|
-
|
|
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
|
-
|
|
331
|
-
/** Whether
|
|
332
|
-
|
|
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
|
|
337
|
-
*
|
|
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
|
|
342
|
-
* @returns Diagnostic results or
|
|
387
|
+
* @param servers - Servers to query diagnostics for
|
|
388
|
+
* @returns Diagnostic results or undefined if no servers
|
|
343
389
|
*/
|
|
344
|
-
|
|
390
|
+
async function getDiagnosticsForFile(
|
|
345
391
|
absolutePath: string,
|
|
346
392
|
cwd: string,
|
|
347
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
372
|
-
const diagnostics = await waitForDiagnostics(client, uri
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
455
|
-
*
|
|
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
|
|
476
|
+
* @param absolutePath - Absolute path (for URI)
|
|
477
|
+
* @param content - Content to format
|
|
458
478
|
* @param cwd - Working directory for LSP config resolution
|
|
459
|
-
* @
|
|
479
|
+
* @param servers - Servers to try formatting with
|
|
480
|
+
* @returns Formatted content, or original if no formatter available
|
|
460
481
|
*/
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
|
489
|
+
return content;
|
|
467
490
|
}
|
|
468
491
|
|
|
469
492
|
const uri = fileToUri(absolutePath);
|
|
470
493
|
|
|
471
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
494
|
-
return { available: true, serverName, formatted: false };
|
|
510
|
+
return content;
|
|
495
511
|
}
|
|
496
512
|
|
|
497
|
-
// Apply
|
|
498
|
-
|
|
513
|
+
// Apply edits in-memory and return
|
|
514
|
+
return applyTextEditsToString(content, edits);
|
|
515
|
+
} catch {}
|
|
516
|
+
}
|
|
499
517
|
|
|
500
|
-
|
|
501
|
-
|
|
518
|
+
return content;
|
|
519
|
+
}
|
|
502
520
|
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
508
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
};
|