@d3ara1n/pi-hashline-edit 0.1.0 → 0.1.1

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/src/core/parse.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
- * 严格解析器:patch 字符串 → {@link ParsedPatch}
2
+ * Strict parser: patch string → {@link ParsedPatch}.
3
3
  *
4
- * 核心零猜测:只接受规范格式(正确 verb + 正确锚 + `+` body 行),
5
- * 任何变体(裸行、旧 `oldText`/`newText`、`SWAP`/`DEL` 等)一律拒绝,
6
- * 交给 `transforms/normalize-legacy` 中间件归一化后再进入解析。
4
+ * Core principle zero guessing: only the canonical format is accepted (correct
5
+ * verb + correct anchor + `+` body rows). Any variant (bare lines, legacy
6
+ * `oldText`/`newText`, `SWAP`/`DEL`, etc.) is rejected and left to the
7
+ * `transforms/normalize-legacy` middleware to normalize before parsing.
7
8
  *
8
- * 格式:
9
+ * Format:
9
10
  *
10
11
  * ```
11
12
  * file: <path>
@@ -62,7 +63,7 @@ type ParsedHeader =
62
63
  readonly build: (body: string[]) => Edit;
63
64
  };
64
65
 
65
- /** 解析单个操作头(已 trim)。末尾冒号可选(有 body verb)。 */
66
+ /** Parse a single operation header (already trimmed). Trailing colon is optional (for body-bearing verbs). */
66
67
  function parseOpHeader(s: string): ParsedHeader {
67
68
  let core = s;
68
69
  if (core.endsWith(":")) core = core.slice(0, -1).trimEnd();
@@ -101,10 +102,10 @@ function parseOpHeader(s: string): ParsedHeader {
101
102
  }
102
103
 
103
104
  /**
104
- * 严格解析 patch
105
+ * Strictly parse a patch.
105
106
  *
106
- * @param input patch 字符串(CRLF 自动归一为 LF
107
- * @returns 解析结果;非法格式返回 `ok: false` + PatchError(含输入行号)
107
+ * @param input patch string (CRLF is normalized to LF automatically)
108
+ * @returns parse result; malformed input returns `ok: false` + PatchError (with the input line number)
108
109
  */
109
110
  export function parsePatch(input: string): ParseResult {
110
111
  const normalized = input.replace(/\r\n/g, "\n");
@@ -2,7 +2,7 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { splitLines, joinLines, createSnapshot, verifyAnchor } from "./snapshot.ts";
4
4
 
5
- test("splitLines 边界", () => {
5
+ test("splitLines edge cases", () => {
6
6
  assert.deepEqual(splitLines(""), []);
7
7
  assert.deepEqual(splitLines("a"), ["a"]);
8
8
  assert.deepEqual(splitLines("a\nb"), ["a", "b"]);
@@ -11,19 +11,19 @@ test("splitLines 边界", () => {
11
11
  assert.deepEqual(splitLines("\n"), [""]);
12
12
  });
13
13
 
14
- test("splitLines 去掉 CRLF \\r", () => {
14
+ test("splitLines strips CRLF \\r", () => {
15
15
  assert.deepEqual(splitLines("a\r\nb\r\n"), ["a", "b"]);
16
16
  assert.deepEqual(splitLines("a\r\nb"), ["a", "b"]);
17
17
  });
18
18
 
19
- test("joinLines 行尾恢复", () => {
19
+ test("joinLines restores line endings", () => {
20
20
  assert.equal(joinLines(["a", "b"]), "a\nb\n");
21
21
  assert.equal(joinLines([]), "");
22
22
  assert.equal(joinLines(["a", "b"], "crlf"), "a\r\nb\r\n");
23
23
  assert.equal(joinLines(["a", "b"], "lf"), "a\nb\n");
24
24
  });
25
25
 
26
- test("createSnapshot 记录 path/text/hashLen", () => {
26
+ test("createSnapshot records path/text/hashLen", () => {
27
27
  const s = createSnapshot("f.ts", "a\nb\nc\n");
28
28
  assert.equal(s.path, "f.ts");
29
29
  assert.equal(s.text, "a\nb\nc\n");
@@ -31,16 +31,16 @@ test("createSnapshot 记录 path/text/hashLen", () => {
31
31
  assert.equal(s.hashLen, 4);
32
32
  });
33
33
 
34
- test("createSnapshot 自定义 hashLen", () => {
34
+ test("createSnapshot custom hashLen", () => {
35
35
  assert.equal(createSnapshot("f", "a\n", 6).hashLen, 6);
36
36
  });
37
37
 
38
- test("createSnapshot 记录 lineEnding", () => {
38
+ test("createSnapshot records lineEnding", () => {
39
39
  assert.equal(createSnapshot("f", "a\nb\n").lineEnding, "lf");
40
40
  assert.equal(createSnapshot("f", "a\r\nb\r\n").lineEnding, "crlf");
41
41
  });
42
42
 
43
- test("verifyAnchor 匹配", () => {
43
+ test("verifyAnchor match", () => {
44
44
  const s = createSnapshot("f", "a\nb\n");
45
45
  assert.deepEqual(verifyAnchor(s, { line: 2, hash: s.lineHashes[1] }), { ok: true, line: 2 });
46
46
  });
@@ -52,7 +52,7 @@ test("verifyAnchor hash_not_found", () => {
52
52
  if (!r.ok) assert.equal(r.error, "hash_not_found");
53
53
  });
54
54
 
55
- test("verifyAnchor line_mismatch(漂移)", () => {
55
+ test("verifyAnchor line_mismatch (drift)", () => {
56
56
  const s = createSnapshot("f", "a\nb\n");
57
57
  const r = verifyAnchor(s, { line: 1, hash: s.lineHashes[1] });
58
58
  assert.equal(r.ok, false);
@@ -1,12 +1,16 @@
1
1
  /**
2
- * 文件快照与锚校验。
2
+ * File snapshot and anchor verification.
3
3
  *
4
- * 快照在 read 时记录原文 + 每行 hash;apply 时校验「当前文件 == 快照」
5
- * stale 检查)以及每个锚的 hash 与行号对得上(防模型记错)。
4
+ * A snapshot records the original text + per-line hash at read time; at apply
5
+ * time it verifies "current file == snapshot" (stale check) and that each
6
+ * anchor's hash matches its line number (guards against the model
7
+ * misremembering).
6
8
  *
7
- * CRLFsplitLines 归一化去掉每行尾的 \r(hash 基于干净行,与模型从显示中
8
- * 复制的无 \r 内容一致),createSnapshot 记录原行尾,joinLines 按记录的
9
- * 行尾恢复——保证 CRLF 文件 edit 后行尾不变。
9
+ * CRLF: splitLines normalizes by stripping the trailing `\r` from each line
10
+ * (hashes are based on clean lines, matching the `\r`-free content the model
11
+ * copies from the display); createSnapshot records the original line ending,
12
+ * and joinLines restores it per the recorded ending — guaranteeing a CRLF file
13
+ * keeps its line endings after edit.
10
14
  *
11
15
  * @module pi-hashline-edit/core
12
16
  */
@@ -15,11 +19,13 @@ import { hashFileLines } from "./hash.ts";
15
19
  import type { Anchor, FileSnapshot, LineEnding } from "./types.ts";
16
20
 
17
21
  /**
18
- * 按行分割文本,去掉每行尾的 \r(CRLF 归一化,hash 基于干净行)。
22
+ * Split text into lines, stripping the trailing `\r` of each line (CRLF
23
+ * normalization, so hashes are based on clean lines).
19
24
  *
20
- * 约定:末尾换行视为最后一行的终止符,不产生多余空尾行。
25
+ * Convention: a trailing newline is treated as the terminator of the last line,
26
+ * not as producing an extra empty trailing line.
21
27
  * - `"a\nb\n"` → `["a", "b"]`
22
- * - `"a\r\nb\r\n"` → `["a", "b"]`(\r 去掉)
28
+ * - `"a\r\nb\r\n"` → `["a", "b"]` (`\r` stripped)
23
29
  * - `"a\n\n"` → `["a", ""]`
24
30
  * - `""` → `[]`
25
31
  */
@@ -29,42 +35,42 @@ export function splitLines(text: string): string[] {
29
35
  return normalized.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
30
36
  }
31
37
 
32
- /** 检测文本的主导行尾(含 \r\n 即视为 CRLF)。 */
38
+ /** Detect the dominant line ending of the text (any `\r\n` counts as CRLF). */
33
39
  export function detectLineEnding(text: string): LineEnding {
34
40
  return text.includes("\r\n") ? "crlf" : "lf";
35
41
  }
36
42
 
37
- /** 把行数组 join 成文本,按指定行尾恢复(默认 LF)。非空文件末尾带换行。 */
43
+ /** Join a line array back into text, restoring the given line ending (default LF). Non-empty files end with a newline. */
38
44
  export function joinLines(lines: readonly string[], ending: LineEnding = "lf"): string {
39
45
  if (lines.length === 0) return "";
40
46
  const sep = ending === "crlf" ? "\r\n" : "\n";
41
47
  return lines.join(sep) + sep;
42
48
  }
43
49
 
44
- /** 为文件创建快照:记录原文 + 行尾 + 每行 context-aware hash */
50
+ /** Create a snapshot for a file: record original text + line ending + per-line context-aware hash. */
45
51
  export function createSnapshot(path: string, text: string, len = 4): FileSnapshot {
46
52
  const lines = splitLines(text);
47
53
  const lineHashes = hashFileLines(lines, len);
48
54
  return { path, lineHashes, text, hashLen: len, lineEnding: detectLineEnding(text) };
49
55
  }
50
56
 
51
- /** 锚校验结果。 */
57
+ /** Anchor verification result. */
52
58
  export type AnchorVerifyResult =
53
59
  | { readonly ok: true; readonly line: number }
54
60
  | {
55
61
  readonly ok: false;
56
62
  readonly error: "hash_not_found" | "line_mismatch" | "collision";
57
- /** hash 实际出现的行号(1-based)。 */
63
+ /** Line number(s) where the hash actually appears (1-based). */
58
64
  readonly found?: readonly number[];
59
65
  };
60
66
 
61
67
  /**
62
- * 在快照中校验锚。
68
+ * Verify an anchor against the snapshot.
63
69
  *
64
- * - hash 唯一存在且行号匹配 → `ok`
65
- * - hash 唯一存在但行号不符 → `line_mismatch`(`found` 给真实行号;漂移,可由 relocate 中间件处理)
66
- * - hash 多处出现 → `collision`(`found` 给所有位置)
67
- * - hash 不存在 → `hash_not_found`(文件已变,需重读)
70
+ * - hash is unique and the line number matches → `ok`
71
+ * - hash is unique but the line number differs → `line_mismatch` (`found` gives the real line number; drift, handled by the relocate middleware)
72
+ * - hash appears at multiple lines → `collision` (`found` gives all positions)
73
+ * - hash does not exist → `hash_not_found` (file changed, needs re-read)
68
74
  */
69
75
  export function verifyAnchor(snapshot: FileSnapshot, anchor: Anchor): AnchorVerifyResult {
70
76
  const found: number[] = [];
package/src/core/types.ts CHANGED
@@ -1,18 +1,19 @@
1
1
  /**
2
- * Hashline 核心类型定义。
2
+ * Hashline core type definitions.
3
3
  *
4
4
  * @module pi-hashline-edit/core
5
5
  */
6
6
 
7
- /** 行锚:行号(1-based)+ 内容 hash 双重引用。 */
7
+ /** Line anchor: dual reference of line number (1-based) + content hash. */
8
8
  export interface Anchor {
9
9
  readonly line: number;
10
10
  readonly hash: string;
11
11
  }
12
12
 
13
13
  /**
14
- * 编辑操作。所有带行号的操作都通过 {@link Anchor} 引用——
15
- * 行号给人读,hash 给机器校验,二者必须同时匹配快照。
14
+ * Edit operation. Every line-numbered op references a line via {@link Anchor}
15
+ * the line number is for humans, the hash for machine verification; both must
16
+ * match the snapshot at once.
16
17
  */
17
18
  export type Edit =
18
19
  | { readonly op: "replace"; readonly start: Anchor; readonly end?: Anchor; readonly body: string[] }
@@ -24,41 +25,41 @@ export type Edit =
24
25
 
25
26
  export type LineEnding = "lf" | "crlf";
26
27
 
27
- /** 文件快照:read 时记录的原文 + 每行 context-aware hash */
28
+ /** File snapshot: original text recorded at read time + per-line context-aware hash. */
28
29
  export interface FileSnapshot {
29
30
  readonly path: string;
30
- /** `lineHashes[i]` = (i+1) 行的 hash,长度恒等于文件行数。 */
31
+ /** `lineHashes[i]` = hash of line (i+1); length always equals the file's line count. */
31
32
  readonly lineHashes: readonly string[];
32
33
  readonly text: string;
33
- /** 生成 lineHashes 时用的 hash 长度;apply 生成新快照时须沿用,避免长度不一致导致下次校验失败。 */
34
+ /** Hash length used when generating lineHashes; apply must reuse it for the new snapshot to avoid length drift on the next verification. */
34
35
  readonly hashLen: number;
35
- /** 原文件行尾(lf/crlf);apply 据此恢复,保证 CRLF 文件 edit 后行尾不变。 */
36
+ /** Original file line ending (lf/crlf); apply restores it so a CRLF file keeps its line endings after edit. */
36
37
  readonly lineEnding: LineEnding;
37
38
  }
38
39
 
39
- /** 解析出的单个文件 patch */
40
+ /** A single parsed file patch. */
40
41
  export interface ParsedPatch {
41
42
  readonly path: string;
42
43
  readonly edits: Edit[];
43
44
  }
44
45
 
45
- /** 错误种类。 */
46
+ /** Error kinds. */
46
47
  export type PatchErrorKind =
47
- | "parse" // 输入格式错误
48
- | "stale" // 文件已变(当前 text !== snapshot.text
49
- | "anchor" // hash 不匹配快照(模型记错)或行号越界
50
- | "collision" // hash 在文件中多处出现,无法唯一定位
51
- | "range" // 操作范围非法(重叠、逆序、跨空等)
52
- | "noop"; // 编辑未产生变化(body 与目标行字节相同)
48
+ | "parse" // malformed input
49
+ | "stale" // file changed (current text !== snapshot.text)
50
+ | "anchor" // anchor hash does not match the snapshot (model misremembered) or line out of range
51
+ | "collision" // hash appears at multiple lines, cannot locate uniquely
52
+ | "range" // illegal operation range (overlap, reverse order, spanning a gap, etc.)
53
+ | "noop"; // edit produced no change (body byte-identical to the target)
53
54
 
54
55
  export interface PatchError {
55
56
  readonly kind: PatchErrorKind;
56
57
  readonly message: string;
57
- /** 输入 patch 中的行号(1-based),用于错误定位。 */
58
+ /** Line number (1-based) in the input patch, for error localization. */
58
59
  readonly line?: number;
59
60
  }
60
61
 
61
- /** 应用结果。 */
62
+ /** Apply result. */
62
63
  export type ApplyResult =
63
64
  | {
64
65
  readonly ok: true;
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
- * pi-hashline-edit 扩展入口。
2
+ * pi-hashline-edit extension entry.
3
3
  *
4
- * override 内置 read/editread 输出「行号#hash│内容」并记录快照,
5
- * edit 只接受 hashline patchLINE#HASH 锚),旧 oldText/newText 明确拒绝
6
- * (不静默降级)。renderer 自动继承内置渲染。
4
+ * Overrides the built-in read/edit: read outputs "lineNo#hash│content" and
5
+ * records a snapshot; edit accepts only a hashline patch (LINE#HASH anchors),
6
+ * and legacy oldText/newText is rejected explicitly (no silent degradation).
7
+ * The renderer is inherited from the built-in automatically.
7
8
  *
8
9
  * @module pi-hashline-edit
9
10
  */
@@ -17,7 +18,7 @@ import { makeReadOverride } from "./pi/read-tool.ts";
17
18
  export default function (pi: ExtensionAPI) {
18
19
  const cwd = process.cwd();
19
20
 
20
- // session 启动/重载时刷新配置与快照
21
+ // refresh config and snapshots on session start / reload
21
22
  pi.on("session_start", async () => {
22
23
  const config = loadConfig(cwd);
23
24
  const state = getState();
package/src/pi/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
- * 配置加载:项目 `.pi/settings.json` 替换全局,per-field `??` DEFAULT 兜底。
3
- * 配置字段 `hashlineEdit`(去 `pi-` 前缀转 camelCase)。
2
+ * Config loading: project `.pi/settings.json` replaces global, per-field `??`
3
+ * falls back to DEFAULT. Config field `hashlineEdit` (drop the `pi-` prefix,
4
+ * camelCase).
4
5
  *
5
6
  * @module pi-hashline-edit/pi
6
7
  */
@@ -10,9 +11,9 @@ import * as os from "node:os";
10
11
  import * as path from "node:path";
11
12
 
12
13
  export interface HashlineEditConfig {
13
- /** 是否启用 hashlinefalse 时透传内置 read/edit)。 */
14
+ /** Whether hashline is enabled (when false, delegate to the built-in read/edit). */
14
15
  enabled: boolean;
15
- /** hash 长度(默认 4)。 */
16
+ /** Line hash length (default 4). */
16
17
  hashLen: number;
17
18
  }
18
19
 
@@ -24,7 +25,7 @@ function getAgentDir(): string {
24
25
  return path.join(os.homedir(), ".pi", "agent");
25
26
  }
26
27
 
27
- /** 直接 JSON.parse,不剥注释(标准 JSON 禁止注释,出错降级默认)。 */
28
+ /** Parse JSON directly without stripping comments (standard JSON forbids comments; on error fall back to default). */
28
29
  function readSettings(filePath: string): Record<string, unknown> {
29
30
  try {
30
31
  if (!fs.existsSync(filePath)) return {};
@@ -35,8 +36,8 @@ function readSettings(filePath: string): Record<string, unknown> {
35
36
  }
36
37
 
37
38
  /**
38
- * 加载配置。项目 `cwd/.pi/settings.json` 的 `hashlineEdit` 整块替换全局,
39
- * 缺失字段由 DEFAULT_CONFIG 兜底。
39
+ * Load config. The `hashlineEdit` in project `cwd/.pi/settings.json` replaces
40
+ * the global one wholesale; missing fields fall back to DEFAULT_CONFIG.
40
41
  */
41
42
  export function loadConfig(cwd?: string): HashlineEditConfig {
42
43
  const globalSettings = readSettings(path.join(getAgentDir(), "settings.json"));
@@ -1,13 +1,16 @@
1
1
  /**
2
- * Override edit:只接受 hashline patch(`input`)。
2
+ * Override edit: accepts only a hashline patch (`input`).
3
3
  *
4
- * 不兼容旧 oldText/newText——发现旧格式输入时明确报错,让开发者知道
5
- * 模型没用新方案,而非静默降级。容错仅限不影响结果的格式归一化
6
- * (parse 层的可选冒号、CRLF 等);范式级兼容一概拒绝。
4
+ * Not backward-compatible with legacy oldText/newText — when legacy input is
5
+ * detected it errors explicitly so the developer knows the model didn't use the
6
+ * new approach, rather than silently degrading. Tolerance is limited to
7
+ * format-only normalizations that don't affect the result (optional colon, CRLF
8
+ * at the parse layer); paradigm-level compatibility is refused outright.
7
9
  *
8
- * 并发安全:read-modify-write 包在 withFileMutationQueue 里,串行化同文件
9
- * 的多次 edit,防止 pi 默认并行执行下丢数据。响应 AbortSignal——读后/写前
10
- * 检查,用户取消时不落盘。
10
+ * Concurrency safety: the read-modify-write is wrapped in
11
+ * withFileMutationQueue, serializing multiple edits to the same file to prevent
12
+ * data loss under pi's default parallel execution. AbortSignal is honored —
13
+ * checked after read / before write, so a user cancel never touches the disk.
11
14
  *
12
15
  * @module pi-hashline-edit/pi
13
16
  */
@@ -19,10 +22,13 @@ import { applyEdits, parsePatch } from "../core/index.ts";
19
22
  import type { Edit, FileSnapshot, PatchError } from "../core/types.ts";
20
23
  import { canonicalPath } from "./read-tool.ts";
21
24
  import { getState, getSnapshot, putSnapshot, recordSnapshot } from "./state.ts";
25
+ import { Text } from "@earendil-works/pi-tui";
22
26
 
23
- // schema 不声明 additionalProperties:false:模型误发旧 edits/oldText/newText
24
- // 时,这些额外字段原样到达 execute,由 missingInputError 检测并明确拒绝。
25
- // 依赖 typebox 默认允许额外属性 + pi validation strip —— 勿改这两点。
27
+ // The schema deliberately omits additionalProperties:false: when the model
28
+ // mistakenly sends legacy edits/oldText/newText, those extra fields reach
29
+ // execute as-is and are caught and rejected explicitly by missingInputError.
30
+ // This relies on typebox allowing extra properties by default + pi validation
31
+ // not stripping them — do not change either of these.
26
32
 
27
33
  const editSchema = Type.Object({
28
34
  path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
@@ -34,7 +40,7 @@ const editSchema = Type.Object({
34
40
  ),
35
41
  });
36
42
 
37
- /** PatchError 转成对模型有用的提示文本。 */
43
+ /** Turn a PatchError into helpful hint text for the model. */
38
44
  function errorText(e: PatchError, path: string): string {
39
45
  switch (e.kind) {
40
46
  case "stale":
@@ -52,7 +58,7 @@ function errorText(e: PatchError, path: string): string {
52
58
  }
53
59
  }
54
60
 
55
- /** 取应用后首个变更行(用于 firstChangedLine / TUI 跳转)。insert_after 取锚 +1 */
61
+ /** Get the first changed line after applying (for firstChangedLine / TUI jump). insert_after uses anchor + 1. */
56
62
  function minAnchorLine(edits: readonly Edit[]): number | undefined {
57
63
  let min: number | undefined;
58
64
  for (const e of edits) {
@@ -61,15 +67,16 @@ function minAnchorLine(edits: readonly Edit[]): number | undefined {
61
67
  else if (e.op === "insert_after") line = e.anchor.line + 1;
62
68
  else if (e.op === "insert_before") line = e.anchor.line;
63
69
  else if (e.op === "prepend") line = 1;
64
- // append 无锚,跳过(末尾追加)
70
+ // append has no anchor, skip (trailing append)
65
71
  if (line !== undefined && (min === undefined || line < min)) min = line;
66
72
  }
67
73
  return min;
68
74
  }
69
75
 
70
76
  /**
71
- * 模型未用 hashline(发了旧 oldText/newText 或缺 input)→ 明确告知,不静默降级。
72
- * 导出以便测试。params any 以便检测 schema 外的旧格式字段。
77
+ * The model didn't use hashline (sent legacy oldText/newText or is missing
78
+ * input) tell it explicitly, don't silently degrade. Exported for testing.
79
+ * params is `any` so it can detect legacy fields outside the schema.
73
80
  */
74
81
  export function missingInputError(path: string, params: any): string {
75
82
  const legacy =
@@ -82,7 +89,7 @@ export function missingInputError(path: string, params: any): string {
82
89
  return `Edit ${path}: missing \`input\` (hashline patch). Read ${path} first, then send \`input\` referencing LINE#HASH anchors.`;
