pi-readseek 0.3.23 → 0.3.24

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": "pi-readseek",
3
- "version": "0.3.23",
3
+ "version": "0.3.24",
4
4
  "description": "Pi extension for readseek-backed hash-anchored read/edit/grep, structural code maps, structural search, and file exploration",
5
5
  "type": "module",
6
6
  "exports": {
package/src/edit.ts CHANGED
@@ -20,9 +20,10 @@ import { resolveSyntaxValidateMode, type SyntaxValidateOptions } from "./syntax-
20
20
  import { replaceSymbol } from "./replace-symbol.js";
21
21
  import { buildEditPreviewKey, buildPendingEditPreviewData, resolvePendingDiffPreview, type PendingDiffPreviewResult } from "./pending-diff-preview.js";
22
22
  import { buildDiffData, type DiffBlockRange } from "./diff-data.js";
23
- import { clampLineToWidth, clampLinesToWidth, linkToolPath, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
23
+ import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
24
24
  import { DiffPreviewComponent } from "./tui-diff-component.js";
25
25
  import type { FreshAnchorsPredicate } from "./tool-types.js";
26
+ import { registerReadseekTool } from "./register-tool.js";
26
27
 
27
28
  import { resolveEditDiffDisplay } from "./readseek-settings.js";
28
29
 
@@ -566,22 +567,17 @@ export async function executeEdit(opts: ExecuteEditOptions): Promise<any> {
566
567
 
567
568
 
568
569
  export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}) {
569
- const toolConfig = {
570
- callable: true,
571
- enabled: true,
572
- policy: "mutating" as const,
573
- readOnly: false,
570
+ const tool = registerReadseekTool(pi, {
571
+ policy: "mutating",
574
572
  pythonName: "edit",
575
- defaultExposure: "not-safe-by-default" as const,
576
- };
577
- const tool = {
573
+ defaultExposure: "not-safe-by-default",
574
+ }, {
578
575
  name: "edit",
579
576
  label: "Edit",
580
577
  description: EDIT_PROMPT_METADATA.description,
581
578
  promptSnippet: EDIT_PROMPT_METADATA.promptSnippet,
582
579
  promptGuidelines: EDIT_PROMPT_METADATA.promptGuidelines,
583
580
  parameters: hashlineEditSchema,
584
- ptc: toolConfig,
585
581
  renderShell: "default" as const,
586
582
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
587
583
  return executeEdit({
@@ -645,7 +641,7 @@ export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}
645
641
  const { isPartial, isError, expanded: baseExpanded, width, context } = resolveRenderResultContext(options, rest);
646
642
 
647
643
  if (isPartial) {
648
- return new Text(clampLinesToWidth([summaryLine("pending edit")], width).join("\n"), 0, 0);
644
+ return renderPendingResult("pending edit", width);
649
645
  }
650
646
 
651
647
  // Extract data from result
@@ -698,8 +694,6 @@ export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}
698
694
  }
699
695
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
700
696
  },
701
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
702
-
703
- pi.registerTool(tool);
697
+ });
704
698
  return tool;
705
699
  }
package/src/grep.ts CHANGED
@@ -17,8 +17,9 @@ import { throwIfAborted } from "./runtime.js";
17
17
  import { Text } from "@earendil-works/pi-tui";
18
18
  import { formatGrepCallText, formatGrepResultText } from "./grep-render-helpers.js";
19
19
  import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
20
- import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
20
+ import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderErrorResult, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
21
21
  import type { FileAnchoredCallback } from "./tool-types.js";
22
+ import { registerReadseekTool } from "./register-tool.js";
22
23
 
