@oh-my-pi/pi-coding-agent 15.2.2 → 15.2.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 (52) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/dist/types/cli/worktree-cli.d.ts +26 -0
  3. package/dist/types/commands/worktree.d.ts +34 -0
  4. package/dist/types/config/settings-schema.d.ts +23 -0
  5. package/dist/types/hashline/constants.d.ts +0 -2
  6. package/dist/types/hashline/hash.d.ts +13 -39
  7. package/dist/types/hashline/parser.d.ts +2 -6
  8. package/dist/types/modes/shared.d.ts +9 -0
  9. package/dist/types/modes/theme/shimmer.d.ts +21 -10
  10. package/dist/types/session/agent-session.d.ts +2 -0
  11. package/dist/types/session/yield-queue.d.ts +24 -0
  12. package/dist/types/slash-commands/helpers/format.d.ts +1 -1
  13. package/dist/types/task/worktree.d.ts +0 -1
  14. package/dist/types/utils/git.d.ts +1 -0
  15. package/package.json +7 -7
  16. package/src/autoresearch/storage.ts +14 -2
  17. package/src/cli/worktree-cli.ts +291 -0
  18. package/src/cli.ts +1 -0
  19. package/src/commands/worktree.ts +56 -0
  20. package/src/config/prompt-templates.ts +1 -8
  21. package/src/config/settings-schema.ts +16 -0
  22. package/src/edit/index.ts +1 -1
  23. package/src/edit/renderer.ts +5 -7
  24. package/src/edit/streaming.ts +24 -12
  25. package/src/hashline/constants.ts +0 -3
  26. package/src/hashline/diff.ts +1 -1
  27. package/src/hashline/execute.ts +2 -2
  28. package/src/hashline/grammar.lark +7 -8
  29. package/src/hashline/hash.ts +21 -43
  30. package/src/hashline/input.ts +15 -13
  31. package/src/hashline/parser.ts +62 -161
  32. package/src/internal-urls/docs-index.generated.ts +2 -2
  33. package/src/modes/components/mcp-add-wizard.ts +4 -3
  34. package/src/modes/components/settings-selector.ts +23 -10
  35. package/src/modes/components/welcome.ts +77 -35
  36. package/src/modes/controllers/event-controller.ts +2 -1
  37. package/src/modes/controllers/mcp-command-controller.ts +4 -3
  38. package/src/modes/interactive-mode.ts +51 -10
  39. package/src/modes/shared.ts +16 -0
  40. package/src/modes/theme/shimmer.ts +173 -33
  41. package/src/modes/utils/ui-helpers.ts +31 -13
  42. package/src/prompts/tools/async-result.md +5 -2
  43. package/src/prompts/tools/hashline.md +62 -81
  44. package/src/sdk.ts +95 -21
  45. package/src/session/agent-session.ts +22 -0
  46. package/src/session/yield-queue.ts +155 -0
  47. package/src/slash-commands/helpers/format.ts +4 -1
  48. package/src/task/worktree.ts +2 -7
  49. package/src/tools/gh.ts +35 -32
  50. package/src/utils/commit-message-generator.ts +6 -1
  51. package/src/utils/git.ts +4 -0
  52. package/src/utils/title-generator.ts +45 -13
