pi-readseek 0.4.14 → 0.4.16

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.14",
3
+ "version": "0.4.16",
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.19",
42
+ "@jarkkojs/readseek": "^0.4.20",
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") {
@@ -169,7 +169,7 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
169
169
  } catch {
170
170
  // detect unavailable — fall through to binary-as-text handling below
171
171
  }
172
- if (detection?.image) {
172
+ if (detection?.type === "image") {
173
173
  const builtinRead = createReadTool(cwd);
174
174
  const builtinResult = await builtinRead.execute(toolCallId, p, signal, onUpdate);
175
175
  const ocrText = detection.ocr?.text?.trim();
@@ -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,13 +108,6 @@ export interface ReadSeekCheckOutput {
108
108
  diagnostics: ReadSeekDiagnostic[];
109
109
  }
110
110
 
111
- export interface ReadSeekImageInfo {
112
- format: string;
113
- width: number;
114
- height: number;
115
- animated: boolean;
116
- }
117
-
118
111
  export interface ReadSeekOcrLine {
119
112
  text: string;
120
113
  bbox: [number, number, number, number];
@@ -125,17 +118,36 @@ export interface ReadSeekOcrText {
125
118
  lines: ReadSeekOcrLine[];
126
119
  }
127
120
 
128
- export interface ReadSeekDetection {
129
- file: string;
130
- language: string;
131
- engine: string | null;
132
- supported: boolean;
133
- binary: boolean;
134
- mime: string | null;
135
- syntax: string | null;
136
- image: ReadSeekImageInfo | null;
137
- ocr?: ReadSeekOcrText;
138
- }
121
+ export type ReadSeekDetection =
122
+ | {
123
+ type: "source";
124
+ file: string;
125
+ language: string;
126
+ engine?: string;
127
+ supported: boolean;
128
+ mime?: string;
129
+ syntax?: string;
130
+ }
131
+ | {
132
+ type: "image";
133
+ file: string;
134
+ mime?: string;
135
+ format: string;
136
+ width: number;
137
+ height: number;
138
+ animated: boolean;
139
+ ocr?: ReadSeekOcrText;
140
+ }
141
+ | {
142
+ type: "binary";
143
+ file: string;
144
+ mime?: string;
145
+ }
146
+ | {
147
+ type: "text";
148
+ file: string;
149
+ mime?: string;
150
+ };
139
151
 
140
152
  interface ReadSeekSearchOptions {
141
153
  language?: string;
@@ -554,11 +566,6 @@ function optionalString(value: unknown, field: string): string | undefined {
554
566
  return requireString(value, field);
555
567
  }
556
568
 
557
- function nullableString(value: unknown, field: string): string | null {
558
- if (value === undefined || value === null) return null;
559
- return requireString(value, field);
560
- }
561
-
562
569
  function parseRefsOutput(value: unknown): ReadSeekRefsOutput {
563
570
  if (!value || typeof value !== "object") throw new Error("invalid readseek refs output");
564
571
  const output = value as Record<string, unknown>;
@@ -634,18 +641,6 @@ export async function readseekCheck(
634
641
  );
635
642
  }
636
643
 
637
- function parseImageInfo(value: unknown): ReadSeekImageInfo | null {
638
- if (value === undefined || value === null) return null;
639
- if (typeof value !== "object") throw new Error("invalid readseek detect image");
640
- const image = value as Record<string, unknown>;
641
- return {
642
- format: requireString(image.format, "image.format"),
643
- width: requireNumber(image.width, "image.width"),
644
- height: requireNumber(image.height, "image.height"),
645
- animated: requireBoolean(image.animated, "image.animated"),
646
- };
647
- }
648
-
649
644
  function parseOcrText(value: unknown): ReadSeekOcrText | undefined {
650
645
  if (value === undefined || value === null) return undefined;
651
646
  if (typeof value !== "object") throw new Error("invalid readseek detect ocr");
@@ -669,17 +664,37 @@ function parseOcrText(value: unknown): ReadSeekOcrText | undefined {
669
664
  function parseDetectOutput(value: unknown): ReadSeekDetection {
670
665
  if (!value || typeof value !== "object") throw new Error("invalid readseek detect output");
671
666
  const output = value as Record<string, unknown>;
672
- return {
673
- file: requireString(output.file, "file"),
674
- language: requireString(output.language, "language"),
675
- engine: nullableString(output.engine, "engine"),
676
- supported: requireBoolean(output.supported, "supported"),
677
- binary: requireBoolean(output.binary, "binary"),
678
- mime: nullableString(output.mime, "mime"),
679
- syntax: nullableString(output.syntax, "syntax"),
680
- image: parseImageInfo(output.image),
681
- ocr: parseOcrText(output.ocr),
682
- };
667
+ const type = requireString(output.type, "type");
668
+ const file = requireString(output.file, "file");
669
+ const mime = optionalString(output.mime, "mime");
670
+ switch (type) {
671
+ case "source":
672
+ return {
673
+ type,
674
+ file,
675
+ language: requireString(output.language, "language"),
676
+ engine: optionalString(output.engine, "engine"),
677
+ supported: requireBoolean(output.supported, "supported"),
678
+ mime,
679
+ syntax: optionalString(output.syntax, "syntax"),
680
+ };
681
+ case "image":
682
+ return {
683
+ type,
684
+ file,
685
+ mime,
686
+ format: requireString(output.format, "format"),
687
+ width: requireNumber(output.width, "width"),
688
+ height: requireNumber(output.height, "height"),
689
+ animated: requireBoolean(output.animated, "animated"),
690
+ ocr: parseOcrText(output.ocr),
691
+ };
692
+ case "binary":
693
+ case "text":
694
+ return { type, file, mime };
695
+ default:
696
+ throw new Error(`invalid readseek detect type: ${type}`);
697
+ }
683
698
  }
684
699
 
685
700
  export async function readseekDetect(
@@ -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
  },