23
24
  const GREP_PROMPT_METADATA = defineToolPromptMetadata({
24
25
  promptUrl: new URL("../prompts/grep.md", import.meta.url),
@@ -508,21 +509,15 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
508
509
  }
509
510
 
510
511
  export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}) {
511
- const toolConfig = {
512
- callable: true,
513
- enabled: true,
514
- policy: "read-only" as const,
515
- readOnly: true,
512
+ const tool = registerReadseekTool(pi, {
513
+ policy: "read-only",
516
514
  pythonName: "grep",
517
- defaultExposure: "safe-by-default" as const,
518
- };
519
-
520
- const tool = {
515
+ defaultExposure: "safe-by-default",
516
+ }, {
521
517
  name: "grep",
522
518
  label: "grep",
523
519
  description: GREP_PROMPT_METADATA.description,
524
520
  parameters: grepSchema,
525
- ptc: toolConfig,
526
521
  promptSnippet: GREP_PROMPT_METADATA.promptSnippet,
527
522
  promptGuidelines: options.searchGuideline
528
523
  ? [GREP_PROMPT_METADATA.promptGuidelines[0], options.searchGuideline]
@@ -558,16 +553,12 @@ export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}
558
553
  renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
559
554
  const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
560
555
 
561
- if (isPartial) return new Text(clampLinesToWidth([summaryLine("pending search")], width).join("\n"), 0, 0);
556
+ if (isPartial) return renderPendingResult("pending search", width);
562
557
 
563
558
  const content = result.content?.[0];
564
559
  const textContent = content?.type === "text" ? content.text : "";
565
560
 
566
- if (isError || result.isError) {
567
- const firstLine = textContent.split("\n")[0] || "Error";
568
- const body = expanded && textContent ? textContent : firstLine;
569
- return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
570
- }
561
+ if (isError || result.isError) return renderErrorResult(textContent, { expanded, width });
571
562
 
572
563
  const readseekValue = (result.details as any)?.readseekValue as {
573
564
  tool: "grep";
@@ -607,8 +598,6 @@ export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}
607
598
  }
608
599
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
609
600
  },
610
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
611
-
612
- pi.registerTool(tool);
601
+ });
613
602
  return tool;
614
603
  }
package/src/read.ts CHANGED
@@ -26,8 +26,9 @@ import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
26
26
  import { readseekRead } from "./readseek-client.js";
27
27
  import { Text } from "@earendil-works/pi-tui";
28
28
  import { formatReadCallText, formatReadResultText } from "./read-render-helpers.js";
29
- import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidth } from "./tui-render-utils.js";
29
+ import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidth } from "./tui-render-utils.js";
30
30
  import type { FileAnchoredCallback } from "./tool-types.js";
31
+ import { registerReadseekTool } from "./register-tool.js";
31
32
 
32
33
  const READ_PROMPT_METADATA = defineToolPromptMetadata({
33
34
  promptUrl: new URL("../prompts/read.md", import.meta.url),
@@ -423,16 +424,11 @@ function splitReadseekLines(text: string): string[] {
423
424
  }
424
425
 
425
426
  export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}) {
426
- const toolConfig = {
427
- callable: true,
428
- enabled: true,
429
- policy: "read-only" as const,
430
- readOnly: true,
427
+ const tool = registerReadseekTool(pi, {
428
+ policy: "read-only",
431
429
  pythonName: "read",
432
- defaultExposure: "safe-by-default" as const,
433
- };
434
-
435
- const tool = {
430
+ defaultExposure: "safe-by-default",
431
+ }, {
436
432
  name: "read",
437
433
  label: "Read",
438
434
  description: READ_PROMPT_METADATA.description,
@@ -460,7 +456,6 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
460
456
  }),
461
457
  ),
462
458
  }),
463
- ptc: toolConfig,
464
459
  async execute(toolCallId, params, signal, onUpdate, ctx) {
465
460
  return executeRead({
466
461
  toolCallId,
@@ -489,7 +484,7 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
489
484
  },
490
485
  renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
491
486
  const { isPartial, isError, expanded, width } = resolveRenderResultContext(options, rest);
492
- if (isPartial) return new Text(clampLinesToWidth([summaryLine("pending read")], width).join("\n"), 0, 0);
487
+ if (isPartial) return renderPendingResult("pending read", width);
493
488
 
494
489
  const content = result.content?.[0];
495
490
  const textContent = content?.type === "text" ? content.text : "";
@@ -516,8 +511,6 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
516
511
  if (expanded && textContent) text += "\n" + wrapReadHashlinesForWidth(textContent, width);
517
512
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
518
513
  },
519
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
520
-
521
- pi.registerTool(tool);
514
+ });
522
515
  return tool;
523
516
  }
@@ -203,6 +203,28 @@ export function isReadseekAvailable(): boolean {
203
203
  }
204
204
  }
205
205
 