@@ -75,7 +75,13 @@ export function describeAnchorExamples(linePrefix = ""): string {
75
75
  * pass through unchanged.
76
76
  */
77
77
  export function resolveHashlineGrammarPlaceholders(grammar: string): string {
78
- return grammar.replaceAll("$HFMT$", "[a-z]{2}").replaceAll("$HSEP$", JSON.stringify(HL_EDIT_SEP));
78
+ return grammar
79
+ .replaceAll("$HFMT$", "[a-z]{2}")
80
+ .replaceAll("$HOP_INSERT_BEFORE$", HL_OP_INSERT_BEFORE)
81
+ .replaceAll("$HOP_INSERT_AFTER$", HL_OP_INSERT_AFTER)
82
+ .replaceAll("$HOP_REPLACE$", HL_OP_REPLACE)
83
+ .replaceAll("$HOP_CHARS$", HL_OP_CHARS)
84
+ .replaceAll("$HFILE$", HL_FILE_PREFIX);
79
85
  }
80
86
 
81
87
  /** @deprecated Use {@link resolveHashlineGrammarPlaceholders}. */
@@ -84,51 +90,23 @@ export const resolveLarkLidPlaceholders = resolveHashlineGrammarPlaceholders;
84
90
  const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
85
91
 
86
92
  /**
87
- * Single source of truth for the hashline edit payload separator. This is the
88
- * configured separator that starts inserted/replacement payload lines in
89
- * hashline edit input (`<separator>TEXT`) and separates inline modify ops from
90
- * their appended/prepended text.
93
+ * Hashline edit input markers. File section headers start with {@link HL_FILE_PREFIX};
94
+ * op lines start with a direction/action sigil: {@link HL_OP_INSERT_BEFORE},
95
+ * {@link HL_OP_INSERT_AFTER}, or {@link HL_OP_REPLACE}. Payload lines are
96
+ * verbatim file content and have no per-line marker.
91
97
  *
92
- * Override at runtime with the `PI_HL_SEP` env var (e.g.
93
- * `PI_HL_SEP=">"`, `PI_HL_SEP="\\"`). The value is read once at module load;
94
- * the edit grammar, prompt helper, and edit parser derive from it.
95
- *
96
- * Default is `~`, chosen empirically. Benchmark across 8 candidate separators
97
- * x 3 models (glm-4.7:nitro, gpt-5.4-nano, claude-sonnet-4-6), 24-48 runs per
98
- * cell, hashline variant, 12 sampled tasks per run:
99
- *
100
- * sep | task ✓ | edit ✓ | patch fail | tok/run
101
- * ----|--------|--------|-----------------|--------
102
- * + | 70.8% | 78.0% | 27/125 (21.6%) | 32,127
103
- * ÷ | 70.7% | 90.6% | 22/211 (10.4%) | 31,666
104
- * ~ | 69.4% | 94.9% | 6/107 ( 5.6%) | 30,529 <-- default
105
- * > | 69.2% | 91.5% | 21/219 ( 9.6%) | 30,777
106
- * : | 66.7% | 86.4% | 20/126 (15.9%) | 33,900
107
- * | | 65.9% | 86.9% | 20/127 (15.7%) | 34,589
108
- * \ | 65.5% | 89.8% | 16/124 (12.9%) | 36,010
109
- * % | 63.9% | 92.8% | 11/125 ( 8.8%) | 36,530
110
- *
111
- * `~` wins because:
112
- * - highest edit-tool success rate (94.9%) of any tested separator
113
- * - lowest patch-failure rate (5.6%) — model rarely emits a malformed payload
114
- * - cheapest in tokens alongside `>` (no retry overhead from format collisions)
115
- * - no line-leading role in any mainstream language, markdown, diff, regex,
116
- * or shell, so payload lines are unambiguous to both the parser and models
117
- * - task-success is statistically tied with `>` and `÷` (within run-to-run
118
- * noise), so the edit-reliability win is free
119
- *
120
- * `+` and `÷` lead on raw task-success but at the cost of ~2-4x more patch
121
- * failures (the model retries until it lands a valid edit). `:`, `|`, `\`
122
- * collide with line-leading syntax (label/object-key, body separator, escape)
123
- * and degrade both edit reliability and intent-match.
98
+ * These constants are the single source of truth for the edit parser, grammar,
99
+ * renderer, and prompt.
124
100
  */
125
- export const HL_EDIT_SEP = (() => {
126
- const sep = process.env.PI_HL_SEP?.trim();
127
- return sep?.length === 1 ? sep : "~";
128
- })();
101
+ export const HL_OP_INSERT_BEFORE = "«";
102
+ export const HL_OP_INSERT_AFTER = "»";
103
+ export const HL_OP_REPLACE = "";
104
+
105
+ /** All hashline edit op sigils, concatenated for fast membership tests. */
106
+ export const HL_OP_CHARS = `${HL_OP_INSERT_BEFORE}${HL_OP_INSERT_AFTER}${HL_OP_REPLACE}`;
129
107
 
130
- /** Regex-escaped form of {@link HL_EDIT_SEP}, safe for regexes. */
131
- export const HL_EDIT_SEP_RE_RAW = regexEscape(HL_EDIT_SEP);
108
+ /** Hashline edit file section header marker. */
109
+ export const HL_FILE_PREFIX = "§";
132
110
 
133
111
  /** Stable separator for read/search/hashline display output. Intentionally not configurable. */
134
112
  export const HL_BODY_SEP = "|";
@@ -1,8 +1,11 @@
1
1
  import * as path from "node:path";
2
- import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER, FILE_HEADER_PREFIX } from "./constants";
3
- import { HL_EDIT_SEP } from "./hash";
2
+ import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./constants";
3
+ import { HL_FILE_PREFIX, HL_OP_CHARS } from "./hash";
4
4
  import type { SplitHashlineOptions } from "./types";
