@d3ara1n/pi-hashline-edit 0.3.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-hashline-edit",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Hashline-style file editing for pi — line-anchored edits verified by content hash, replacing oldText/newText matching",
6
6
  "keywords": [
@@ -209,7 +209,9 @@ export function makeEditOverride(cwd: string) {
209
209
  }
210
210
  const diff: string | undefined = result.details?.diff;
211
211
  if (!diff) {
212
- const t = content?.type === "text" ? content.text : "Edited";
212
+ // No net diff (e.g. a successful but non-mutating edit): show only the summary
213
+ // line — content.text also carries `Updated anchors` (hashline) for the model.
214
+ const t = content?.type === "text" ? content.text.split("\n")[0] : "Edited";
213
215
  return new Text(theme.fg("success", t), 0, 0);
214
216
  }
215
217
  // details.diff is pi-format (+N/-N/<space>N content); color by leading char
@@ -33,6 +33,7 @@ import { hashFileLines } from "../core/hash.ts";
33
33
  import { splitLines } from "../core/lines.ts";
34
34
  import { getState } from "./state.ts";
35
35
  import { canonicalPath } from "./read-tool.ts";
36
+ import { parseHashline } from "./render.ts";
36
37
 
37
38
  const DEFAULT_LIMIT = 100;
38
39
  /** Max chars per result line for display (mirrors pi's truncate.ts; not exported there). */
@@ -89,9 +90,9 @@ function toDisplayLines(raw: string, theme: any): string[] {
89
90
  const group: { lineNo: string; content: string }[] = [];
90
91
  let j = i + 1;
91
92
  while (j < lines.length) {
92
- const a = lines[j].match(/^(\d+)#[A-Za-z0-9]+│(.*)$/);
93
+ const a = parseHashline(lines[j]);
93
94
  if (!a) break;
94
- group.push({ lineNo: a[1], content: a[2] });
95
+ group.push({ lineNo: a.lineNo, content: a.content });
95
96
  j++;
96
97
  }
97
98
  // common base = min leading whitespace across the group; fold it into a marker
@@ -9,13 +9,15 @@
9
9
  * @module pi-hashline-edit/pi
10
10
  */
11
11
 
12
- import { createReadTool } from "@earendil-works/pi-coding-agent";
12
+ import { createReadTool, getLanguageFromPath, highlightCode } from "@earendil-works/pi-coding-agent";
13
+ import { Text } from "@earendil-works/pi-tui";
13
14
  import { readFile } from "node:fs/promises";
14
15
  import { join, resolve } from "node:path";
15
16
  import { homedir } from "node:os";
16
17
  import { hashFileLines } from "../core/hash.ts";
17
18
  import { splitLines } from "../core/lines.ts";
18
19
  import { getState } from "./state.ts";
20
+ import { parseHashline } from "./render.ts";
19
21
 
20
22
  const MAX_LINES = 2000;
21
23
  const MAX_BYTES = 256 * 1024;
@@ -37,6 +39,70 @@ function expandTilde(p: string): string {
37
39
  return p;
38
40
  }
39
41
 
42
+ /** Offset/limit range suffix for the read call line, e.g. `:50-99` (mirrors pi core's read tool). */
43
+ function formatReadLineRange(args: any, theme: any): string {
44
+ if (args?.offset === undefined && args?.limit === undefined) return "";
45
+ const start = args.offset ?? 1;
46
+ const end = args.limit !== undefined ? start + args.limit - 1 : "";
47
+ return theme.fg("warning", `:${start}${end ? `-${end}` : ""}`);
48
+ }
49
+
50
+ /**
51
+ * Render the expanded read body for the TUI: color the header, strip the
52
+ * `LINE#HASH│` prefix from every anchor line to ` N: content`, and
53
+ * syntax-highlight the code block by the file's language (falls back to a
54
+ * single `toolOutput` color when the language is unknown or the highlight
55
+ * line count diverges). Trailing notices (e.g. truncation) are shown in
56
+ * `warning`.
57
+ */
58
+ function renderReadBody(raw: string, path: string, theme: any): string {
59
+ const lines = raw.split("\n");
60
+ if (lines.length === 0) return "";
61
+ const out: string[] = [];
62
+
63
+ // Header: "<path> · <N> lines" optionally followed by " (from line <offset>)".
64
+ let bodyStart = 0;
65
+ const h = lines[0].match(/^(.+?) · (\d+ lines(?: \(from line \d+\))?)$/);
66
+ if (h) {
67
+ out.push(theme.fg("success", h[1]) + theme.fg("dim", ` · ${h[2]}`));
68
+ bodyStart = 1;
69
+ }
70
+
71
+ // Collect anchor rows (full content); the first non-anchor line begins the tail.
72
+ const lineNos: string[] = [];
73
+ const codeContents: string[] = [];
74
+ let tailStart = lines.length;
75
+ for (let i = bodyStart; i < lines.length; i++) {
76
+ const row = parseHashline(lines[i]);
77
+ if (!row) {
78
+ tailStart = i;
79
+ break;
80
+ }
81
+ lineNos.push(row.lineNo);
82
+ codeContents.push(row.content);
83
+ }
84
+
85
+ // Syntax-highlight the whole block so multi-line constructs stay correct.
86
+ const detabbed = codeContents.map((l) => l.replace(/\t/g, " "));
87
+ const lang = getLanguageFromPath(path);
88
+ let rendered: string[];
89
+ if (lang) {
90
+ const hl = highlightCode(detabbed.join("\n"), lang);
91
+ // Guard against highlighters that reshape line count: fall back to plain.
92
+ rendered = hl.length === detabbed.length ? hl : detabbed.map((l) => theme.fg("toolOutput", l));
93
+ } else {
94
+ rendered = detabbed.map((l) => theme.fg("toolOutput", l));
95
+ }
96
+ for (let i = 0; i < rendered.length && i < lineNos.length; i++) {
97
+ out.push(theme.fg("dim", ` ${lineNos[i]}: `) + rendered[i]);
98
+ }
99
+
100
+ for (let i = tailStart; i < lines.length; i++) {
101
+ out.push(theme.fg("warning", lines[i]));
102
+ }
103
+ return out.join("\n");
104
+ }
105
+
40
106
  /** Build the read override (a ToolDefinition fragment for registerTool). */
41
107
  export function makeReadOverride(cwd: string) {
42
108
  const builtin = createReadTool(cwd);
@@ -52,6 +118,29 @@ export function makeReadOverride(cwd: string) {
52
118
  "Pass `path`; optionally `offset` (1-indexed start line) and `limit` (max lines). Prefer read over cat/sed for files you intend to edit.",
53
119
  ],
54
120
  parameters: builtin.parameters,
121
+ renderShell: "default" as const,
122
+
123
+ renderCall(args: any, theme: any) {
124
+ const pathDisplay = String(args?.path ?? "");
125
+ let text = theme.fg("toolTitle", theme.bold("read")) + " " + theme.fg("accent", pathDisplay);
126
+ const range = formatReadLineRange(args, theme);
127
+ if (range) text += range;
128
+ return new Text(text, 0, 0);
129
+ },
130
+
131
+ renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
132
+ if (isPartial) return new Text(theme.fg("warning", "Reading…"), 0, 0);
133
+ const content = result.content?.[0];
134
+ if (context?.isError) {
135
+ const t = content?.type === "text" ? content.text.split("\n")[0] : "Error";
136
+ return new Text(theme.fg("error", t), 0, 0);
137
+ }
138
+ // Collapsed (not expanded): show nothing — the call line carries the
139
+ // title, matching the built-in read's fold behavior.
140
+ if (!expanded) return new Text("", 0, 0);
141
+ const raw = content?.type === "text" ? content.text : "";
142
+ return new Text(renderReadBody(raw, String(context?.args?.path ?? ""), theme), 0, 0);
143
+ },
55
144
 
56
145
  async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any) {
57
146
  // Not enabled OR user cancelled → delegate to the built-in (builtin handles abort itself)
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared anchor-parsing helper for the hashline-aware tool renderers.
3
+ *
4
+ * The model-facing `content` text uses the `LINE#HASH│content` anchor format so
5
+ * an anchor can be copied straight into an edit op. The user-facing TUI
6
+ * renderers (read, grep) strip that prefix back to a clean ` N: content` form.
7
+ * Parsing the anchor format in one place keeps read and grep in sync.
8
+ *
9
+ * @module pi-hashline-edit/pi
10
+ */
11
+
12
+ const HASHLINE_RE = /^(\d+)#[A-Za-z0-9]+│(.*)$/;
13
+
14
+ export interface HashlineRow {
15
+ /** Line number as written in the anchor (string form). */
16
+ lineNo: string;
17
+ /** Line content with the `LINE#HASH│` prefix removed. */
18
+ content: string;
19
+ }
20
+
21
+ /**
22
+ * Parse a `LINE#HASH│content` anchor line.
23
+ *
24
+ * @returns the row, or `null` for non-anchor lines (headers, notices, free
25
+ * text) so callers can fall through to their own formatting.
26
+ */
27
+ export function parseHashline(line: string): HashlineRow | null {
28
+ const m = line.match(HASHLINE_RE);
29
+ return m ? { lineNo: m[1], content: m[2] } : null;
30
+ }
@@ -180,7 +180,9 @@ export function makeReplaceTool(cwd: string) {
180
180
  }
181
181
  const diff: string | undefined = result.details?.diff;
182
182
  if (!diff) {
183
- const t = content?.type === "text" ? content.text : "Replaced";
183
+ // No net diff: show only the summary line — content.text also carries
184
+ // `Updated anchors` (hashline) for the model.
185
+ const t = content?.type === "text" ? content.text.split("\n")[0] : "Replaced";
184
186
  return new Text(theme.fg("success", t), 0, 0);
185
187
  }
186
188
  // details.diff is pi-format (+N/-N/<space>N content); color by leading char