206
+ export interface ReadseekFailure {
207
+ code: "readseek-not-installed" | "readseek-execution-error";
208
+ message: string;
209
+ hint?: string;
210
+ }
211
+
212
+ /**
213
+ * Classify an error thrown while invoking readseek into the shared failure
214
+ * taxonomy: a missing binary or package (`readseek-not-installed`, with an
215
+ * install hint) versus any other execution error.
216
+ */
217
+ export function classifyReadseekFailure(err: unknown): ReadseekFailure {
218
+ const message = String((err as { message?: unknown } | null)?.message || err);
219
+ const missing =
220
+ (err as { code?: unknown } | null)?.code === "ENOENT" ||
221
+ /Cannot find package|Cannot find module|no such file/i.test(message);
222
+ if (missing) {
223
+ return { code: "readseek-not-installed", message, hint: "Run npm install to install @jarkkojs/readseek." };
224
+ }
225
+ return { code: "readseek-execution-error", message };
226
+ }
227
+
206
228
  function directoryExists(dirPath: string): boolean {
207
229
  try {
208
230
  return statSync(dirPath).isDirectory();
package/src/refs.ts CHANGED
@@ -6,9 +6,10 @@ import { stat as fsStat } from "node:fs/promises";
6
6
  import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
7
7
  import { buildReadseekLineWithHash, buildToolErrorResult } from "./readseek-value.js";
8
8
  import { resolveToCwd } from "./path-utils.js";
9
- import { isReadseekAvailable, readseekRefs, type ReadseekReference } from "./readseek-client.js";
9
+ import { classifyReadseekFailure, readseekRefs, type ReadseekReference } from "./readseek-client.js";
10
10
  import { buildRefsOutput, type RefsOutputFile, type RefsOutputLine } from "./refs-output.js";
11
11
  import type { FileAnchoredCallback } from "./tool-types.js";
12
+ import { registerReadseekTool } from "./register-tool.js";
12
13
 
13
14
  import { clampLineToWidth, renderAnchoredFilesResult, renderToolLabel } from "./tui-render-utils.js";
14
15
 
@@ -29,10 +30,6 @@ const REFS_PROMPT_METADATA = defineToolPromptMetadata({
29
30
  promptSnippet: "Find references to an identifier with readseek and return edit-ready anchors",
30
31
  });
31
32
 
32
- export function isRefsAvailable(): boolean {
33
- return isReadseekAvailable();
34
- }
35
-
36
33
  interface RefsToolOptions {
37
34
  onFileAnchored?: FileAnchoredCallback;
38
35
  }
@@ -127,28 +124,17 @@ export async function executeRefs(opts: ExecuteRefsOptions): Promise<any> {
127
124
  details: { readseekValue: builtOutput.readseekValue },
128
125
  };
129
126
  } catch (err: any) {
130
- const message = String(err?.message || err);
131
- const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
132
- return buildToolErrorResult(
133
- "refs",
134
- missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
135
- message,
136
- missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
137
- );
127
+ const failure = classifyReadseekFailure(err);
128
+ return buildToolErrorResult("refs", failure.code, failure.message, failure.hint ? { hint: failure.hint } : {});
138
129
  }
139
130
  }
140
131
 
141
132
  export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}) {
142
- const toolConfig = {
143
- callable: true,
144
- enabled: true,
145
- policy: "read-only" as const,
146
- readOnly: true,
133
+ const tool = registerReadseekTool(pi, {
134
+ policy: "read-only",
147
135
  pythonName: "refs",
148
- defaultExposure: "opt-in" as const,
149
- };
150
-
151
- const tool = {
136
+ defaultExposure: "opt-in",
137
+ }, {
152
138
  name: "refs",
153
139
  label: "References",
154
140
  description: REFS_PROMPT_METADATA.description,
@@ -165,7 +151,6 @@ export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}
165
151
  others: Type.Optional(Type.Boolean({ description: "In a Git repository, search untracked files" })),
166
152
  ignored: Type.Optional(Type.Boolean({ description: "With others=true, include ignored untracked files" })),
167
153
  }),
168
- ptc: toolConfig,
169
154
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
170
155
  return executeRefs({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
171
156
  },
@@ -186,8 +171,6 @@ export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}
186
171
  unitPlural: "references",
187
172
  });
188
173
  },
189
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
190
-
191
- pi.registerTool(tool);
174
+ });
192
175
  return tool;