83
90
  }
84
91
 
85
- /** 构造错误 result(带 isError: true,让 TUI/agent loop 识别失败而非当成功)。 */
92
+ /** Build an error result (with isError: true so the TUI/agent loop treats it as a failure, not a success). */
86
93
  function errResult(text: string) {
87
94
  return {
88
95
  isError: true as const,
@@ -110,20 +117,56 @@ export function makeEditOverride(cwd: string) {
110
117
  parameters: editSchema,
111
118
  renderShell: "self" as const,
112
119
 
120
+ renderCall(args: Static<typeof editSchema>, theme: any) {
121
+ let text = theme.fg("toolTitle", theme.bold("edit "));
122
+ text += theme.fg("accent", args.path);
123
+ if (args.input) {
124
+ const firstOp = args.input.split("\n").find((l) => l.trim() && !l.startsWith("+"));
125
+ if (firstOp) text += theme.fg("dim", ` — ${firstOp.trim()}`);
126
+ }
127
+ return new Text(text, 0, 0);
128
+ },
129
+
130
+ renderResult(result: any, { isPartial, expanded }: any, theme: any) {
131
+ if (isPartial) return new Text(theme.fg("warning", "Editing…"), 0, 0);
132
+ const content = result.content?.[0];
133
+ if (result.isError) {
134
+ const t = content?.type === "text" ? content.text.split("\n")[0] : "Error";
135
+ return new Text(theme.fg("error", t), 0, 0);
136
+ }
137
+ const diff: string | undefined = result.details?.diff;
138
+ if (!diff) {
139
+ const t = content?.type === "text" ? content.text : "Edited";
140
+ return new Text(theme.fg("success", t), 0, 0);
141
+ }
142
+ // details.diff is pi-format (+N/-N/<space>N content); color by leading char
143
+ const allLines = diff.split("\n");
144
+ const shown = expanded ? allLines : allLines.slice(0, 24);
145
+ const body = shown
146
+ .map((line: string) => {
147
+ if (line.startsWith("+")) return theme.fg("success", line);
148
+ if (line.startsWith("-")) return theme.fg("error", line);
149
+ return theme.fg("dim", line);
150
+ })
151
+ .join("\n");
152
+ const more = !expanded && allLines.length > 24 ? `\n${theme.fg("dim", `… (${allLines.length - 24} more)`)}` : "";
153
+ return new Text(body + more, 0, 0);
154
+ },
155
+
113
156
  async execute(toolCallId: string, params: Static<typeof editSchema>, signal: AbortSignal | undefined, onUpdate: any) {
114
157
  const state = getState();
115
- // 用户主动关闭 hashline(config.enabled=false)→ 透传内置
158
+ // hashline disabled by the user (config.enabled=false) → delegate to the built-in
116
159
  if (!state.config.enabled) return builtin.execute(toolCallId, params as any, signal, onUpdate);
117
160
 
118
161
  const path = params.path;
119
162
  const absPath = canonicalPath(cwd, path);
120
163
  const input = params.input;
121
164
 
122
- // input → 明确告知(区分旧格式 vs 缺失),不静默降级
165
+ // No input → tell explicitly (distinguish legacy vs missing), don't silently degrade
123
166
  if (typeof input !== "string" || input.trim() === "") {
124
167
  return errResult(missingInputError(path, params as any));
125
168
  }
126
- // withFileMutationQueue 串行化同文件的 read-modify-write,防并行 edit 丢数据
169
+ // withFileMutationQueue serializes read-modify-write for the same file, preventing parallel-edit data loss
127
170
  return await withFileMutationQueue(absPath, () => runHashline(absPath, path, input, signal));
128
171
  },
129
172
  };
@@ -137,14 +180,15 @@ async function runHashline(absPath: string, displayPath: string, input: string,
137
180
  const msg = e instanceof Error ? e.message : String(e);
138
181
  return errResult(`Error reading ${displayPath}: ${msg}`);
139
182
  }
140
- // 读后检查取消:用户 abort 则不继续 parse/apply,文件不变
183
+ // Check for cancel after read: if the user aborted, don't proceed to parse/apply; the file stays untouched
141
184
  if (signal?.aborted) return errResult(`Edit ${displayPath} aborted before apply.`);
142
185
 
143
- // 取已记录快照;若无(模型未 read)则用当前文件建立——anchor 校验仍会强制
144
- // 模型用真实 hash(没读过就猜不对),自然引导它先 read。
186
+ // Use the recorded snapshot, or if there is none (the model didn't read) build one from the
187
+ // current file — anchor verification still forces the model to use a real hash (it can't guess
188
+ // correctly without reading), naturally steering it to read first.
145
189
  const snap: FileSnapshot = getSnapshot(absPath) ?? recordSnapshot(absPath, currentText);
146
190
 
147
- // input 不含 file 头,prepend 一个让 parsePatch 通过(path 仅作标签)
191
+ // input has no file header; prepend one so parsePatch passes (path is just a label)
148
192
  const parsed = parsePatch(`file: ${absPath}\n\n${input}`);
149
193
  if (!parsed.ok) {
150
194
  return errResult(errorText(parsed.error, displayPath));
@@ -155,7 +199,7 @@ async function runHashline(absPath: string, displayPath: string, input: string,
155
199
  return errResult(errorText(result.error, displayPath));
156
200
  }
157
201
 
158
- // 写前检查取消:abort 则不落盘,文件不变
202
+ // Check for cancel before write: if aborted, don't touch the disk; the file stays untouched
159
203
  if (signal?.aborted) return errResult(`Edit ${displayPath} aborted before write.`);
160
204
 
161
205
  try {
@@ -165,7 +209,7 @@ async function runHashline(absPath: string, displayPath: string, input: string,
165
209
  return errResult(`Error writing ${displayPath}: ${msg}`);
166
210
  }
167
211
 
168
- // 更新快照:连续 edit 无需重读(result.newSnapshot 基于新文本),走 LRU
212
+ // Update the snapshot: consecutive edits need no re-read (result.newSnapshot is based on the new text), via LRU
169
213
  putSnapshot(absPath, result.newSnapshot);
170
214
 
171
215
  return {
@@ -173,8 +217,9 @@ async function runHashline(absPath: string, displayPath: string, input: string,
173
217
  { type: "text" as const, text: `Edited ${displayPath} (${parsed.patch.edits.length} op(s)).` },
174
218
  ],
175
219
  details: {
176
- // details.diff 必须用 pi generateDiffString(+N content 格式),
177
- // 内置 renderer parseDiffLine 只认这个格式;core 的标准 unified diff 会被当纯文本灰显
220
+ // details.diff must use pi's generateDiffString (+N content format); the built-in
221
+ // renderer's parseDiffLine only recognizes this format — core's standard unified diff
222
+ // would be grayed out as plain text
178
223
  diff: generateDiffString(currentText, result.text),
179
224
  patch: generateUnifiedPatch(displayPath, currentText, result.text),
180
225
  firstChangedLine: minAnchorLine(parsed.patch.edits),
@@ -1,6 +1,7 @@
1
1
  /**
2
- * pi 接入层 execute 集成测试:驱动真实的 makeReadOverride/makeEditOverride
3
- * execute,覆盖文本 read 带锚、hashline edit 闭环、错误返回 isError。
2
+ * Integration tests for the pi integration layer's execute: drives the real
3
+ * makeReadOverride/makeEditOverride execute, covering text read with anchors,
4
+ * the hashline edit closed loop, and error returns with isError.
4
5
  */
5
6
  import { test } from "node:test";
6
7
  import assert from "node:assert/strict";
@@ -24,7 +25,7 @@ async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
24
25
 
25
26
  const call = (tool: any, params: any) => tool.execute("0", params, undefined, undefined);
26
27
 
27
- test("read execute:文本输出 LINE#HASH│content", async () => {
28
+ test("read execute: text outputs LINE#HASH│content", async () => {
28
29
  await withDir(async (dir) => {
29
30
  await writeFile(join(dir, "f.txt"), "line1\nline2\n");
30
31
  const read = makeReadOverride(dir);
@@ -37,18 +38,18 @@ test("read execute:文本输出 LINE#HASH│content", async () => {
37
38
  });
38
39
  });
39
40
 
40
- test("read execute:记录 snapshot edit", async () => {
41
+ test("read execute: records a snapshot for edit", async () => {
41
42
  await withDir(async (dir) => {
42
43
  await writeFile(join(dir, "f.txt"), "a\nb\n");
43
44
  const read = makeReadOverride(dir);
44
45
  await call(read, { path: "f.txt" });
45
46
  const snap = getSnapshot(join(dir, "f.txt"));
46
- assert.ok(snap, "snapshot 未记录");
47
+ assert.ok(snap, "snapshot not recorded");
47
48
  assert.equal(snap!.lineHashes.length, 2);
48
49
  });
49
50
  });
50
51
 
51
- test("edit executehashline 闭环(read → edit → 文件改)", async () => {
52
+ test("edit execute: hashline closed loop (read → edit → file changed)", async () => {
52
53
  await withDir(async (dir) => {
53
54
  const f = join(dir, "f.txt");
54
55
  await writeFile(f, "a\nb\nc\n");
@@ -59,12 +60,12 @@ test("edit execute:hashline 闭环(read → edit → 文件改)", async ()
59
60
  path: "f.txt",
60
61
  input: `replace 2#${snap.lineHashes[1]}:\n+B`,
61
62
  });
62
- assert.equal(r.isError, undefined, "不应是错误");
63
+ assert.equal(r.isError, undefined, "should not be an error");
63
64
  assert.equal(await readFile(f, "utf-8"), "a\nB\nc\n");
64
65
  });
65
66
  });
66
67
 
67
- test("edit execute:连续 edit 复用更新后的 snapshot", async () => {
68
+ test("edit execute: consecutive edits reuse the updated snapshot", async () => {
68
69
  await withDir(async (dir) => {
69
70
  const f = join(dir, "f.txt");
70
71
  await writeFile(f, "a\nb\n");
@@ -73,7 +74,7 @@ test("edit execute:连续 edit 复用更新后的 snapshot", async () => {
73
74
  await call(read, { path: "f.txt" });
74
75
  let snap = getSnapshot(f)!;
75
76
  await call(edit, { path: "f.txt", input: `replace 1#${snap.lineHashes[0]}:\n+A` });
76
- // 第二次 editsnapshot 已被 edit 更新,用新 hash
77
+ // second edit: the snapshot was updated by the edit, use the new hash
77
78
  snap = getSnapshot(f)!;
78
79
  const r: any = await call(edit, { path: "f.txt", input: `replace 2#${snap.lineHashes[1]}:\n+B` });
79
80
  assert.equal(r.isError, undefined);
@@ -81,17 +82,17 @@ test("edit execute:连续 edit 复用更新后的 snapshot", async () => {
81
82
  });
82
83
  });
83
84
 
84
- test("edit execute:无 read 直接 edit → anchor 校验失败", async () => {
85
+ test("edit execute: edit without a prior read → anchor verification fails", async () => {
85
86
  await withDir(async (dir) => {
86
87
  await writeFile(join(dir, "f.txt"), "a\nb\n");
87
88
  const edit = makeEditOverride(dir);
88
- // read 过,hash 是瞎写的
89
+ // never read, so the hash is made up
89
90
  const r: any = await call(edit, { path: "f.txt", input: "replace 1#XXXX:\n+A" });
90
91
  assert.equal(r.isError, true);
91
92
  });
92
93
  });
93
94
 
94
- test("edit execute:缺 input → isError + missing 提示", async () => {
95
+ test("edit execute: missing input → isError + missing hint", async () => {
95
96
  await withDir(async (dir) => {
96
97
  await writeFile(join(dir, "f.txt"), "a\n");
97
98
  const r: any = await call(makeEditOverride(dir), { path: "f.txt" });
@@ -100,7 +101,7 @@ test("edit execute:缺 input → isError + missing 提示", async () => {
100
101
  });
101
102
  });
102
103
 
103
- test("edit execute:旧 oldText/newText → isError + legacy 提示", async () => {
104
+ test("edit execute: legacy oldText/newText → isError + legacy hint", async () => {
104
105
  await withDir(async (dir) => {
105
106
  await writeFile(join(dir, "f.txt"), "a\n");
106
107
  const r: any = await call(makeEditOverride(dir), {
@@ -113,7 +114,7 @@ test("edit execute:旧 oldText/newText → isError + legacy 提示", async ()
113
114
  });
114
115
  });
115
116
 
116
- test("edit executeparse 错误 → isError", async () => {
117
+ test("edit execute: parse error → isError", async () => {
117
118
  await withDir(async (dir) => {
118
119
  await writeFile(join(dir, "f.txt"), "a\n");
119
120
  await call(makeReadOverride(dir), { path: "f.txt" });