5
5
 
6
+ const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
+ const HASHLINE_OP_LINE_RE = new RegExp(`^[${regexEscape(HL_OP_CHARS)}]`);
8
+
6
9
  export interface HashlineInputSection {
7
10
  path: string;
8
11
  diff: string;
@@ -26,19 +29,18 @@ function normalizeHashlinePath(rawPath: string, cwd?: string): string {
26
29
 
27
30
  function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSection | null {
28
31
  const trimmed = line.trimEnd();
29
- if (!trimmed.startsWith(FILE_HEADER_PREFIX)) return null;
30
- // Some models occasionally emit unified-diff-style "@@ path" (or even longer
31
- // runs of "@"). Strip every leading "@" before resolving the path so those
32
- // stray headers still route to the right file.
32
+ if (!trimmed.startsWith(HL_FILE_PREFIX)) return null;
33
+ // Strip a run of leading header markers so canonical `§PATH` and
34
+ // runaway-prefix forms like `§§PATH` / `§§§PATH` route to the same file.
33
35
  let prefixEnd = 0;
34
- while (prefixEnd < trimmed.length && trimmed[prefixEnd] === FILE_HEADER_PREFIX) prefixEnd++;
36
+ while (prefixEnd < trimmed.length && trimmed[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
35
37
  const rest = trimmed.slice(prefixEnd);
36
38
  if (rest.trim().length === 0) {
37
- throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
39
+ throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`);
38
40
  }
39
41
  const parsedPath = normalizeHashlinePath(rest, cwd);
40
42
  if (parsedPath.length === 0) {
41
- throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
43
+ throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`);
42
44
  }
43
45
  return { path: parsedPath, diff: "" };
44
46
  }
@@ -64,7 +66,7 @@ function stripLeadingBlankLines(input: string): string {
64
66
 
65
67
  export function containsRecognizableHashlineOperations(input: string): boolean {
66
68
  for (const line of input.split(/\r?\n/)) {
67
- if (/^[+<=-]\s+/.test(line) || line.startsWith(HL_EDIT_SEP)) return true;
69
+ if (HASHLINE_OP_LINE_RE.test(line)) return true;
68
70
  }
69
71
  return false;
70
72
  }
@@ -79,7 +81,7 @@ function normalizeFallbackInput(input: string, options: SplitHashlineOptions): s
79
81
  if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
80
82
  const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
81
83
  if (fallbackPath.length === 0) return input;
82
- return `${FILE_HEADER_PREFIX} ${fallbackPath}\n${input}`;
84
+ return `${HL_FILE_PREFIX}${fallbackPath}\n${input}`;
83
85
  }
84
86
 
85
87
  export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): { path: string; diff: string } {
@@ -95,8 +97,8 @@ export function splitHashlineInputs(input: string, options: SplitHashlineOptions
95
97
  if (parseHashlineHeaderLine(firstLine, options.cwd) === null) {
96
98
  const preview = JSON.stringify(firstLine.slice(0, 120));
97
99
  throw new Error(
98
- `input must begin with "@@ PATH" on the first non-blank line; got: ${preview}. ` +
99
- `Example: "@@ src/foo.ts" then edit ops.`,
100
+ `input must begin with "${HL_FILE_PREFIX}PATH" on the first non-blank line; got: ${preview}. ` +
101
+ `Example: "${HL_FILE_PREFIX}src/foo.ts" then edit ops.`,
100
102
  );
101
103
  }
102
104
 
@@ -1,8 +1,17 @@
1
1
  import { ABORT_MARKER, ABORT_WARNING, BEGIN_PATCH_MARKER, END_PATCH_MARKER, RANGE_INTERIOR_HASH } from "./constants";
2
- import { describeAnchorExamples, HL_EDIT_SEP, HL_HASH_CAPTURE_RE_RAW } from "./hash";
2
+ import {
3
+ describeAnchorExamples,
4
+ HL_FILE_PREFIX,
5
+ HL_HASH_CAPTURE_RE_RAW,
6
+ HL_OP_CHARS,
7
+ HL_OP_INSERT_AFTER,
8
+ HL_OP_INSERT_BEFORE,
9
+ HL_OP_REPLACE,
10
+ } from "./hash";
3
11
  import type { Anchor, HashlineCursor, HashlineEdit } from "./types";
4
12
 
5
13
  const LID_CAPTURE_RE = new RegExp(`^${HL_HASH_CAPTURE_RE_RAW}$`);
14
+ const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6
15
 
7
16
  function parseLid(raw: string, lineNum: number): Anchor {
8
17
  const match = LID_CAPTURE_RE.exec(raw);
@@ -22,12 +31,8 @@ interface ParsedRange {
22
31
 
23
32
  function parseRange(raw: string, lineNum: number): ParsedRange {
24
33
  if (!raw.includes("..")) {
25
- throw new Error(
26
- `line ${lineNum}: explicit ranges are required for delete/replace. ` +
27
- `Repeat the same anchor on both sides for a one-line edit (for example, ` +
28
- `${describeAnchorExamples("119")}..${describeAnchorExamples("119")}); ` +
29
- `got ${JSON.stringify(raw)}.`,
30
- );
34
+ const start = parseLid(raw, lineNum);
35
+ return { start, end: { ...start } };
31
36
  }
32
37
  const [startRaw, endRaw, extra] = raw.split("..");
33
38
  if (extra !== undefined || !startRaw || !endRaw) {
@@ -64,157 +69,61 @@ function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after
64
69
  return { kind: cursorKind, anchor: parseLid(raw, lineNum) };
65
70
  }
66
71
 
67
- const INSERT_BEFORE_OP_RE = /^<\s*(\S+)$/;
68
- const INSERT_AFTER_OP_RE = /^\+\s*(\S+)$/;
69
- const DELETE_OP_RE = /^-\s*(\S+)$/;
70
- const REPLACE_OP_RE = /^=\s*(\S+)$/;
72
+ const INSERT_BEFORE_OP_RE = new RegExp(`^${regexEscape(HL_OP_INSERT_BEFORE)}\\s*(\\S+)\\s*$`);
73
+ const INSERT_AFTER_OP_RE = new RegExp(`^${regexEscape(HL_OP_INSERT_AFTER)}\\s*(\\S+)\\s*$`);
74
+ const REPLACE_OP_RE = new RegExp(`^${regexEscape(HL_OP_REPLACE)}\\s*([^\\s+<\\-=]\\S*)\\s*$`);
75
+
76
+ function isEnvelopeOrAbortMarkerLine(line: string): boolean {
77
+ const trimmed = line.trimEnd();
78
+ return trimmed === BEGIN_PATCH_MARKER || trimmed === END_PATCH_MARKER || trimmed === ABORT_MARKER;
79
+ }
80
+
81
+ function isPayloadTerminatorLine(line: string): boolean {
82
+ const first = line[0];
83
+ return (
84
+ first === HL_FILE_PREFIX ||
85
+ (first !== undefined && HL_OP_CHARS.includes(first)) ||
86
+ isEnvelopeOrAbortMarkerLine(line)
87
+ );
88
+ }
71
89
 
72
90
  export function cloneCursor(cursor: HashlineCursor): HashlineCursor {
73
91
  if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
74
92
  if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } };
75
93
  return cursor;
76
94
  }
77
- /**
78
- * Returns true when every non-empty payload line looks like the `~ TEXT` readability-padding
79
- * typo: exactly one leading space followed by a non-space character (or a bare single space).
80
- *
81
- * Indented file content (Python 4-space, YAML/JSON/Markdown 2-space, etc.) starts with two or
82
- * more leading spaces, so this heuristic ignores legitimate indentation while still flagging
83
- * the common `~ beta` mistake that silently corrupts file content with a stray space.
84
- */
85
- function hasUniformSeparatorPadding(payload: string[]): boolean {
86
- let any = false;
87
- for (const text of payload) {
88
- if (text.length === 0) continue;
89
- if (text.charCodeAt(0) !== 0x20) return false;
90
- // Two or more leading spaces is real indentation, not separator padding.
91
- if (text.length > 1 && text.charCodeAt(1) === 0x20) return false;
92
- any = true;
93
- }
94
- return any;
95
- }
96
-
97
- /**
98
- * File extensions where leading single-space indentation is plausible legitimate file content
99
- * (off-side-rule languages, structured-indent data formats, prose with continuation indent).
100
- * For these we suppress the separator-padding warning entirely — the heuristic's false-positive
101
- * cost on a real edit outweighs the rare chance it catches a `~ TEXT` typo.
102
- */
103
- const INDENT_SENSITIVE_EXTS: Record<string, true> = {
104
- ".py": true,
105
- ".pyi": true,
106
- ".pyx": true,
107
- ".pyw": true,
108
- ".yml": true,
109
- ".yaml": true,
110
- ".md": true,
111
- ".mdx": true,
112
- ".markdown": true,
113
- ".rst": true,
114
- ".adoc": true,
115
- ".asciidoc": true,
116
- ".toml": true,
117
- ".json": true,
118
- ".jsonc": true,
119
- ".json5": true,
120
- ".ndjson": true,
121
- ".jsonl": true,
122
- ".tf": true,
123
- ".tfvars": true,
124
- ".hcl": true,
125
- ".nix": true,
126
- ".coffee": true,
127
- ".litcoffee": true,
128
- ".haml": true,
129
- ".slim": true,
130
- ".pug": true,
131
- ".jade": true,
132
- ".sass": true,
133
- ".styl": true,
134
- ".nim": true,
135
- ".cr": true,
136
- ".elm": true,
137
- ".fs": true,
138
- ".fsi": true,
139
- ".fsx": true,
140
- };
141
-
142
- function isIndentationSensitivePath(path: string | undefined): boolean {
143
- if (!path) return false;
144
- const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
145
- const dot = path.lastIndexOf(".");
146
- if (dot <= slash) return false;
147
- const ext = path.slice(dot).toLowerCase();
148
- return INDENT_SENSITIVE_EXTS[ext] === true;
149
- }
150
95
 
151
96
  function collectPayload(
152
97
  lines: string[],
153
98
  startIndex: number,
154
99
  opLineNum: number,
155
100
  requirePayload: boolean,
156
- checkPadding: boolean,
157
- ): { payload: string[]; nextIndex: number; paddingWarning?: string } {
101
+ ): { payload: string[]; nextIndex: number } {
158
102
  const payload: string[] = [];
159
103
  let index = startIndex;
160
104
  while (index < lines.length) {
161
105
  const line = lines[index];
162
- if (line.startsWith(HL_EDIT_SEP)) {
163
- payload.push(line.slice(HL_EDIT_SEP.length).trimEnd());
164
- index++;
165
- continue;
166
- }
167
- // Silently recover from a missing payload prefix on an otherwise blank
168
- // line: if more payload follows (possibly past further blanks), treat
169
- // each intervening blank as an empty `${HL_EDIT_SEP}` payload line.
170
- // Additionally, when the op explicitly requires payload (`+`/`<`) and
171
- // we have not collected any yet, accept the blank(s) themselves as the
172
- // empty payload — common typo of forgetting the `${HL_EDIT_SEP}` prefix
173
- // when inserting a blank line.
174
- if (line.length === 0) {
175
- let lookahead = index + 1;
176
- while (lookahead < lines.length && lines[lookahead].length === 0) {
177
- lookahead++;
178
- }
179
- const followedByPayload = lookahead < lines.length && lines[lookahead].startsWith(HL_EDIT_SEP);
180
- const acceptBareBlank = requirePayload && payload.length === 0;
181
- if (followedByPayload || acceptBareBlank) {
182
- for (let j = index; j < lookahead; j++) payload.push("");
183
- index = lookahead;
184
- continue;
185
- }
186
- }
187
- break;
106
+ if (isPayloadTerminatorLine(line)) break;
107
+ payload.push(line);
108
+ index++;
188
109
  }
189
110
  if (payload.length === 0 && requirePayload) {
190
- throw new Error(`line ${opLineNum}: + and < operations require at least one ${HL_EDIT_SEP}TEXT payload line.`);
111
+ throw new Error(
112
+ `line ${opLineNum}: ${HL_OP_INSERT_BEFORE} and ${HL_OP_INSERT_AFTER} operations require at least one verbatim payload line.`,
113
+ );
191
114
  }
192
- const paddingWarning =
193
- checkPadding && hasUniformSeparatorPadding(payload)
194
- ? `line ${opLineNum}: every payload line begins with exactly one space before non-space content, ` +
195
- `which looks like a readability gap after "${HL_EDIT_SEP}". The space becomes file content. ` +
196
- `Drop it unless the file genuinely uses a one-space indent.`
197
- : undefined;
198
- return { payload, nextIndex: index, paddingWarning };
115
+ return { payload, nextIndex: index };
199
116
  }
200
117
 
201
- export function parseHashline(diff: string, opts: ParseHashlineOptions = {}): HashlineEdit[] {
202
- return parseHashlineWithWarnings(diff, opts).edits;
118
+ export function parseHashline(diff: string): HashlineEdit[] {
119
+ return parseHashlineWithWarnings(diff).edits;
203
120
  }
204
121
 
205
- export interface ParseHashlineOptions {
206
- /** File path the diff targets. Used to suppress indent-sensitive false-positive warnings. */
207
- path?: string;
208
- }
209
-
210
- export function parseHashlineWithWarnings(
211
- diff: string,
212
- opts: ParseHashlineOptions = {},
213
- ): { edits: HashlineEdit[]; warnings: string[] } {
122
+ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]; warnings: string[] } {
214
123
  const edits: HashlineEdit[] = [];
215
124
  const warnings: string[] = [];
216
125
  const lines = diff.split(/\r?\n/);
217
- const checkPadding = !isIndentationSensitivePath(opts.path);
126
+ if (diff.endsWith("\n") && lines.at(-1) === "") lines.pop();
218
127
  let editIndex = 0;
219
128
 
220
129
  const pushInsert = (cursor: HashlineCursor, text: string, lineNum: number) => {
@@ -240,15 +149,11 @@ export function parseHashlineWithWarnings(
240
149
  i++;
241
150
  continue;
242
151
  }
243
- if (line.startsWith(HL_EDIT_SEP)) {
244
- throw new Error(`line ${lineNum}: payload line has no preceding +, <, or = operation.`);
245
- }
246
152
 
247
153
  const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
248
154
  if (insertBeforeMatch) {
249
155
  const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
250
- const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true, checkPadding);
251
- if (paddingWarning) warnings.push(paddingWarning);
156
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
252
157
  for (const text of payload) pushInsert(cursor, text, lineNum);
253
158
  i = nextIndex;
254
159
  continue;
@@ -257,37 +162,26 @@ export function parseHashlineWithWarnings(
257
162
  const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line);
258
163
  if (insertAfterMatch) {
259
164
  const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after");
260
- const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true, checkPadding);
261
- if (paddingWarning) warnings.push(paddingWarning);
165
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
262
166
  for (const text of payload) pushInsert(cursor, text, lineNum);
263
167
  i = nextIndex;
264
168
  continue;
265
169
  }
266
170
 
267
- const deleteMatch = DELETE_OP_RE.exec(line);
268
- if (deleteMatch) {
269
- for (const anchor of expandRange(parseRange(deleteMatch[1], lineNum))) {
270
- edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ });
271
- }
272
- i++;
273
- continue;
274
- }
275
-
276
171
  const replaceMatch = REPLACE_OP_RE.exec(line);
277
172
  if (replaceMatch) {
278
173
  const range = parseRange(replaceMatch[1], lineNum);
279
- const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, false, checkPadding);
280
- if (paddingWarning) warnings.push(paddingWarning);
281
- // `= A..B` with no payload blanks the range to a single empty line.
282
- const replacement = payload.length === 0 ? [""] : payload;
283
- for (const text of replacement) {
284
- edits.push({
285
- kind: "insert",
286
- cursor: { kind: "before_anchor", anchor: { ...range.start } },
287
- text,
288
- lineNum,
289
- index: editIndex++,
290
- });
174
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
175
+ if (payload.length > 0) {
176
+ for (const text of payload) {
177
+ edits.push({
178
+ kind: "insert",
179
+ cursor: { kind: "before_anchor", anchor: { ...range.start } },
180
+ text,
181
+ lineNum,
182
+ index: editIndex++,
183
+ });
184
+ }
291
185
  }
292
186
  for (const anchor of expandRange(range)) {
293
187
  edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ });
@@ -296,8 +190,15 @@ export function parseHashlineWithWarnings(
296
190
  continue;
297
191
  }
298
192
 
193
+ if (isPayloadTerminatorLine(line) || /^[-@\u00B6]/u.test(line)) {
194
+ throw new Error(
195
+ `line ${lineNum}: unrecognized op. Use ${HL_OP_INSERT_BEFORE}ANCHOR (insert before), ${HL_OP_INSERT_AFTER}ANCHOR (insert after), or ${HL_OP_REPLACE}A..B (replace/delete). ` +
196
+ `Got ${JSON.stringify(line)}.`,
197
+ );
198
+ }
199
+
299
200
  throw new Error(
300
- `line ${lineNum}: unrecognized op. Use < ANCHOR (insert before), + ANCHOR (insert after), - A..B (delete), = A..B (replace), or "${HL_EDIT_SEP}TEXT" payload lines. ` +
201
+ `line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, or ${HL_OP_REPLACE} operation. ` +
301
202
  `Got ${JSON.stringify(line)}.`,
302
203
  );
303
204
  }