pi-readseek 0.4.15 → 0.4.17

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.4.15",
3
+ "version": "0.4.17",
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": {
@@ -39,10 +39,8 @@
39
39
  "node": ">=20.0.0"
40
40
  },
41
41
  "dependencies": {
42
- "@jarkkojs/readseek": "^0.4.20",
42
+ "@jarkkojs/readseek": "^0.4.21",
43
43
  "diff": "^8.0.3",
44
- "ignore": "^7.0.5",
45
- "picomatch": "^4.0.4",
46
44
  "xxhash-wasm": "^1.1.0"
47
45
  },
48
46
  "peerDependencies": {
package/src/edit.ts CHANGED
@@ -24,7 +24,7 @@ import { replaceSymbol } from "./replace-symbol.js";
24
24
  import { buildEditPreviewKey, buildPendingEditPreviewData, resolvePendingDiffPreview, type PendingDiffPreviewResult } from "./pending-diff-preview.js";
25
25
  import { buildDiffData, type DiffBlockRange } from "./diff-data.js";
26
26
  import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
27
- import { DiffPreviewComponent } from "./tui-diff-component.js";
27
+ import { upsertDiffComponent, upsertTextComponent } from "./tui-diff-component.js";
28
28
  import type { FreshAnchorsPredicate } from "./tool-types.js";
29
29
  import { registerReadSeekTool } from "./register-tool.js";
30
30
 
@@ -643,11 +643,7 @@ export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}
643
643
  // expandable diff). Keeping the "↳ pending edit" sub-line and its
644
644
  // preview alongside the final result is just duplicate noise.
645
645
  if (context.executionStarted) {
646
- const textComponent = (context.lastComponent && !(context.lastComponent instanceof DiffPreviewComponent))
647
- ? context.lastComponent
648
- : new Text("", 0, 0);
649
- textComponent.setText(text);
650
- return textComponent;
646
+ return upsertTextComponent(context.lastComponent, text);
651
647
  }
652
648
  const previewKey = buildEditPreviewKey(args ?? {});
653
649
  const preview = resolvePendingDiffPreview(
@@ -659,17 +655,9 @@ export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}
659
655
  const expanded = !!context.expanded || resolveEditDiffDisplay() === "expanded";
660
656
  const preview2 = pendingPreviewLines(text, preview, expanded);
661
657
  if (preview2.diffData) {
662
- const diffComponent = context.lastComponent instanceof DiffPreviewComponent
663
- ? context.lastComponent
664
- : new DiffPreviewComponent({ prefixLines: preview2.lines, diffData: preview2.diffData, theme, expanded: true });
665
- diffComponent.update({ prefixLines: preview2.lines, diffData: preview2.diffData, theme, expanded: true });
666
- return diffComponent;
658
+ return upsertDiffComponent(context.lastComponent, { prefixLines: preview2.lines, diffData: preview2.diffData, theme, expanded: true });
667
659
  }
668
- const textComponent = (context.lastComponent && !(context.lastComponent instanceof DiffPreviewComponent))
669
- ? context.lastComponent
670
- : new Text("", 0, 0);
671
- textComponent.setText(clampLinesToWidth(preview2.lines, context.width).join("\n"));
672
- return textComponent;
660
+ return upsertTextComponent(context.lastComponent, clampLinesToWidth(preview2.lines, context.width).join("\n"));
673
661
  },
674
662
  renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
675
663
  const { isPartial, isError, expanded: baseExpanded, width, context } = resolveRenderResultContext(options, rest);
@@ -719,11 +707,7 @@ export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}
719
707
  if (info.warningsBadge) badges.push(info.warningsBadge);
720
708
  text = summaryLine(badges.join(" • "), { hidden: !!diffData && !expanded });
721
709
  if (expanded && diffData) {
722
- const diffComponent = context.lastComponent instanceof DiffPreviewComponent
723
- ? context.lastComponent
724
- : new DiffPreviewComponent({ prefixLines: text.split("\n"), diffData, theme, expanded: true });
725
- diffComponent.update({ prefixLines: text.split("\n"), diffData, theme, expanded: true });
726
- return diffComponent;
710
+ return upsertDiffComponent(context.lastComponent, { prefixLines: text.split("\n"), diffData, theme, expanded: true });
727
711
  }
