@oh-my-pi/pi-coding-agent 14.7.2 → 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.
Files changed (56) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +7 -7
  3. package/src/cli/read-cli.ts +1 -2
  4. package/src/commands/read.ts +2 -7
  5. package/src/config/settings-schema.ts +0 -5
  6. package/src/edit/modes/hashline.ts +40 -19
  7. package/src/edit/modes/patch.ts +7 -5
  8. package/src/edit/modes/replace.ts +6 -2
  9. package/src/edit/notebook.ts +222 -0
  10. package/src/edit/read-file.ts +7 -0
  11. package/src/edit/renderer.ts +4 -3
  12. package/src/edit/streaming.ts +49 -7
  13. package/src/modes/components/diff.ts +54 -7
  14. package/src/modes/components/tool-execution.ts +3 -29
  15. package/src/prompts/agents/designer.md +1 -2
  16. package/src/prompts/agents/explore.md +2 -5
  17. package/src/prompts/agents/init.md +1 -4
  18. package/src/prompts/agents/librarian.md +1 -3
  19. package/src/prompts/agents/plan.md +7 -8
  20. package/src/prompts/agents/reviewer.md +1 -2
  21. package/src/prompts/ci-green-request.md +10 -10
  22. package/src/prompts/commands/orchestrate.md +48 -0
  23. package/src/prompts/memories/consolidation.md +10 -10
  24. package/src/prompts/memories/read-path.md +6 -6
  25. package/src/prompts/system/agent-creation-architect.md +54 -44
  26. package/src/prompts/system/custom-system-prompt.md +3 -5
  27. package/src/prompts/system/eager-todo.md +4 -4
  28. package/src/prompts/system/handoff-document.md +7 -4
  29. package/src/prompts/system/plan-mode-active.md +7 -3
  30. package/src/prompts/system/plan-mode-approved.md +5 -5
  31. package/src/prompts/system/summarization-system.md +2 -2
  32. package/src/prompts/system/system-prompt.md +53 -65
  33. package/src/prompts/system/title-system.md +2 -2
  34. package/src/prompts/system/web-search.md +16 -19
  35. package/src/prompts/tools/bash.md +8 -8
  36. package/src/prompts/tools/browser.md +4 -4
  37. package/src/prompts/tools/debug.md +3 -1
  38. package/src/prompts/tools/eval.md +13 -9
  39. package/src/prompts/tools/hashline.md +4 -2
  40. package/src/prompts/tools/image-gen.md +1 -1
  41. package/src/prompts/tools/read.md +1 -2
  42. package/src/prompts/tools/reflect.md +3 -3
  43. package/src/prompts/tools/render-mermaid.md +2 -2
  44. package/src/prompts/tools/resolve.md +2 -2
  45. package/src/prompts/tools/retain.md +3 -2
  46. package/src/prompts/tools/rewind.md +2 -2
  47. package/src/prompts/tools/search-tool-bm25.md +3 -4
  48. package/src/prompts/tools/task.md +1 -1
  49. package/src/prompts/tools/todo-write.md +2 -2
  50. package/src/task/commands.ts +5 -1
  51. package/src/tools/fetch.ts +6 -7
  52. package/src/tools/index.ts +0 -4
  53. package/src/tools/read.ts +18 -7
  54. package/src/tools/renderers.ts +0 -2
  55. package/src/tools/write.ts +41 -26
  56. 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.2",
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.2",
50
- "@oh-my-pi/pi-agent-core": "14.7.2",
51
- "@oh-my-pi/pi-ai": "14.7.2",
52
- "@oh-my-pi/pi-natives": "14.7.2",
53
- "@oh-my-pi/pi-tui": "14.7.2",
54
- "@oh-my-pi/pi-utils": "14.7.2",
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",
@@ -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, timeout: cmd.timeout });
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") {
@@ -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, Flags } from "@oh-my-pi/pi-utils/cli";
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, flags } = await this.parse(Read);
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(file: { text(): Promise<string> }, pathText: string): Promise<string> {
1660
+ async function readHashlineFileText(
1661
+ _file: { text(): Promise<string> },
1662
+ absolutePath: string,
1663
+ pathText: string,
1664
+ ): Promise<string> {
1660
1665
  try {
1661
- return await file.text();
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 computeHashlineDiff(
1670
- input: { input: string; path?: string },
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 Bun.file(absolutePath).text() };
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 = bom + restoreLineEndings(result.lines, originalEnding);
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,
@@ -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 Bun.file(path).text();
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 this.#getFile(path).text();
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
- content,
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 = bom + restoreLineEndings(result.content, originalEnding);
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
+ }
@@ -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
+ }
@@ -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", `… (${hidden} earlier lines)\n`);
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
 
@@ -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 { computeHashlineDiff } from "./modes/hashline";
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
- const result = await computeHashlineDiff({ input: args.input, path: args.path }, ctx.cwd, {
227
- autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
228
- });
229
- ctx.signal.throwIfAborted();
230
- if ("error" in result && !args.path) return [{ path: "", error: result.error }];
231
- return [toPerFilePreview(args.path ?? "", result)];
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 "";