@oh-my-pi/pi-coding-agent 14.7.3 → 14.7.4
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 +21 -0
- package/package.json +7 -7
- package/src/cli/read-cli.ts +1 -2
- package/src/commands/read.ts +2 -7
- package/src/config/settings-schema.ts +0 -5
- package/src/edit/modes/hashline.ts +40 -19
- package/src/edit/modes/patch.ts +7 -5
- package/src/edit/modes/replace.ts +6 -2
- package/src/edit/notebook.ts +222 -0
- package/src/edit/read-file.ts +7 -0
- package/src/edit/renderer.ts +4 -3
- package/src/edit/streaming.ts +49 -7
- package/src/modes/components/diff.ts +54 -7
- package/src/prompts/agents/designer.md +1 -2
- package/src/prompts/agents/explore.md +2 -5
- package/src/prompts/agents/init.md +1 -4
- package/src/prompts/agents/librarian.md +1 -3
- package/src/prompts/agents/plan.md +7 -8
- package/src/prompts/agents/reviewer.md +1 -2
- package/src/prompts/ci-green-request.md +10 -10
- package/src/prompts/commands/orchestrate.md +48 -0
- package/src/prompts/memories/consolidation.md +10 -10
- package/src/prompts/memories/read-path.md +6 -6
- package/src/prompts/system/agent-creation-architect.md +54 -44
- package/src/prompts/system/custom-system-prompt.md +3 -5
- package/src/prompts/system/eager-todo.md +4 -4
- package/src/prompts/system/handoff-document.md +7 -4
- package/src/prompts/system/plan-mode-active.md +7 -3
- package/src/prompts/system/plan-mode-approved.md +5 -5
- package/src/prompts/system/summarization-system.md +2 -2
- package/src/prompts/system/system-prompt.md +53 -65
- package/src/prompts/system/title-system.md +2 -2
- package/src/prompts/system/web-search.md +16 -19
- package/src/prompts/tools/bash.md +8 -8
- package/src/prompts/tools/browser.md +4 -4
- package/src/prompts/tools/debug.md +3 -1
- package/src/prompts/tools/eval.md +13 -9
- package/src/prompts/tools/hashline.md +4 -2
- package/src/prompts/tools/image-gen.md +1 -1
- package/src/prompts/tools/read.md +1 -2
- package/src/prompts/tools/reflect.md +3 -3
- package/src/prompts/tools/render-mermaid.md +2 -2
- package/src/prompts/tools/resolve.md +2 -2
- package/src/prompts/tools/retain.md +3 -2
- package/src/prompts/tools/rewind.md +2 -2
- package/src/prompts/tools/search-tool-bm25.md +3 -4
- package/src/prompts/tools/task.md +1 -1
- package/src/task/commands.ts +5 -1
- package/src/tools/fetch.ts +6 -7
- package/src/tools/index.ts +0 -4
- package/src/tools/read.ts +18 -7
- package/src/tools/renderers.ts +0 -2
- package/src/tools/write.ts +41 -26
- package/src/tools/notebook.ts +0 -286
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [14.7.4] - 2026-05-07
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Removed the dedicated `notebook` tool; `.ipynb` reads and edits now go through `read` and `edit`.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Changed diff previews to syntax-highlight contiguous context lines in the unchanged sections when file language can be detected
|
|
14
|
+
- Changed `read` tool behavior for `.ipynb:raw` requests to return raw notebook content instead of converting via markit
|
|
15
|
+
- Changed `.ipynb` edit and read handling to route through notebook serialization helpers
|
|
16
|
+
- Changed `.ipynb` reads to return an editable cell text representation and apply edits back to notebook JSON while preserving cell metadata and outputs where possible.
|
|
17
|
+
|
|
18
|
+
### Removed
|
|
19
|
+
|
|
20
|
+
- Removed the `notebook.enabled` configuration option from tool settings
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Fixed hashline edit streaming preview collapsing to a header-only "opaque box" when a second `@PATH` section header arrived mid-stream — earlier completed sections now stay rendered while the trailing section is still being typed.
|
|
25
|
+
|
|
5
26
|
## [14.7.2] - 2026-05-06
|
|
6
27
|
### Breaking Changes
|
|
7
28
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "14.7.
|
|
4
|
+
"version": "14.7.4",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -46,12 +46,12 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@agentclientprotocol/sdk": "0.21.0",
|
|
48
48
|
"@mozilla/readability": "^0.6.0",
|
|
49
|
-
"@oh-my-pi/omp-stats": "14.7.
|
|
50
|
-
"@oh-my-pi/pi-agent-core": "14.7.
|
|
51
|
-
"@oh-my-pi/pi-ai": "14.7.
|
|
52
|
-
"@oh-my-pi/pi-natives": "14.7.
|
|
53
|
-
"@oh-my-pi/pi-tui": "14.7.
|
|
54
|
-
"@oh-my-pi/pi-utils": "14.7.
|
|
49
|
+
"@oh-my-pi/omp-stats": "14.7.4",
|
|
50
|
+
"@oh-my-pi/pi-agent-core": "14.7.4",
|
|
51
|
+
"@oh-my-pi/pi-ai": "14.7.4",
|
|
52
|
+
"@oh-my-pi/pi-natives": "14.7.4",
|
|
53
|
+
"@oh-my-pi/pi-tui": "14.7.4",
|
|
54
|
+
"@oh-my-pi/pi-utils": "14.7.4",
|
|
55
55
|
"@puppeteer/browsers": "^2.13.0",
|
|
56
56
|
"@sinclair/typebox": "^0.34.49",
|
|
57
57
|
"@types/turndown": "5.0.6",
|
package/src/cli/read-cli.ts
CHANGED
|
@@ -15,7 +15,6 @@ import { renderError } from "../tools/tool-errors";
|
|
|
15
15
|
|
|
16
16
|
export interface ReadCommandArgs {
|
|
17
17
|
path: string;
|
|
18
|
-
timeout?: number;
|
|
19
18
|
}
|
|
20
19
|
|
|
21
20
|
export async function runReadCommand(cmd: ReadCommandArgs): Promise<void> {
|
|
@@ -38,7 +37,7 @@ export async function runReadCommand(cmd: ReadCommandArgs): Promise<void> {
|
|
|
38
37
|
const tool = wrapToolWithMetaNotice(new ReadTool(session));
|
|
39
38
|
|
|
40
39
|
try {
|
|
41
|
-
const result = await tool.execute("omp-read", { path: cmd.path
|
|
40
|
+
const result = await tool.execute("omp-read", { path: cmd.path });
|
|
42
41
|
|
|
43
42
|
for (const block of result.content) {
|
|
44
43
|
if (block.type === "text") {
|
package/src/commands/read.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Show what the read tool will return for a given path.
|
|
3
3
|
*/
|
|
4
|
-
import { Args, Command
|
|
4
|
+
import { Args, Command } from "@oh-my-pi/pi-utils/cli";
|
|
5
5
|
import { type ReadCommandArgs, runReadCommand } from "../cli/read-cli";
|
|
6
6
|
import { initTheme } from "../modes/theme/theme";
|
|
7
7
|
|
|
@@ -15,10 +15,6 @@ export default class Read extends Command {
|
|
|
15
15
|
}),
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
static flags = {
|
|
19
|
-
timeout: Flags.integer({ description: "Request timeout in seconds (URLs only)" }),
|
|
20
|
-
};
|
|
21
|
-
|
|
22
18
|
static examples = [
|
|
23
19
|
"omp read src/foo.ts",
|
|
24
20
|
"omp read src/foo.ts:50-100",
|
|
@@ -29,10 +25,9 @@ export default class Read extends Command {
|
|
|
29
25
|
];
|
|
30
26
|
|
|
31
27
|
async run(): Promise<void> {
|
|
32
|
-
const { args
|
|
28
|
+
const { args } = await this.parse(Read);
|
|
33
29
|
const cmd: ReadCommandArgs = {
|
|
34
30
|
path: args.path ?? "",
|
|
35
|
-
timeout: flags.timeout,
|
|
36
31
|
};
|
|
37
32
|
await initTheme();
|
|
38
33
|
await runReadCommand(cmd);
|
|
@@ -1756,11 +1756,6 @@ export const SETTINGS_SCHEMA = {
|
|
|
1756
1756
|
},
|
|
1757
1757
|
|
|
1758
1758
|
// Optional tools
|
|
1759
|
-
"notebook.enabled": {
|
|
1760
|
-
type: "boolean",
|
|
1761
|
-
default: true,
|
|
1762
|
-
ui: { tab: "tools", label: "Notebook", description: "Enable the notebook tool for notebook editing" },
|
|
1763
|
-
},
|
|
1764
1759
|
|
|
1765
1760
|
"renderMermaid.enabled": {
|
|
1766
1761
|
type: "boolean",
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
HL_HASH_CAPTURE_RE_RAW,
|
|
53
53
|
} from "../line-hash";
|
|
54
54
|
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
|
|
55
|
+
import { readEditFileText, serializeEditFileText } from "../read-file";
|
|
55
56
|
import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
|
56
57
|
|
|
57
58
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -1547,7 +1548,7 @@ export function applyHashlineEdits(
|
|
|
1547
1548
|
// but no header, we synthesize one from the caller-supplied `path` option.
|
|
1548
1549
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1549
1550
|
|
|
1550
|
-
interface HashlineInputSection {
|
|
1551
|
+
export interface HashlineInputSection {
|
|
1551
1552
|
path: string;
|
|
1552
1553
|
diff: string;
|
|
1553
1554
|
}
|
|
@@ -1588,7 +1589,7 @@ function stripLeadingBlankLines(input: string): string {
|
|
|
1588
1589
|
return lines.join("\n");
|
|
1589
1590
|
}
|
|
1590
1591
|
|
|
1591
|
-
function containsRecognizableHashlineOperations(input: string): boolean {
|
|
1592
|
+
export function containsRecognizableHashlineOperations(input: string): boolean {
|
|
1592
1593
|
for (const rawLine of input.split("\n")) {
|
|
1593
1594
|
const line = stripTrailingCarriageReturn(rawLine);
|
|
1594
1595
|
if (/^[+<=-]\s+/.test(line) || line.startsWith(HL_EDIT_SEP)) return true;
|
|
@@ -1656,30 +1657,27 @@ export function splitHashlineInputs(input: string, options: SplitHashlineOptions
|
|
|
1656
1657
|
// 13. Diff computation (for streaming preview)
|
|
1657
1658
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1658
1659
|
|
|
1659
|
-
async function readHashlineFileText(
|
|
1660
|
+
async function readHashlineFileText(
|
|
1661
|
+
_file: { text(): Promise<string> },
|
|
1662
|
+
absolutePath: string,
|
|
1663
|
+
pathText: string,
|
|
1664
|
+
): Promise<string> {
|
|
1660
1665
|
try {
|
|
1661
|
-
return await
|
|
1666
|
+
return await readEditFileText(absolutePath, pathText);
|
|
1662
1667
|
} catch (error) {
|
|
1663
|
-
if (isEnoent(error)) throw new Error(`File not found: ${pathText}`);
|
|
1664
1668
|
const message = error instanceof Error ? error.message : String(error);
|
|
1665
1669
|
throw new Error(message || `Unable to read ${pathText}`);
|
|
1666
1670
|
}
|
|
1667
1671
|
}
|
|
1668
1672
|
|
|
1669
|
-
export async function
|
|
1670
|
-
|
|
1673
|
+
export async function computeHashlineSectionDiff(
|
|
1674
|
+
section: HashlineInputSection,
|
|
1671
1675
|
cwd: string,
|
|
1672
1676
|
options: HashlineApplyOptions = {},
|
|
1673
1677
|
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
|
1674
1678
|
try {
|
|
1675
|
-
const sections = splitHashlineInputs(input.input, { cwd, path: input.path });
|
|
1676
|
-
if (sections.length !== 1) {
|
|
1677
|
-
return { error: "Streaming diff preview supports exactly one hashline section." };
|
|
1678
|
-
}
|
|
1679
|
-
const [section] = sections;
|
|
1680
|
-
|
|
1681
1679
|
const absolutePath = resolveToCwd(section.path, cwd);
|
|
1682
|
-
const rawContent = await readHashlineFileText(Bun.file(absolutePath), section.path);
|
|
1680
|
+
const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path);
|
|
1683
1681
|
const { text: content } = stripBom(rawContent);
|
|
1684
1682
|
const normalized = normalizeToLF(content);
|
|
1685
1683
|
const result = applyHashlineEdits(normalized, parseHashline(section.diff), options);
|
|
@@ -1690,6 +1688,23 @@ export async function computeHashlineDiff(
|
|
|
1690
1688
|
}
|
|
1691
1689
|
}
|
|
1692
1690
|
|
|
1691
|
+
export async function computeHashlineDiff(
|
|
1692
|
+
input: { input: string; path?: string },
|
|
1693
|
+
cwd: string,
|
|
1694
|
+
options: HashlineApplyOptions = {},
|
|
1695
|
+
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
|
1696
|
+
let sections: HashlineInputSection[];
|
|
1697
|
+
try {
|
|
1698
|
+
sections = splitHashlineInputs(input.input, { cwd, path: input.path });
|
|
1699
|
+
} catch (err) {
|
|
1700
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
1701
|
+
}
|
|
1702
|
+
if (sections.length !== 1) {
|
|
1703
|
+
return { error: "Streaming diff preview supports exactly one hashline section." };
|
|
1704
|
+
}
|
|
1705
|
+
return computeHashlineSectionDiff(sections[0], cwd, options);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1693
1708
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1694
1709
|
// 14. Execution
|
|
1695
1710
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -1699,11 +1714,13 @@ interface ReadHashlineFileResult {
|
|
|
1699
1714
|
rawContent: string;
|
|
1700
1715
|
}
|
|
1701
1716
|
|
|
1702
|
-
async function readHashlineFile(absolutePath: string): Promise<ReadHashlineFileResult> {
|
|
1717
|
+
async function readHashlineFile(absolutePath: string, pathText: string): Promise<ReadHashlineFileResult> {
|
|
1703
1718
|
try {
|
|
1704
|
-
return { exists: true, rawContent: await
|
|
1719
|
+
return { exists: true, rawContent: await readEditFileText(absolutePath, pathText) };
|
|
1705
1720
|
} catch (error) {
|
|
1706
1721
|
if (isEnoent(error)) return { exists: false, rawContent: "" };
|
|
1722
|
+
if (error instanceof Error && error.message === `File not found: ${pathText}`)
|
|
1723
|
+
return { exists: false, rawContent: "" };
|
|
1707
1724
|
throw error;
|
|
1708
1725
|
}
|
|
1709
1726
|
}
|
|
@@ -1746,7 +1763,7 @@ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions &
|
|
|
1746
1763
|
const { edits } = parseHashlineWithWarnings(diff);
|
|
1747
1764
|
enforcePlanModeWrite(session, sectionPath, { op: "update" });
|
|
1748
1765
|
|
|
1749
|
-
const source = await readHashlineFile(absolutePath);
|
|
1766
|
+
const source = await readHashlineFile(absolutePath, sectionPath);
|
|
1750
1767
|
if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sectionPath}`);
|
|
1751
1768
|
if (source.exists) assertEditableFileContent(source.rawContent, sectionPath);
|
|
1752
1769
|
|
|
@@ -1773,7 +1790,7 @@ async function executeHashlineSection(
|
|
|
1773
1790
|
const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff);
|
|
1774
1791
|
enforcePlanModeWrite(session, sourcePath, { op: "update" });
|
|
1775
1792
|
|
|
1776
|
-
const source = await readHashlineFile(absolutePath);
|
|
1793
|
+
const source = await readHashlineFile(absolutePath, sourcePath);
|
|
1777
1794
|
if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sourcePath}`);
|
|
1778
1795
|
if (source.exists) assertEditableFileContent(source.rawContent, sourcePath);
|
|
1779
1796
|
|
|
@@ -1789,7 +1806,11 @@ async function executeHashlineSection(
|
|
|
1789
1806
|
};
|
|
1790
1807
|
}
|
|
1791
1808
|
|
|
1792
|
-
const finalContent =
|
|
1809
|
+
const finalContent = await serializeEditFileText(
|
|
1810
|
+
absolutePath,
|
|
1811
|
+
sourcePath,
|
|
1812
|
+
bom + restoreLineEndings(result.lines, originalEnding),
|
|
1813
|
+
);
|
|
1793
1814
|
const diagnostics = await writethrough(
|
|
1794
1815
|
absolutePath,
|
|
1795
1816
|
finalContent,
|
package/src/edit/modes/patch.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
restoreLineEndings,
|
|
45
45
|
stripBom,
|
|
46
46
|
} from "../normalize";
|
|
47
|
+
import { readEditFileText, serializeEditFileText } from "../read-file";
|
|
47
48
|
import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
|
48
49
|
import {
|
|
49
50
|
type ContextLineResult,
|
|
@@ -104,13 +105,13 @@ export const defaultFileSystem: FileSystem = {
|
|
|
104
105
|
return Bun.file(path).exists();
|
|
105
106
|
},
|
|
106
107
|
async read(path: string): Promise<string> {
|
|
107
|
-
return
|
|
108
|
+
return readEditFileText(path, path);
|
|
108
109
|
},
|
|
109
110
|
async readBinary(path: string): Promise<Uint8Array> {
|
|
110
111
|
return fs.promises.readFile(path);
|
|
111
112
|
},
|
|
112
113
|
async write(path: string, content: string): Promise<void> {
|
|
113
|
-
await Bun.write(path, content);
|
|
114
|
+
await Bun.write(path, await serializeEditFileText(path, path, content));
|
|
114
115
|
},
|
|
115
116
|
async delete(path: string): Promise<void> {
|
|
116
117
|
await fs.promises.unlink(path);
|
|
@@ -999,7 +1000,7 @@ async function readExistingPatchFile(fileSystem: FileSystem, absolutePath: strin
|
|
|
999
1000
|
try {
|
|
1000
1001
|
return await fileSystem.read(absolutePath);
|
|
1001
1002
|
} catch (error) {
|
|
1002
|
-
if (isEnoent(error)) {
|
|
1003
|
+
if (isEnoent(error) || (error instanceof Error && error.message.startsWith("File not found:"))) {
|
|
1003
1004
|
throw new ApplyPatchError(`File not found: ${path}`);
|
|
1004
1005
|
}
|
|
1005
1006
|
throw error;
|
|
@@ -1637,7 +1638,7 @@ class LspFileSystem implements FileSystem {
|
|
|
1637
1638
|
}
|
|
1638
1639
|
|
|
1639
1640
|
async read(path: string): Promise<string> {
|
|
1640
|
-
return
|
|
1641
|
+
return readEditFileText(path, path);
|
|
1641
1642
|
}
|
|
1642
1643
|
|
|
1643
1644
|
async readBinary(path: string): Promise<Uint8Array> {
|
|
@@ -1647,10 +1648,11 @@ class LspFileSystem implements FileSystem {
|
|
|
1647
1648
|
|
|
1648
1649
|
async write(path: string, content: string): Promise<void> {
|
|
1649
1650
|
const file = this.#getFile(path);
|
|
1651
|
+
const finalContent = await serializeEditFileText(path, path, content);
|
|
1650
1652
|
const deferredForPath = this.deferredForPath;
|
|
1651
1653
|
const result = await this.writethrough(
|
|
1652
1654
|
path,
|
|
1653
|
-
|
|
1655
|
+
finalContent,
|
|
1654
1656
|
this.signal,
|
|
1655
1657
|
file,
|
|
1656
1658
|
this.batchRequest,
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
restoreLineEndings,
|
|
22
22
|
stripBom,
|
|
23
23
|
} from "../normalize";
|
|
24
|
-
import { readEditFileText } from "../read-file";
|
|
24
|
+
import { readEditFileText, serializeEditFileText } from "../read-file";
|
|
25
25
|
import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
|
26
26
|
|
|
27
27
|
export interface FuzzyMatch {
|
|
@@ -1065,7 +1065,11 @@ export async function executeReplaceSingle(
|
|
|
1065
1065
|
throw new Error(`Edits to ${path} resulted in no changes being made.`);
|
|
1066
1066
|
}
|
|
1067
1067
|
|
|
1068
|
-
const finalContent =
|
|
1068
|
+
const finalContent = await serializeEditFileText(
|
|
1069
|
+
absolutePath,
|
|
1070
|
+
path,
|
|
1071
|
+
bom + restoreLineEndings(result.content, originalEnding),
|
|
1072
|
+
);
|
|
1069
1073
|
const diagnostics = await writethrough(
|
|
1070
1074
|
absolutePath,
|
|
1071
1075
|
finalContent,
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
3
|
+
|
|
4
|
+
export type NotebookCellType = "code" | "markdown" | "raw";
|
|
5
|
+
|
|
6
|
+
export interface NotebookCell {
|
|
7
|
+
cell_type: NotebookCellType;
|
|
8
|
+
source?: string | string[];
|
|
9
|
+
metadata?: Record<string, unknown>;
|
|
10
|
+
execution_count?: number | null;
|
|
11
|
+
outputs?: unknown[];
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface NotebookDocument {
|
|
16
|
+
cells: NotebookCell[];
|
|
17
|
+
metadata: Record<string, unknown>;
|
|
18
|
+
nbformat: number;
|
|
19
|
+
nbformat_minor: number;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const CELL_MARKER_RE = /^# %% \[(code|markdown|raw)\](?: cell:(\d+))?$/;
|
|
24
|
+
|
|
25
|
+
export function isNotebookPath(filePath: string): boolean {
|
|
26
|
+
return path.extname(filePath).toLowerCase() === ".ipynb";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
30
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isCellType(value: unknown): value is NotebookCellType {
|
|
34
|
+
return value === "code" || value === "markdown" || value === "raw";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sourceToText(source: string | string[] | undefined): string {
|
|
38
|
+
if (source === undefined) return "";
|
|
39
|
+
if (typeof source === "string") return source;
|
|
40
|
+
return source.join("");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function splitNotebookSource(content: string): string[] {
|
|
44
|
+
if (content.length === 0) return [];
|
|
45
|
+
return content.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cloneCell(cell: NotebookCell): NotebookCell {
|
|
49
|
+
return structuredClone(cell);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createNotebookCell(cellType: NotebookCellType, source: string): NotebookCell {
|
|
53
|
+
const cell: NotebookCell = {
|
|
54
|
+
cell_type: cellType,
|
|
55
|
+
metadata: {},
|
|
56
|
+
source: splitNotebookSource(source),
|
|
57
|
+
};
|
|
58
|
+
if (cellType === "code") {
|
|
59
|
+
cell.execution_count = null;
|
|
60
|
+
cell.outputs = [];
|
|
61
|
+
}
|
|
62
|
+
return cell;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createEmptyNotebook(): NotebookDocument {
|
|
66
|
+
return {
|
|
67
|
+
cells: [],
|
|
68
|
+
metadata: {},
|
|
69
|
+
nbformat: 4,
|
|
70
|
+
nbformat_minor: 5,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateNotebook(value: unknown, displayPath: string): NotebookDocument {
|
|
75
|
+
if (!isRecord(value)) {
|
|
76
|
+
throw new Error(`Invalid notebook structure (expected object): ${displayPath}`);
|
|
77
|
+
}
|
|
78
|
+
if (!Array.isArray(value.cells)) {
|
|
79
|
+
throw new Error(`Invalid notebook structure (missing cells array): ${displayPath}`);
|
|
80
|
+
}
|
|
81
|
+
for (let index = 0; index < value.cells.length; index++) {
|
|
82
|
+
const cell = value.cells[index];
|
|
83
|
+
if (!isRecord(cell) || !isCellType(cell.cell_type)) {
|
|
84
|
+
throw new Error(`Invalid notebook cell ${index} in ${displayPath}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return value as unknown as NotebookDocument;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function readNotebookDocument(absolutePath: string, displayPath: string): Promise<NotebookDocument> {
|
|
91
|
+
try {
|
|
92
|
+
return validateNotebook(await Bun.file(absolutePath).json(), displayPath);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (isEnoent(error)) throw new Error(`File not found: ${displayPath}`);
|
|
95
|
+
if (error instanceof SyntaxError) throw new Error(`Invalid JSON in notebook: ${displayPath}`);
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function notebookToEditableText(notebook: NotebookDocument): string {
|
|
101
|
+
return notebook.cells
|
|
102
|
+
.map((cell, index) => {
|
|
103
|
+
const source = sourceToText(cell.source);
|
|
104
|
+
return source.length > 0
|
|
105
|
+
? `# %% [${cell.cell_type}] cell:${index}\n${source}`
|
|
106
|
+
: `# %% [${cell.cell_type}] cell:${index}`;
|
|
107
|
+
})
|
|
108
|
+
.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ParsedVirtualCell {
|
|
112
|
+
cellType: NotebookCellType;
|
|
113
|
+
cellIndex?: number;
|
|
114
|
+
source: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseVirtualCellMarker(line: string): { cellType: NotebookCellType; cellIndex?: number } | undefined {
|
|
118
|
+
const match = CELL_MARKER_RE.exec(line);
|
|
119
|
+
if (!match) return undefined;
|
|
120
|
+
const cellType = match[1] as NotebookCellType;
|
|
121
|
+
const cellIndexText = match[2];
|
|
122
|
+
return {
|
|
123
|
+
cellType,
|
|
124
|
+
cellIndex: cellIndexText === undefined ? undefined : Number.parseInt(cellIndexText, 10),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function linesToSourceText(lines: string[]): string {
|
|
129
|
+
if (lines.length === 0) return "";
|
|
130
|
+
return lines.join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseNotebookEditableText(text: string, displayPath: string): ParsedVirtualCell[] {
|
|
134
|
+
const lines = text.length === 0 ? [] : text.split("\n");
|
|
135
|
+
const cells: ParsedVirtualCell[] = [];
|
|
136
|
+
let current: { cellType: NotebookCellType; cellIndex?: number; lines: string[] } | undefined;
|
|
137
|
+
|
|
138
|
+
const flush = () => {
|
|
139
|
+
if (!current) return;
|
|
140
|
+
cells.push({
|
|
141
|
+
cellType: current.cellType,
|
|
142
|
+
cellIndex: current.cellIndex,
|
|
143
|
+
source: linesToSourceText(current.lines),
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
const marker = parseVirtualCellMarker(line);
|
|
149
|
+
if (marker) {
|
|
150
|
+
flush();
|
|
151
|
+
current = { ...marker, lines: [] };
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!current) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Invalid notebook editable representation for ${displayPath}: expected first line to be "# %% [code] cell:0", "# %% [markdown] cell:0", or "# %% [raw] cell:0".`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
current.lines.push(line);
|
|
160
|
+
}
|
|
161
|
+
flush();
|
|
162
|
+
return cells;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function applyNotebookEditableText(
|
|
166
|
+
notebook: NotebookDocument,
|
|
167
|
+
text: string,
|
|
168
|
+
displayPath: string,
|
|
169
|
+
): NotebookDocument {
|
|
170
|
+
const parsedCells = parseNotebookEditableText(text, displayPath);
|
|
171
|
+
const usedOriginalCells = new Set<number>();
|
|
172
|
+
const nextNotebook = structuredClone(notebook);
|
|
173
|
+
nextNotebook.cells = parsedCells.map(parsedCell => {
|
|
174
|
+
const originalIndex = parsedCell.cellIndex;
|
|
175
|
+
const originalCell =
|
|
176
|
+
originalIndex !== undefined &&
|
|
177
|
+
originalIndex >= 0 &&
|
|
178
|
+
originalIndex < notebook.cells.length &&
|
|
179
|
+
!usedOriginalCells.has(originalIndex)
|
|
180
|
+
? notebook.cells[originalIndex]
|
|
181
|
+
: undefined;
|
|
182
|
+
if (originalCell) {
|
|
183
|
+
usedOriginalCells.add(originalIndex!);
|
|
184
|
+
const cell = cloneCell(originalCell);
|
|
185
|
+
cell.cell_type = parsedCell.cellType;
|
|
186
|
+
cell.source = splitNotebookSource(parsedCell.source);
|
|
187
|
+
if (parsedCell.cellType === "code") {
|
|
188
|
+
cell.execution_count ??= null;
|
|
189
|
+
cell.outputs ??= [];
|
|
190
|
+
} else {
|
|
191
|
+
delete cell.execution_count;
|
|
192
|
+
delete cell.outputs;
|
|
193
|
+
}
|
|
194
|
+
return cell;
|
|
195
|
+
}
|
|
196
|
+
return createNotebookCell(parsedCell.cellType, parsedCell.source);
|
|
197
|
+
});
|
|
198
|
+
return nextNotebook;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function readEditableNotebookText(absolutePath: string, displayPath: string): Promise<string> {
|
|
202
|
+
return notebookToEditableText(await readNotebookDocument(absolutePath, displayPath));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function serializeEditedNotebookText(
|
|
206
|
+
absolutePath: string,
|
|
207
|
+
displayPath: string,
|
|
208
|
+
text: string,
|
|
209
|
+
): Promise<string> {
|
|
210
|
+
let notebook: NotebookDocument;
|
|
211
|
+
try {
|
|
212
|
+
notebook = await readNotebookDocument(absolutePath, displayPath);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (error instanceof Error && error.message === `File not found: ${displayPath}`) {
|
|
215
|
+
notebook = createEmptyNotebook();
|
|
216
|
+
} else {
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const nextNotebook = applyNotebookEditableText(notebook, text, displayPath);
|
|
221
|
+
return JSON.stringify(nextNotebook, null, 1);
|
|
222
|
+
}
|
package/src/edit/read-file.ts
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* error referencing the display path.
|
|
6
6
|
*/
|
|
7
7
|
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
8
|
+
import { isNotebookPath, readEditableNotebookText, serializeEditedNotebookText } from "./notebook";
|
|
8
9
|
|
|
9
10
|
export async function readEditFileText(absolutePath: string, path: string): Promise<string> {
|
|
10
11
|
try {
|
|
12
|
+
if (isNotebookPath(absolutePath)) return await readEditableNotebookText(absolutePath, path);
|
|
11
13
|
return await Bun.file(absolutePath).text();
|
|
12
14
|
} catch (error) {
|
|
13
15
|
if (isEnoent(error)) {
|
|
@@ -16,3 +18,8 @@ export async function readEditFileText(absolutePath: string, path: string): Prom
|
|
|
16
18
|
throw error;
|
|
17
19
|
}
|
|
18
20
|
}
|
|
21
|
+
|
|
22
|
+
export async function serializeEditFileText(absolutePath: string, path: string, content: string): Promise<string> {
|
|
23
|
+
if (isNotebookPath(absolutePath)) return serializeEditedNotebookText(absolutePath, path, content);
|
|
24
|
+
return content;
|
|
25
|
+
}
|
package/src/edit/renderer.ts
CHANGED
|
@@ -251,11 +251,12 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe
|
|
|
251
251
|
const displayLines = lines.slice(-EDIT_STREAMING_PREVIEW_LINES);
|
|
252
252
|
const hidden = total - displayLines.length;
|
|
253
253
|
let text = "\n\n";
|
|
254
|
+
text += renderDiffColored(displayLines.join("\n"), { filePath: rawPath });
|
|
254
255
|
if (hidden > 0) {
|
|
255
|
-
text += uiTheme.fg("dim",
|
|
256
|
+
text += uiTheme.fg("dim", `\n… (${label} +${hidden} lines)`);
|
|
257
|
+
} else {
|
|
258
|
+
text += uiTheme.fg("dim", `\n(${label})`);
|
|
256
259
|
}
|
|
257
|
-
text += renderDiffColored(displayLines.join("\n"), { filePath: rawPath });
|
|
258
|
-
text += uiTheme.fg("dim", `\n… (${label})`);
|
|
259
260
|
return text;
|
|
260
261
|
}
|
|
261
262
|
|
package/src/edit/streaming.ts
CHANGED
|
@@ -16,7 +16,13 @@ import type { Theme } from "../modes/theme/theme";
|
|
|
16
16
|
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
|
|
17
17
|
import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
|
|
18
18
|
import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
computeHashlineDiff,
|
|
21
|
+
computeHashlineSectionDiff,
|
|
22
|
+
containsRecognizableHashlineOperations,
|
|
23
|
+
type HashlineInputSection,
|
|
24
|
+
splitHashlineInputs,
|
|
25
|
+
} from "./modes/hashline";
|
|
20
26
|
import { computePatchDiff, type PatchEditEntry } from "./modes/patch";
|
|
21
27
|
import type { ReplaceEditEntry } from "./modes/replace";
|
|
22
28
|
|
|
@@ -223,12 +229,48 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
|
223
229
|
async computeDiffPreview(args, ctx) {
|
|
224
230
|
if (typeof args.input !== "string" || args.input.length === 0) return null;
|
|
225
231
|
ctx.signal.throwIfAborted();
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
+
|
|
233
|
+
let sections: HashlineInputSection[];
|
|
234
|
+
try {
|
|
235
|
+
sections = splitHashlineInputs(args.input, { cwd: ctx.cwd, path: args.path });
|
|
236
|
+
} catch {
|
|
237
|
+
// Single-section fallback keeps the original error rendering for the
|
|
238
|
+
// "haven't typed `@PATH` yet" case.
|
|
239
|
+
const result = await computeHashlineDiff({ input: args.input, path: args.path }, ctx.cwd, {
|
|
240
|
+
autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
|
|
241
|
+
});
|
|
242
|
+
ctx.signal.throwIfAborted();
|
|
243
|
+
if ("error" in result && !args.path) return [{ path: "", error: result.error }];
|
|
244
|
+
return [toPerFilePreview(args.path ?? "", result)];
|
|
245
|
+
}
|
|
246
|
+
if (sections.length === 0) return null;
|
|
247
|
+
|
|
248
|
+
// While the trailing section is still being typed (no operations yet)
|
|
249
|
+
// skip it so its empty/parse-error result doesn't replace previews of
|
|
250
|
+
// already-completed sections with an opaque header.
|
|
251
|
+
const lastIndex = sections.length - 1;
|
|
252
|
+
const trailingIncomplete =
|
|
253
|
+
sections.length > 1 && !containsRecognizableHashlineOperations(sections[lastIndex].diff);
|
|
254
|
+
const sectionsToProcess = trailingIncomplete ? sections.slice(0, -1) : sections;
|
|
255
|
+
const trailingProcessedIndex = sectionsToProcess.length - 1;
|
|
256
|
+
|
|
257
|
+
const previews: PerFileDiffPreview[] = [];
|
|
258
|
+
for (let i = 0; i < sectionsToProcess.length; i++) {
|
|
259
|
+
ctx.signal.throwIfAborted();
|
|
260
|
+
const section = sectionsToProcess[i];
|
|
261
|
+
const result = await computeHashlineSectionDiff(section, ctx.cwd, {
|
|
262
|
+
autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
|
|
263
|
+
});
|
|
264
|
+
ctx.signal.throwIfAborted();
|
|
265
|
+
// In a multi-section preview, ignore parse/apply errors from the
|
|
266
|
+
// last section: it's still streaming and the partial op may not
|
|
267
|
+
// parse yet. Earlier sections are stable and stay rendered.
|
|
268
|
+
if (sectionsToProcess.length > 1 && i === trailingProcessedIndex && "error" in result) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
previews.push(toPerFilePreview(section.path, result));
|
|
272
|
+
}
|
|
273
|
+
return previews.length > 0 ? previews : null;
|
|
232
274
|
},
|
|
233
275
|
renderStreamingFallback() {
|
|
234
276
|
return "";
|