728
712
  }
729
713
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
package/src/grep.ts CHANGED
@@ -21,7 +21,8 @@ import { formatGrepCallText, formatGrepResultText } from "./grep-render-helpers.
21
21
  import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
22
22
  import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderErrorResult, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
23
23
  import type { FileAnchoredCallback } from "./tool-types.js";
24
- import { registerReadSeekTool } from "./register-tool.js";
24
+ import { optionalIntOrString, registerReadSeekTool } from "./register-tool.js";
25
+ import { searchPathParam } from "./readseek-params.js";
25
26
 
26
27
  const GREP_PROMPT_METADATA = defineToolPromptMetadata({
27
28
  promptUrl: new URL("../prompts/grep.md", import.meta.url),
@@ -30,34 +31,19 @@ const GREP_PROMPT_METADATA = defineToolPromptMetadata({
30
31
 
31
32
  const grepSchema = Type.Object({
32
33
  pattern: Type.String({ description: "Pattern to search" }),
33
- path: Type.Optional(Type.String({ description: "Search path" })),
34
+ path: searchPathParam(),
34
35
  glob: Type.Optional(Type.String({ description: "Glob filter" })),
35
36
  ignoreCase: Type.Optional(Type.Boolean({ description: "Ignore case" })),
36
37
  literal: Type.Optional(Type.Boolean({ description: "Treat pattern literally" })),
37
- context: Type.Optional(
38
- Type.Union([
39
- Type.Number({ description: "Context lines" }),
40
- Type.String({ description: "Context lines" }),
41
- ]),
42
- ),
43
- limit: Type.Optional(
44
- Type.Union([
45
- Type.Number({ description: "Max matches" }),
46
- Type.String({ description: "Max matches" }),
47
- ]),
48
- ),
38
+ context: optionalIntOrString("Context lines"),
39
+ limit: optionalIntOrString("Max matches"),
49
40
  summary: Type.Optional(Type.Boolean({ description: "Return per-file counts" })),
50
41
  scope: Type.Optional(
51
42
  Type.Literal("symbol", {
52
43
  description: "Scope matches to symbols",
53
44
  }),
54
45
  ),
55
- scopeContext: Type.Optional(
56
- Type.Union([
57
- Type.Number({ description: "Symbol context lines" }),
58
- Type.String({ description: "Symbol context lines" }),
59
- ]),
60
- ),
46
+ scopeContext: optionalIntOrString("Symbol context lines"),
61
47
  });
62
48
 
63
49
  interface GrepParams {
package/src/read.ts CHANGED
@@ -29,7 +29,7 @@ import { readseekRead, readseekDetect, type ReadSeekDetection } from "./readseek
29
29
  import { formatReadCallText, formatReadResultText } from "./read-render-helpers.js";
30
30
  import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidth } from "./tui-render-utils.js";
31
31
  import type { FileAnchoredCallback } from "./tool-types.js";
32
- import { registerReadSeekTool } from "./register-tool.js";
32
+ import { optionalIntOrString, registerReadSeekTool } from "./register-tool.js";
33
33
 
34
34
  const READ_PROMPT_METADATA = defineToolPromptMetadata({
35
35
  promptUrl: new URL("../prompts/read.md", import.meta.url),
@@ -145,7 +145,7 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
145
145
  } catch (err: any) {
146
146
  const code = err?.code;
147
147
  if (code === "EISDIR") {
148
- const message = `Path is a directory: ${rawPath}. Use ls to inspect directories.`;
148
+ const message = `Path is a directory: ${rawPath}`;
149
149
  return buildToolErrorResult("read", "path-is-directory", message, { path: rawParams.path, hint: `Use ls(${JSON.stringify(rawPath)}) to inspect directories.` });
150
150
  }
151
151
  if (code === "EACCES" || code === "EPERM") {
@@ -162,23 +162,23 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
162
162
 
163
163
  const hasBinaryContent = looksLikeBinary(rawBuffer);
164
164
  if (hasBinaryContent) {
165
- // Images are always binary; classify and OCR in a single readseek detect call.
165
+ // Images are always binary; classify and transcribe in a single readseek detect call.
166
166
  let detection: ReadSeekDetection | undefined;
167
167
  try {
168
- detection = await readseekDetect(absolutePath, { ocr: true, signal });
168
+ detection = await readseekDetect(absolutePath, { transcribe: true, signal });
169
169
  } catch {
170
170
  // detect unavailable — fall through to binary-as-text handling below
171
171
  }
172
172
  if (detection?.type === "image") {
173
173
  const builtinRead = createReadTool(cwd);
174
174
  const builtinResult = await builtinRead.execute(toolCallId, p, signal, onUpdate);
175
- const ocrText = detection.ocr?.text?.trim();
176
- if (ocrText) {
175
+ const transcript = detection.transcribe?.text?.trim();
176
+ if (transcript) {
177
177
  return succeed({
178
178
  ...builtinResult,
179
179
  content: [
180
180
  ...(builtinResult.content ?? []),
181
- { type: "text" as const, text: `OCR text extracted from image:\n${ocrText}` },
181
+ { type: "text" as const, text: `Transcribed text from image:\n${transcript}` },
182
182
  ],
183
183
  });
184
184
  }
@@ -194,7 +194,7 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
194
194
  let startLine = p.offset !== undefined ? p.offset : 1;
195
195
  let endIdx = p.limit !== undefined ? Math.min(startLine - 1 + p.limit, total) : total;
196
196
  if (p.offset !== undefined && startLine > total) {
197
- const message = `[offset ${p.offset} is past end of file (${total} lines)]`;
197
+ const message = `offset ${p.offset} is past end of file (${total} lines)`;
198
198
  return buildToolErrorResult("read", "offset-past-end", message, { path: rawParams.path });
199
199
  }
200
200
  let symbolMatch: SymbolMatch | undefined;
@@ -451,18 +451,8 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
451
451
  promptGuidelines: READ_PROMPT_METADATA.promptGuidelines,
452
452
  parameters: Type.Object({
453
453
  path: Type.String({ description: "File path" }),
454
- offset: Type.Optional(
455
- Type.Union([
456
- Type.Number({ description: "Start line (1-indexed)" }),
457
- Type.String({ description: "Start line (1-indexed)" }),
458
- ]),
459
- ),
460
- limit: Type.Optional(
461
- Type.Union([
462
- Type.Number({ description: "Max lines" }),
463
- Type.String({ description: "Max lines" }),
464
- ]),
465
- ),
454
+ offset: optionalIntOrString("Start line (1-indexed)"),
455
+ limit: optionalIntOrString("Max lines"),
466
456
  symbol: Type.Optional(Type.String({ description: "Symbol name to read" })),
467
457
  map: Type.Optional(Type.Boolean({ description: "Append structural map" })),
468
458
  bundle: Type.Optional(
@@ -108,14 +108,14 @@ export interface ReadSeekCheckOutput {
108
108
  diagnostics: ReadSeekDiagnostic[];
109
109
  }
110
110
 
111
- export interface ReadSeekOcrLine {
111
+ export interface ReadSeekTranscriptRegion {
112
112
  text: string;
113
- bbox: [number, number, number, number];
113
+ quad: [number, number, number, number, number, number, number, number];
114
114
  }
115
115
 
116
- export interface ReadSeekOcrText {
116
+ export interface ReadSeekTranscript {
117
117
  text: string;
118
- lines: ReadSeekOcrLine[];
118
+ regions: ReadSeekTranscriptRegion[];
119
119
  }
120
120
 
121
121
  export type ReadSeekDetection =
@@ -136,7 +136,7 @@ export type ReadSeekDetection =
136
136
  width: number;
137
137
  height: number;
138
138
  animated: boolean;
139
- ocr?: ReadSeekOcrText;
139
+ transcribe?: ReadSeekTranscript;
140
140
  }
141
141
  | {
142
142
  type: "binary";
@@ -641,21 +641,21 @@ export async function readseekCheck(
641
641
  );
642
642
  }
643
643
 
644
- function parseOcrText(value: unknown): ReadSeekOcrText | undefined {
644
+ function parseTranscript(value: unknown): ReadSeekTranscript | undefined {
645
645
  if (value === undefined || value === null) return undefined;
646
- if (typeof value !== "object") throw new Error("invalid readseek detect ocr");
647
- const ocr = value as Record<string, unknown>;
648
- if (!Array.isArray(ocr.lines)) throw new Error("invalid readseek detect ocr.lines");
646
+ if (typeof value !== "object") throw new Error("invalid readseek detect transcribe");
647
+ const transcribe = value as Record<string, unknown>;
648
+ if (!Array.isArray(transcribe.regions)) throw new Error("invalid readseek detect transcribe.regions");
649
649
  return {
650
- text: requireString(ocr.text, "ocr.text"),
651
- lines: ocr.lines.map((line) => {
652
- if (!line || typeof line !== "object") throw new Error("invalid readseek detect ocr line");
653
- const item = line as Record<string, unknown>;
654
- const bbox = item.bbox;
655
- if (!Array.isArray(bbox) || bbox.length !== 4) throw new Error("invalid readseek detect ocr bbox");
650
+ text: requireString(transcribe.text, "transcribe.text"),
651
+ regions: transcribe.regions.map((region) => {
652
+ if (!region || typeof region !== "object") throw new Error("invalid readseek detect transcribe region");
653
+ const item = region as Record<string, unknown>;
654
+ const quad = item.quad;
655
+ if (!Array.isArray(quad) || quad.length !== 8) throw new Error("invalid readseek detect transcribe quad");
656
656
  return {
657
- text: requireString(item.text, "ocr.line.text"),
658
- bbox: bbox.map((n, i) => requireNumber(n, `ocr.line.bbox[${i}]`)) as [number, number, number, number],
657
+ text: requireString(item.text, "transcribe.region.text"),
658
+ quad: quad.map((n, i) => requireNumber(n, `transcribe.region.quad[${i}]`)) as ReadSeekTranscriptRegion["quad"],
659
659
  };
660
660
  }),
661
661
  };
@@ -687,7 +687,7 @@ function parseDetectOutput(value: unknown): ReadSeekDetection {
687
687
  width: requireNumber(output.width, "width"),
688
688
  height: requireNumber(output.height, "height"),
689
689
  animated: requireBoolean(output.animated, "animated"),
690
- ocr: parseOcrText(output.ocr),
690
+ transcribe: parseTranscript(output.transcribe),
691
691
  };
692
692
  case "binary":
693
693
  case "text":
@@ -699,10 +699,10 @@ function parseDetectOutput(value: unknown): ReadSeekDetection {
699
699
 
700
700
  export async function readseekDetect(
701
701
  filePath: string,
702
- options: { ocr?: boolean; signal?: AbortSignal } = {},
702
+ options: { transcribe?: boolean; signal?: AbortSignal } = {},
703
703
  ): Promise<ReadSeekDetection> {
704
704
  const args = ["detect"];
705
- if (options.ocr) args.push("--ocr");
705
+ if (options.transcribe) args.push("--transcribe");
706
706
  args.push(filePath);
707
707
  return parseDetectOutput(await runReadSeek(args, { signal: options.signal }));
708
708
  }
@@ -0,0 +1,39 @@
1
+ import { Type } from "@sinclair/typebox";
2
+
3
+ import { buildToolErrorResult, type ToolErrorResult } from "./readseek-value.js";
4
+
5
+ /** Optional search path shared by the readseek search tools. */
6
+ export function searchPathParam() {
7
+ return Type.Optional(Type.String({ description: "Search path" }));
8
+ }
9
+
10
+ /** Optional language hint shared by the readseek search tools. */
11
+ export function langParam() {
12
+ return Type.Optional(Type.String({ description: "Language hint" }));
13
+ }
14
+
15
+ /**
16
+ * TypeBox fragment for the Git-scope parameters shared by every readseek
17
+ * search tool. Returns fresh schema instances so each tool spreads its own.
18
+ */
19
+ export function readseekGitSearchParams() {
20
+ return {
21
+ cached: Type.Optional(Type.Boolean({ description: "In a Git repository, search tracked/indexed files" })),
22
+ others: Type.Optional(Type.Boolean({ description: "In a Git repository, search untracked files" })),
23
+ ignored: Type.Optional(Type.Boolean({ description: "With others=true, include ignored untracked files" })),
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Enforce that `ignored` is only meaningful alongside `others`. Returns an
29
+ * error envelope tagged with {@link tool} when the rule is violated, else null.
30
+ */
31
+ export function validateIgnoredRequiresOthers(
32
+ tool: string,
33
+ params: { others?: boolean; ignored?: boolean },
34
+ ): ToolErrorResult | null {
35
+ if (params.ignored && !params.others) {
36
+ return buildToolErrorResult(tool, "invalid-parameter", `Error: ${tool} parameter 'ignored' requires 'others'`);
37
+ }
38
+ return null;
39
+ }
package/src/refs.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import type { ExtensionAPI, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
- import { Text } from "@earendil-works/pi-tui";
3
2
  import { Type } from "@sinclair/typebox";
4
3
  import path from "node:path";
5
4
  import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
@@ -9,9 +8,10 @@ import { resolveToCwd } from "./path-utils.js";
9
8
  import { classifyReadSeekFailure, readseekRefs, type ReadSeekReference } from "./readseek-client.js";
10
9
  import { buildRefsOutput, type RefsOutputFile, type RefsOutputLine } from "./refs-output.js";
11
10
  import type { FileAnchoredCallback } from "./tool-types.js";
11
+ import { langParam, readseekGitSearchParams, searchPathParam, validateIgnoredRequiresOthers } from "./readseek-params.js";
12
12
  import { registerReadSeekTool } from "./register-tool.js";
13
13
 
14
- import { clampLineToWidth, renderAnchoredFilesResult, renderToolLabel } from "./tui-render-utils.js";
14
+ import { renderAnchoredFilesResult, renderReadSeekSearchCall } from "./tui-render-utils.js";
15
15
 
16
16
  type RefsParams = {
17
17
  name: string;
@@ -83,9 +83,8 @@ function isReadSeekCursorValidationFailure(message: string): boolean {
83
83
  export async function executeRefs(opts: ExecuteRefsOptions): Promise<any> {
84
84
  const { params, signal, cwd, onFileAnchored } = opts;
85
85
  const p = params as RefsParams;
86
- if (p.ignored && !p.others) {
87
- return buildToolErrorResult("refs", "invalid-parameter", "Error: refs parameter 'ignored' requires 'others'");
88
- }
86
+ const ignoredError = validateIgnoredRequiresOthers("refs", p);
87
+ if (ignoredError) return ignoredError;
89
88
  if (p.scope && p.line === undefined) {
90
89
  return buildToolErrorResult("refs", "invalid-parameter", "Error: refs parameter 'scope' requires 'line'");
91
90
  }
@@ -136,26 +135,22 @@ export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}
136
135
  promptGuidelines: REFS_PROMPT_METADATA.promptGuidelines,
137
136
  parameters: Type.Object({
138
137
  name: Type.String({ description: "Identifier to find references for" }),
139
- path: Type.Optional(Type.String({ description: "Search path" })),
140
- lang: Type.Optional(Type.String({ description: "Language hint" })),
138
+ path: searchPathParam(),
139
+ lang: langParam(),
141
140
  scope: Type.Optional(Type.Boolean({ description: "Restrict to the binding under line/column (single file)" })),
142
141
  line: Type.Optional(Type.Number({ description: "One-based cursor line, used with scope" })),
143
142
  column: Type.Optional(Type.Number({ description: "One-based cursor byte column, used with scope" })),
144
- cached: Type.Optional(Type.Boolean({ description: "In a Git repository, search tracked/indexed files" })),
145
- others: Type.Optional(Type.Boolean({ description: "In a Git repository, search untracked files" })),
146
- ignored: Type.Optional(Type.Boolean({ description: "With others=true, include ignored untracked files" })),
143
+ ...readseekGitSearchParams(),
147
144
  }),
148
145
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
149
146
  return executeRefs({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
150
147
  },
151
148
  renderCall(args: any, theme: any, ...rest: any[]) {
152
- const context = rest[0] ?? {};
153
- let text = `${renderToolLabel(theme, "refs")} ${theme.fg("accent", args.name)}`;
154
- text += theme.fg("dim", ` in ${args.path ?? "."}`);
155
- if (args.lang) text += theme.fg("dim", ` (${args.lang})`);
156
- const flags = [args.scope && "scope", args.cached && "cached", args.others && "others", args.ignored && "ignored"].filter(Boolean);
157
- if (flags.length > 0) text += theme.fg("dim", ` [${flags.join(",")}]`);
158
- return new Text(clampLineToWidth(text, context.width), 0, 0);
149
+ return renderReadSeekSearchCall(args, theme, rest, {
150
+ label: "refs",
151
+ accent: args.name,
152
+ flags: [args.scope && "scope", args.cached && "cached", args.others && "others", args.ignored && "ignored"],
153
+ });
159
154
  },
160
155
  renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
161
156
  return renderAnchoredFilesResult(result, options, theme, rest, {
@@ -1,4 +1,14 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+
4
+ /**
5
+ * Optional tool parameter accepting an integer or its string form, since models
6
+ * frequently pass integers as quoted strings. Both union halves share
7
+ * {@link description}.
8
+ */
9
+ export function optionalIntOrString(description: string) {
10
+ return Type.Optional(Type.Union([Type.Number({ description }), Type.String({ description })]));
11
+ }
2
12
 
3
13
  export type ReadSeekToolPolicy = "read-only" | "mutating";
4
14
 
package/src/sg.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import path from "node:path";
2
2
 
3
3
  import type { ExtensionAPI, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
4
- import { Text } from "@earendil-works/pi-tui";
5
4
  import { Type } from "@sinclair/typebox";
6
5
 
7
6
  import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
@@ -11,9 +10,10 @@ import { statSearchPathOrError } from "./stat-search-path.js";
11
10
  import { classifyReadSeekFailure, isReadSeekAvailable, readseekSearch, type ReadSeekHashline, type ReadSeekSearchFileOutput } from "./readseek-client.js";
12
11
  import { buildSgOutput } from "./sg-output.js";
13
12
  import type { FileAnchoredCallback } from "./tool-types.js";
13
+ import { langParam, readseekGitSearchParams, searchPathParam, validateIgnoredRequiresOthers } from "./readseek-params.js";
14
14
  import { registerReadSeekTool } from "./register-tool.js";
15
15
 
16
- import { clampLineToWidth, renderAnchoredFilesResult, renderToolLabel } from "./tui-render-utils.js";
16
+ import { renderAnchoredFilesResult, renderReadSeekSearchCall } from "./tui-render-utils.js";
17
17
 
18
18
  type SgParams = { pattern: string; lang?: string; path?: string; cached?: boolean; others?: boolean; ignored?: boolean };
19
19
 
@@ -107,10 +107,8 @@ function readseekLanguageForPath(language: string | undefined, searchPath: strin
107
107
  export async function executeSg(opts: ExecuteSgOptions): Promise<any> {
108
108
  const { params, signal, cwd, onFileAnchored } = opts;
109
109
  const p = params as SgParams;
110
- if (p.ignored && !p.others) {
111
- const message = "Error: search parameter 'ignored' requires 'others'";
112
- return buildToolErrorResult("search", "invalid-parameter", message);
113
- }
110
+ const ignoredError = validateIgnoredRequiresOthers("search", p);
111
+ if (ignoredError) return ignoredError;
114
112
  const searchPath = resolveToCwd(p.path ?? ".", cwd);
115
113
 
116
114
  const statResult = await statSearchPathOrError("search", p.path, searchPath);
@@ -201,23 +199,19 @@ export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
201
199
  promptGuidelines: SG_PROMPT_METADATA.promptGuidelines,
202
200
  parameters: Type.Object({
203
201
  pattern: Type.String({ description: "AST pattern" }),
204
- lang: Type.Optional(Type.String({ description: "Language hint" })),
205
- path: Type.Optional(Type.String({ description: "Search path" })),
206
- cached: Type.Optional(Type.Boolean({ description: "In a Git repository, search tracked/indexed files" })),
207
- others: Type.Optional(Type.Boolean({ description: "In a Git repository, search untracked files" })),
208
- ignored: Type.Optional(Type.Boolean({ description: "With others=true, include ignored untracked files" })),
202
+ lang: langParam(),
203
+ path: searchPathParam(),
204
+ ...readseekGitSearchParams(),
209
205
  }),
210
206
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
211
207
  return executeSg({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
212
208
  },
213
209
  renderCall(args: any, theme: any, ...rest: any[]) {
214
- const context = rest[0] ?? {};
215
- let text = `${renderToolLabel(theme, "search")} ${theme.fg("accent", `/${args.pattern}/`)}`;
216
- text += theme.fg("dim", ` in ${args.path ?? "."}`);
217
- if (args.lang) text += theme.fg("dim", ` (${args.lang})`);
218
- const flags = [args.cached && "cached", args.others && "others", args.ignored && "ignored"].filter(Boolean);
219
- if (flags.length > 0) text += theme.fg("dim", ` [${flags.join(",")}]`);
220
- return new Text(clampLineToWidth(text, context.width), 0, 0);
210
+ return renderReadSeekSearchCall(args, theme, rest, {
211
+ label: "search",
212
+ accent: `/${args.pattern}/`,
213
+ flags: [args.cached && "cached", args.others && "others", args.ignored && "ignored"],
214
+ });
221
215
  },
222
216
  renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
223
217
  return renderAnchoredFilesResult(result, options, theme, rest, {
@@ -1,5 +1,5 @@
1
1
  import type { Component } from "@earendil-works/pi-tui";
2
- import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import { Text, visibleWidth } from "@earendil-works/pi-tui";
3
3
  import type { DiffData } from "./diff-data.js";
4
4
  import { renderTuiDiff } from "./tui-diff-renderer.js";
5
5
  import { clampLineToWidth, normalizeWidth, type RendererTheme } from "./tui-render-utils.js";
@@ -84,3 +84,25 @@ export class DiffPreviewComponent implements Component {
84
84
  return clamped;
85
85
  }
86
86
  }
87
+
88
+ /**
89
+ * Reuse the prior render tick's {@link DiffPreviewComponent} when present, else
90
+ * construct one, then apply {@link options}. Keeps a streaming diff preview
91
+ * stable across ticks instead of remounting a fresh component each frame.
92
+ */
93
+ export function upsertDiffComponent(lastComponent: unknown, options: DiffPreviewComponentOptions): DiffPreviewComponent {
94
+ const component = lastComponent instanceof DiffPreviewComponent ? lastComponent : new DiffPreviewComponent(options);
95
+ component.update(options);
96
+ return component;
97
+ }
98
+
99
+ /**
100
+ * Reuse the prior render tick's text component when it is not a
101
+ * {@link DiffPreviewComponent}, else construct a fresh {@link Text}, then set
102
+ * its content to {@link text}.
103
+ */
104
+ export function upsertTextComponent(lastComponent: any, text: string): Text {
105
+ const component = lastComponent && !(lastComponent instanceof DiffPreviewComponent) ? lastComponent : new Text("", 0, 0);
106
+ component.setText(text);
107
+ return component;
108
+ }
@@ -177,6 +177,26 @@ export interface AnchoredFilesLabels {
177
177
  unitPlural: string;
178
178
  }
179
179
 
180
+ /**
181
+ * Render the call line shared by the readseek search tools (search, refs): a
182
+ * tool label, an accent term, the search path, an optional language hint, and
183
+ * the active boolean flags. Falsy {@link opts.flags} entries are dropped.
184
+ */
185
+ export function renderReadSeekSearchCall(
186
+ args: { path?: string; lang?: string },
187
+ theme: RendererTheme,
188
+ rest: any[],
189
+ opts: { label: string; accent: string; flags: Array<string | false | undefined> },
190
+ ): Text {
191
+ const context = rest[0] ?? {};
192
+ let text = `${renderToolLabel(theme, opts.label)} ${theme.fg("accent", opts.accent)}`;
193
+ text += theme.fg("dim", ` in ${args.path ?? "."}`);
194
+ if (args.lang) text += theme.fg("dim", ` (${args.lang})`);
195
+ const flags = opts.flags.filter(Boolean);
196
+ if (flags.length > 0) text += theme.fg("dim", ` [${flags.join(",")}]`);
197
+ return new Text(clampLineToWidth(text, context.width), 0, 0);
198
+ }
199
+
180
200
  /**
181
201
  * Render the result summary shared by the anchored-files search tools (search,
182
202
  * refs): a pending line, an error first-line/expanded body, an empty-result
package/src/write.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, relative } from "node:path";
3
3
 
4
- import { withFileMutationQueue, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { withFileMutationQueue, type ExtensionAPI, type ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
5
5
  import { Text } from "@earendil-works/pi-tui";
6
6
  import { Type } from "@sinclair/typebox";
7
7
 
@@ -16,8 +16,8 @@ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
16
16
  import { buildPendingWritePreviewData, buildWritePreviewKey, resolvePendingDiffPreview, type PendingDiffPreviewResult } from "./pending-diff-preview.js";
17
17
  import { generateCompactOrFullDiff, normalizeToLF, hasBareCarriageReturn } from "./edit-diff.js";
18
18
  import { buildDiffData, type DiffData } from "./diff-data.js";
19
- import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderErrorResult, renderToolLabel, summaryLine } from "./tui-render-utils.js";
20
- import { DiffPreviewComponent } from "./tui-diff-component.js";
19
+ import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderErrorResult, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
20
+ import { upsertDiffComponent, upsertTextComponent } from "./tui-diff-component.js";
21
21
  import type { FileAnchoredCallback } from "./tool-types.js";
22
22
  import { registerReadSeekTool } from "./register-tool.js";
23
23
 
@@ -399,32 +399,20 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
399
399
  // its preview alongside the final result is just duplicate noise — the
400
400
  // pre-execution state can no longer change in any meaningful way.
401
401
  if (context.executionStarted) {
402
- const textComponent = (context.lastComponent && !(context.lastComponent instanceof DiffPreviewComponent))
403
- ? context.lastComponent
404
- : new Text("", 0, 0);
405
- textComponent.setText(text);
406
- return textComponent;
402
+ return upsertTextComponent(context.lastComponent, text);
407
403
  }
408
404
  const previewKey = buildWritePreviewKey(args ?? {});
409
405
  const preview = resolvePendingDiffPreview(context, WRITE_PENDING_PREVIEW_STATE_KEY, previewKey, () => buildPendingWritePreviewData(args ?? {}, context.cwd ?? process.cwd()));
410
406
  const expanded = !!context.expanded;
411
407
  const parts = pendingWritePreviewParts(text, preview, expanded, theme);
412
408
  if (parts.diffData) {
413
- const diffComponent = context.lastComponent instanceof DiffPreviewComponent
414
- ? context.lastComponent
415
- : new DiffPreviewComponent({ prefixLines: parts.lines, diffData: parts.diffData, theme, expanded: true });
416
- diffComponent.update({ prefixLines: parts.lines, diffData: parts.diffData, theme, expanded: true });
417
- return diffComponent;
409
+ return upsertDiffComponent(context.lastComponent, { prefixLines: parts.lines, diffData: parts.diffData, theme, expanded: true });
418
410
  }
419
- const textComponent = (context.lastComponent && !(context.lastComponent instanceof DiffPreviewComponent))
420
- ? context.lastComponent
421
- : new Text("", 0, 0);
422
- textComponent.setText(clampLinesToWidth(parts.lines, context.width).join("\n"));
423
- return textComponent;
411
+ return upsertTextComponent(context.lastComponent, clampLinesToWidth(parts.lines, context.width).join("\n"));
424
412
  },
425
- renderResult(result: any, options: any, theme: any, context: any = {}) {
426
- const expanded = isRendererExpanded(options, context);
427
- const width = context.width ?? options?.width;
413
+ renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
414
+ const { isPartial, expanded, width, context } = resolveRenderResultContext(options, rest);
415
+ if (isPartial) return renderPendingResult("pending write", width);
428
416
  const details = result.details ?? {};
429
417
  const output = result.content?.[0]?.type === "text" ? result.content[0].text : "";
430
418
  if (result.isError || details.readseekValue?.ok === false) {
@@ -450,11 +438,7 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
450
438
  const hasExpandableDiff = !!diffData;
451
439
  let text = summaryLine(state, { hidden: hasExpandableDiff && !expanded });
452
440
  if (expanded && hasExpandableDiff) {
453
- const diffComponent = context.lastComponent instanceof DiffPreviewComponent
454
- ? context.lastComponent
455
- : new DiffPreviewComponent({ prefixLines: text.split("\n"), diffData, theme, expanded: true });
456
- diffComponent.update({ prefixLines: text.split("\n"), diffData, theme, expanded: true });
457
- return diffComponent;
441
+ return upsertDiffComponent(context.lastComponent, { prefixLines: text.split("\n"), diffData, theme, expanded: true });
458
442
  }
459
443
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
460
444
  },