193
176
  }
@@ -0,0 +1,47 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type ReadseekToolPolicy = "read-only" | "mutating";
4
+
5
+ export type ReadseekToolExposure = "safe-by-default" | "opt-in" | "not-safe-by-default";
6
+
7
+ /**
8
+ * Per-tool fields of the pi tool config (`ptc`) that genuinely differ between
9
+ * readseek tools. The remaining fields are constant or derived.
10
+ */
11
+ export interface ReadseekToolConfig {
12
+ policy: ReadseekToolPolicy;
13
+ pythonName: string;
14
+ defaultExposure: ReadseekToolExposure;
15
+ }
16
+
17
+ export interface ReadseekToolPtc extends ReadseekToolConfig {
18
+ callable: true;
19
+ enabled: true;
20
+ readOnly: boolean;
21
+ }
22
+
23
+ type ToolSpec = Parameters<ExtensionAPI["registerTool"]>[0];
24
+
25
+ /**
26
+ * Attach the standard `ptc` envelope to a tool definition and register it.
27
+ *
28
+ * `callable` and `enabled` are always true and `readOnly` is derived from
29
+ * `policy`, so callers supply only the fields that vary between tools.
30
+ */
31
+ export function registerReadseekTool<T extends ToolSpec>(
32
+ pi: ExtensionAPI,
33
+ config: ReadseekToolConfig,
34
+ tool: T,
35
+ ): T & { ptc: ReadseekToolPtc } {
36
+ const ptc: ReadseekToolPtc = {
37
+ callable: true,
38
+ enabled: true,
39
+ policy: config.policy,
40
+ readOnly: config.policy === "read-only",
41
+ pythonName: config.pythonName,
42
+ defaultExposure: config.defaultExposure,
43
+ };
44
+ const registered = { ...tool, ptc };
45
+ pi.registerTool(registered);
46
+ return registered;
47
+ }
package/src/sg.ts CHANGED
@@ -6,9 +6,10 @@ import { stat as fsStat } from "node:fs/promises";
6
6
  import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
7
7
  import { buildReadseekLineWithHash, buildToolErrorResult, type ReadseekLine } from "./readseek-value.js";
8
8
  import { resolveToCwd } from "./path-utils.js";
9
- import { isReadseekAvailable, readseekSearch, type ReadseekHashline, type ReadseekSearchFileOutput } from "./readseek-client.js";
9
+ import { classifyReadseekFailure, isReadseekAvailable, readseekSearch, type ReadseekHashline, type ReadseekSearchFileOutput } from "./readseek-client.js";
10
10
  import { buildSgOutput } from "./sg-output.js";
11
11
  import type { FileAnchoredCallback } from "./tool-types.js";
12
+ import { registerReadseekTool } from "./register-tool.js";
12
13
 
13
14
  import { clampLineToWidth, renderAnchoredFilesResult, renderToolLabel } from "./tui-render-utils.js";
14
15
 
@@ -193,28 +194,17 @@ export async function executeSg(opts: ExecuteSgOptions): Promise<any> {
193
194
  },
194
195
  };
195
196
  } catch (err: any) {
196
- const message = String(err?.message || err);
197
- const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
198
- return buildToolErrorResult(
199
- "search",
200
- missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
201
- message,
202
- missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
203
- );
197
+ const failure = classifyReadseekFailure(err);
198
+ return buildToolErrorResult("search", failure.code, failure.message, failure.hint ? { hint: failure.hint } : {});
204
199
  }
205
200
  }
206
201
 
207
202
  export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
208
- const toolConfig = {
209
- callable: true,
210
- enabled: true,
211
- policy: "read-only" as const,
212
- readOnly: true,
203
+ const tool = registerReadseekTool(pi, {
204
+ policy: "read-only",
213
205
  pythonName: "search",
214
- defaultExposure: "opt-in" as const,
215
- };
216
-
217
- const tool = {
206
+ defaultExposure: "opt-in",
207
+ }, {
218
208
  name: "search",
219
209
  label: "Structural Search",
220
210
  description: SG_PROMPT_METADATA.description,
@@ -228,7 +218,6 @@ export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
228
218
  others: Type.Optional(Type.Boolean({ description: "In a Git repository, search untracked files" })),
229
219
  ignored: Type.Optional(Type.Boolean({ description: "With others=true, include ignored untracked files" })),
230
220
  }),
231
- ptc: toolConfig,
232
221
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
233
222
  return executeSg({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
234
223
  },
@@ -249,8 +238,6 @@ export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
249
238
  unitPlural: "matches",
250
239
  });
251
240
  },
252
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
253
-
254
- pi.registerTool(tool);
241
+ });
255
242
  return tool;
256
243
  }
@@ -156,6 +156,27 @@ export function wrapReadHashlinesForWidth(text: string, width: number | undefine
156
156
  return output.join("\n");
157
157
  }
158
158
 
159
+ /**
160
+ * Render the `isPartial` placeholder line shared by every tool's `renderResult`.
161
+ */
162
+ export function renderPendingResult(pendingLabel: string, width: number | undefined): Text {
163
+ return new Text(clampLinesToWidth([summaryLine(pendingLabel)], width).join("\n"), 0, 0);
164
+ }
165
+
166
+ /**
167
+ * Render a tool result error: the first line of `textContent`, or its full body
168
+ * when expanded, prefixed and clamped to width. Tools pass their own `fallback`
169
+ * for the empty-content case.
170
+ */
171
+ export function renderErrorResult(
172
+ textContent: string,
173
+ options: { expanded: boolean; width: number | undefined; fallback?: string },
174
+ ): Text {
175
+ const firstLine = textContent.split("\n")[0] || (options.fallback ?? "Error");
176
+ const body = options.expanded && textContent ? textContent : firstLine;
177
+ return new Text(clampLinesToWidth(summaryLine(body).split("\n"), options.width).join("\n"), 0, 0);
178
+ }
179
+
159
180
  export interface AnchoredFilesLabels {
160
181
  pendingLabel: string;
161
182
  emptyLabel: string;
@@ -178,15 +199,11 @@ export function renderAnchoredFilesResult(
178
199
  ): Text {
179
200
  const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
180
201
 
181
- if (isPartial) return new Text(clampLinesToWidth([summaryLine(labels.pendingLabel)], width).join("\n"), 0, 0);
202
+ if (isPartial) return renderPendingResult(labels.pendingLabel, width);
182
203
 
183
204
  const content = result.content?.[0];
184
205
  const textContent = content?.type === "text" ? content.text : "";
185
- if (isError || result.isError) {
186
- const firstLine = textContent.split("\n")[0] || "Error";
187
- const body = expanded && textContent ? textContent : firstLine;
188
- return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
189
- }
206
+ if (isError || result.isError) return renderErrorResult(textContent, { expanded, width });
190
207
 
191
208
  const readseekValue = (result.details as any)?.readseekValue as
192
209
  | { files: Array<{ path: string; lines: any[] }> }
package/src/write.ts CHANGED
@@ -14,9 +14,10 @@ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
14
14
  import { buildPendingWritePreviewData, buildWritePreviewKey, resolvePendingDiffPreview, type PendingDiffPreviewResult } from "./pending-diff-preview.js";
15
15
  import { generateCompactOrFullDiff, normalizeToLF, hasBareCarriageReturn } from "./edit-diff.js";
16
16
  import { buildDiffData, type DiffData } from "./diff-data.js";
17
- import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderToolLabel, summaryLine } from "./tui-render-utils.js";
17
+ import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderErrorResult, renderToolLabel, summaryLine } from "./tui-render-utils.js";
18
18
  import { DiffPreviewComponent } from "./tui-diff-component.js";
19
19
  import type { FileAnchoredCallback } from "./tool-types.js";
20
+ import { registerReadseekTool } from "./register-tool.js";
20
21
 
21
22
  const WRITE_PENDING_PREVIEW_STATE_KEY = "hashline-write-pending-preview";
22
23
 
@@ -169,6 +170,30 @@ function mapFsWriteError(err: any, path: string): MappedFsError {
169
170
  };
170
171
  }
171
172
 
173
+ function buildWriteFsErrorResult(err: any, absolutePath: string) {
174
+ const mapped = mapFsWriteError(err, absolutePath);
175
+ return {
176
+ content: [{ type: "text" as const, text: mapped.message }],
177
+ isError: true,
178
+ details: {
179
+ readseekValue: {
180
+ tool: "write" as const,
181
+ path: absolutePath,
182
+ lines: [] as ReadseekLine[],
183
+ warnings: [] as ReadseekWarning[],
184
+ ok: false,
185
+ error: buildReadseekError(
186
+ mapped.code,
187
+ mapped.message,
188
+ undefined,
189
+ mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
190
+ ),
191
+ },
192
+ warnings: [] as string[],
193
+ },
194
+ };
195
+ }
196
+
172
197
  export async function executeWrite(opts: {
173
198
  path: string;
174
199
  content: string;
@@ -301,21 +326,16 @@ export async function executeWrite(opts: {
301
326
  }
302
327
 
303
328
  export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions = {}) {
304
- const toolConfig = {
305
- callable: true,
306
- enabled: true,
307
- policy: "mutating" as const,
308
- readOnly: false,
329
+ const tool = registerReadseekTool(pi, {
330
+ policy: "mutating",
309
331
  pythonName: "write",
310
- defaultExposure: "not-safe-by-default" as const,
311
- };
312
- const tool = {
332
+ defaultExposure: "not-safe-by-default",
333
+ }, {
313
334
  name: "write",
314
335
  label: "write",
315
336
  description: WRITE_PROMPT_METADATA.description,
316
337
  promptSnippet: WRITE_PROMPT_METADATA.promptSnippet,
317
338
  promptGuidelines: WRITE_PROMPT_METADATA.promptGuidelines,
318
- ptc: toolConfig,
319
339
  parameters: Type.Object({
320
340
  path: Type.String({ description: "File path" }),
321
341
  content: Type.String({ description: "File content" }),
@@ -325,112 +345,72 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
325
345
  const cwd = ctx?.cwd ?? process.cwd();
326
346
  const absolutePath = resolveToCwd(params.path, cwd);
327
347
  try {
328
- return await withFileMutationQueue(absolutePath, async () => {
329
- let result: WriteResult;
330
- try {
331
- result = await executeWrite({
332
- path: absolutePath,
333
- content: params.content,
334
- map: params.map,
335
- cwd,
336
- });
337
- } catch (err: any) {
338
- const mapped = mapFsWriteError(err, absolutePath);
339
- return {
340
- content: [{ type: "text" as const, text: mapped.message }],
341
- isError: true,
342
- details: {
343
- readseekValue: {
344
- tool: "write" as const,
348
+ return await withFileMutationQueue(absolutePath, async () => {
349
+ let result: WriteResult;
350
+ try {
351
+ result = await executeWrite({
345
352
  path: absolutePath,
346
- lines: [] as ReadseekLine[],
347
- warnings: [] as ReadseekWarning[],
348
- ok: false,
349
- error: buildReadseekError(
350
- mapped.code,
351
- mapped.message,
352
- undefined,
353
- mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
354
- ),
355
- },
356
- warnings: [] as string[],
357
- },
358
- };
359
- }
353
+ content: params.content,
354
+ map: params.map,
355
+ cwd,
356
+ });
357
+ } catch (err: any) {
358
+ return buildWriteFsErrorResult(err, absolutePath);
359
+ }
360
360
 
361
- if (result.readseekValue.lines.length > 0) {
362
- options.onFileAnchored?.(absolutePath);
363
- }
361
+ if (result.readseekValue.lines.length > 0) {
362
+ options.onFileAnchored?.(absolutePath);
363
+ }
364
364
 
365
- // Lift binary-content signal into a fatal readseekValue.error envelope so
366
- // downstream consumers get the same taxonomy shape as every other tool.
367
- // The existing ReadseekWarning entry is preserved on readseekValue.warnings for
368
- // backward compatibility (see AC 12 — warnings namespace alignment).
369
- const binaryWarning = result.readseekValue.warnings.find((w) => w.code === "binary-content");
370
- if (binaryWarning) {
371
- return {
372
- content: [{ type: "text" as const, text: result.text }],
373
- isError: true,
374
- details: {
375
- readseekValue: {
376
- ...result.readseekValue,
377
- ok: false,
378
- error: buildReadseekError("binary-content", binaryWarning.message),
379
- },
380
- warnings: result.warnings,
381
- },
382
- };
383
- }
365
+ // Lift binary-content signal into a fatal readseekValue.error envelope so
366
+ // downstream consumers get the same taxonomy shape as every other tool.
367
+ // The existing ReadseekWarning entry is preserved on readseekValue.warnings for
368
+ // backward compatibility (see AC 12 — warnings namespace alignment).
369
+ const binaryWarning = result.readseekValue.warnings.find((w) => w.code === "binary-content");
370
+ if (binaryWarning) {
371
+ return {
372
+ content: [{ type: "text" as const, text: result.text }],
373
+ isError: true,
374
+ details: {
375
+ readseekValue: {
376
+ ...result.readseekValue,
377
+ ok: false,
378
+ error: buildReadseekError("binary-content", binaryWarning.message),
379
+ },
380
+ warnings: result.warnings,
381
+ },
382
+ };
383
+ }
384
384
 
385
- const bareCrWarning = result.readseekValue.warnings.find((w) => w.code === "bare-cr");
386
- if (bareCrWarning) {
387
- return {
388
- content: [{ type: "text" as const, text: result.text }],
389
- isError: true,
390
- details: {
391
- readseekValue: {
392
- ...result.readseekValue,
393
- ok: false,
394
- error: buildReadseekError("bare-cr", bareCrWarning.message),
395
- },
396
- warnings: result.warnings,
397
- },
398
- };
399
- }
385
+ const bareCrWarning = result.readseekValue.warnings.find((w) => w.code === "bare-cr");
386
+ if (bareCrWarning) {
387
+ return {
388
+ content: [{ type: "text" as const, text: result.text }],
389
+ isError: true,
390
+ details: {
391
+ readseekValue: {
392
+ ...result.readseekValue,
393
+ ok: false,
394
+ error: buildReadseekError("bare-cr", bareCrWarning.message),
395
+ },
396
+ warnings: result.warnings,
397
+ },
398
+ };
399
+ }
400
400
 
401
- return {
402
- content: [{ type: "text" as const, text: result.text }],
403
- details: {
404
- ...(result.diff !== undefined ? { diff: result.diff } : {}),
405
- ...(result.diffData !== undefined ? { diffData: result.diffData } : {}),
406
- ...(result.writeState ? { writeState: result.writeState } : {}),
407
- readseekValue: result.readseekValue,
408
- warnings: result.warnings,
409
- },
410
- };
411
- });
412
- } catch (err: any) {
413
- const mapped = mapFsWriteError(err, absolutePath);
414
- return {
415
- content: [{ type: "text" as const, text: mapped.message }],
416
- isError: true,
417
- details: {
418
- readseekValue: {
419
- tool: "write" as const,
420
- path: absolutePath,
421
- lines: [] as ReadseekLine[],
422
- warnings: [] as ReadseekWarning[],
423
- ok: false,
424
- error: buildReadseekError(
425
- mapped.code,
426
- mapped.message,
427
- undefined,
428
- mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
429
- ),
401
+ return {
402
+ content: [{ type: "text" as const, text: result.text }],
403
+ details: {
404
+ ...(result.diff !== undefined ? { diff: result.diff } : {}),
405
+ ...(result.diffData !== undefined ? { diffData: result.diffData } : {}),
406
+ ...(result.writeState ? { writeState: result.writeState } : {}),
407
+ readseekValue: result.readseekValue,
408
+ warnings: result.warnings,
430
409
  },
431
- warnings: [] as string[],
432
- },
433
- };
410
+ };
411
+ });
412
+ } catch (err: any) {
413
+ return buildWriteFsErrorResult(err, absolutePath);
434
414
  }
435
415
  },
436
416
  renderCall(args: any, theme: any, context: any = {}) {
@@ -478,9 +458,7 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
478
458
  const details = result.details ?? {};
479
459
  const output = result.content?.[0]?.type === "text" ? result.content[0].text : "";
480
460
  if (result.isError || details.readseekValue?.ok === false) {
481
- const firstLine = output.split("\n")[0] || "write failed";
482
- const body = expanded && output ? output : firstLine;
483
- return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
461
+ return renderErrorResult(output, { expanded, width, fallback: "write failed" });
484
462
  }
485
463
  const diffData = details.diffData;
486
464
  const state = details.writeState === "overwritten" ? "overwritten" : "created";
@@ -510,7 +488,6 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
510
488
  }
511
489
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
512
490
  },
513
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
514
- pi.registerTool(tool);
491
+ });
515
492
  return tool;
516
493
  }