pi-readseek 0.4.24 → 0.4.26

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/index.ts CHANGED
@@ -4,7 +4,10 @@ import { registerEditTool } from "./src/edit.js";
4
4
  import { registerGrepTool } from "./src/grep.js";
5
5
  import { registerSgTool } from "./src/sg.js";
6
6
  import { registerRefsTool } from "./src/refs.js";
7
+ import { registerRenameTool } from "./src/rename.js";
8
+ import { registerHoverTool } from "./src/hover.js";
7
9
  import { registerWriteTool } from "./src/write.js";
10
+ import { registerDefTool } from "./src/def.js";
8
11
  import { SessionAnchors } from "./src/session-anchors.js";
9
12
  import { isReadSeekAvailable } from "./src/readseek-client.js";
10
13
 
@@ -23,5 +26,8 @@ export default function piReadSeekExtension(pi: ExtensionAPI): void {
23
26
  registerGrepTool(pi, { searchGuideline, onFileAnchored: markAnchored });
24
27
  registerSgTool(pi, { onFileAnchored: markAnchored });
25
28
  registerRefsTool(pi, { onFileAnchored: markAnchored });
29
+ registerRenameTool(pi);
30
+ registerHoverTool(pi);
31
+ registerDefTool(pi, { onFileAnchored: markAnchored });
26
32
  registerWriteTool(pi, { onFileAnchored: markAnchored });
27
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-readseek",
3
- "version": "0.4.24",
3
+ "version": "0.4.26",
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,7 +39,7 @@
39
39
  "node": ">=20.0.0"
40
40
  },
41
41
  "dependencies": {
42
- "@jarkkojs/readseek": "^0.4.31",
42
+ "@jarkkojs/readseek": "^0.4.32",
43
43
  "diff": "^8.0.3",
44
44
  "xxhash-wasm": "^1.1.0"
45
45
  },
package/prompts/def.md ADDED
@@ -0,0 +1,24 @@
1
+ ---
2
+ tools: def
3
+ ---
4
+
5
+ Find structural symbol definitions. Calls `readseek def` which searches
6
+ for the definition of a named symbol across a file or directory.
7
+
8
+ ## Parameters
9
+
10
+ - `path` (optional): File or directory to search (default: ".").
11
+ - `name` (optional): Qualified or unqualified symbol name. Required unless
12
+ `fromIdentify` is true.
13
+ - `lang` (optional): Language override.
14
+ - `fromIdentify` (optional): When true, reads identify output from a
15
+ previous identify call to extract the symbol name automatically.
16
+ - `cached` (optional): Search tracked/indexed files in a Git repository.
17
+ - `others` (optional): Search untracked files.
18
+ - `ignored` (optional): Include ignored untracked files.
19
+
20
+ ## When to use
21
+
22
+ - After a hover call, to jump to where a symbol is defined.
23
+ - When the user asks "where is X defined?".
24
+ - To find a function/class/type definition by its qualified name.
@@ -0,0 +1,19 @@
1
+ ---
2
+ tools: hover
3
+ ---
4
+
5
+ Use hover to identify the identifier and enclosing symbol at a cursor
6
+ position. Calls `readseek identify` with the file content sent via stdin
7
+ so unsaved editor content is included.
8
+
9
+ ## Parameters
10
+
11
+ - `path` (required): File path.
12
+ - `line` (required): One-based cursor line.
13
+ - `column` (optional): One-based cursor byte column.
14
+
15
+ ## When to use
16
+
17
+ - Before a rename, to confirm the identifier under the cursor.
18
+ - Before a go-to-definition, to get the qualified symbol name.
19
+ - To inspect what symbol a specific line belongs to.
@@ -0,0 +1,31 @@
1
+ ---
2
+ tools: rename
3
+ ---
4
+
5
+ Use rename to rename an identifier (variable, function, type, etc.) with
6
+ binding accuracy. The tool calls `readseek rename` which resolves the
7
+ lexical binding under the cursor and renames every occurrence that binds
8
+ to the same declaration.
9
+
10
+ ## Parameters
11
+
12
+ - `path` (required): File holding the binding to rename (a single regular file).
13
+ - `line` (required): One-based cursor line of the binding to rename.
14
+ - `column` (optional): One-based cursor byte column of the binding.
15
+ - `to` (required): New name for the binding. Must be a plain identifier.
16
+ - `workspace` (optional): When true, expands the rename across the project
17
+ root. The cursor file remains binding-accurate; other files are matched
18
+ by name (free uses only, local shadows excluded).
19
+ - `apply` (optional, default true): When true, writes the edits to disk
20
+ after verifying line hashes. When false, returns the plan only.
21
+
22
+ ## When to use
23
+
24
+ - The user asks to rename a symbol (function, variable, class, etc.).
25
+ - The cursor position (line, optionally column) is known from an identify
26
+ call or from the user's editor context.
27
+
28
+ ## When not to use
29
+
30
+ - For search-and-replace across files, use grep or sg.
31
+ - For refactoring that requires adding/removing parameters, use edit.
package/src/def.ts ADDED
@@ -0,0 +1,148 @@
1
+ import type { ExtensionAPI, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import path from "node:path";
4
+
5
+ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
6
+ import { buildReadSeekLineWithHash, buildToolErrorResult } from "./readseek-value.js";
7
+ import { resolveToCwd } from "./path-utils.js";
8
+ import { statSearchPathOrError } from "./stat-search-path.js";
9
+ import { classifyReadSeekFailure, readseekDef } from "./readseek-client.js";
10
+ import { searchPathParam, langParam, readseekGitSearchParams } from "./readseek-params.js";
11
+ import { registerReadSeekTool } from "./register-tool.js";
12
+
13
+ import { renderAnchoredFilesResult, renderReadSeekSearchCall } from "./tui-render-utils.js";
14
+ import type { FileAnchoredCallback } from "./tool-types.js";
15
+
16
+ const DEF_PROMPT_METADATA = defineToolPromptMetadata({
17
+ promptUrl: new URL("../prompts/def.md", import.meta.url),
18
+ promptSnippet: "Find structural symbol definitions with readseek",
19
+ });
20
+
21
+ type DefParams = {
22
+ name?: string;
23
+ path?: string;
24
+ lang?: string;
25
+ fromIdentify?: boolean;
26
+ cached?: boolean;
27
+ others?: boolean;
28
+ ignored?: boolean;
29
+ };
30
+
31
+ interface DefToolOptions {
32
+ onFileAnchored?: FileAnchoredCallback;
33
+ }
34
+
35
+ export interface ExecuteDefOptions {
36
+ params: unknown;
37
+ signal: AbortSignal | undefined;
38
+ cwd: string;
39
+ onFileAnchored?: FileAnchoredCallback;
40
+ }
41
+
42
+ export async function executeDef(opts: ExecuteDefOptions): Promise<any> {
43
+ const { params, signal, cwd, onFileAnchored } = opts;
44
+ const p = params as DefParams;
45
+
46
+ if (!p.fromIdentify && (!p.name || !p.name.trim())) {
47
+ return buildToolErrorResult("def", "invalid-parameter", "def requires 'name' or 'fromIdentify'");
48
+ }
49
+
50
+ const searchPath = resolveToCwd(p.path ?? ".", cwd);
51
+
52
+ const statResult = await statSearchPathOrError("def", p.path, searchPath);
53
+ if (!statResult.ok) return statResult.error;
54
+
55
+ try {
56
+ const definitions = await readseekDef(searchPath, {
57
+ name: p.name,
58
+ fromIdentify: p.fromIdentify,
59
+ language: p.lang,
60
+ cached: p.cached,
61
+ others: p.others,
62
+ ignored: p.ignored,
63
+ signal,
64
+ });
65
+
66
+ if (definitions.length === 0) {
67
+ return {
68
+ content: [{ type: "text", text: "no definitions found" }],
69
+ details: {
70
+ readseekValue: { tool: "def", ok: true, path: searchPath, definitions: [] },
71
+ },
72
+ };
73
+ }
74
+
75
+ const files = new Map<string, { displayPath: string; path: string; lines: ReturnType<typeof buildReadSeekLineWithHash>[] }>();
76
+ for (const def of definitions) {
77
+ const abs = path.isAbsolute(def.file) ? def.file : path.resolve(cwd, def.file);
78
+ let file = files.get(abs);
79
+ if (!file) {
80
+ file = { displayPath: path.relative(cwd, abs) || abs, path: abs, lines: [] };
81
+ files.set(abs, file);
82
+ }
83
+ file.lines.push(buildReadSeekLineWithHash(def.line, def.line_hash, def.text));
84
+ }
85
+
86
+ const fileList = [...files.values()];
87
+ for (const file of fileList) {
88
+ onFileAnchored?.(file.path);
89
+ }
90
+
91
+ const textParts: string[] = [];
92
+ for (const file of fileList) {
93
+ textParts.push(file.displayPath);
94
+ for (const line of file.lines) {
95
+ textParts.push(` ${line.line}:${line.hash} ${line.display}`);
96
+ }
97
+ }
98
+
99
+ return {
100
+ content: [{ type: "text", text: textParts.join("\n") }],
101
+ details: {
102
+ readseekValue: { tool: "def", ok: true, path: searchPath, definitions },
103
+ },
104
+ };
105
+ } catch (err: any) {
106
+ const failure = classifyReadSeekFailure(err);
107
+ return buildToolErrorResult("def", failure.code, failure.message, failure.hint ? { hint: failure.hint } : {});
108
+ }
109
+ }
110
+
111
+ export function registerDefTool(pi: ExtensionAPI, options: DefToolOptions = {}) {
112
+ registerReadSeekTool(pi, {
113
+ policy: "read-only",
114
+ pythonName: "def",
115
+ defaultExposure: "opt-in",
116
+ }, {
117
+ name: "def",
118
+ label: "Definition",
119
+ description: DEF_PROMPT_METADATA.description,
120
+ promptSnippet: DEF_PROMPT_METADATA.promptSnippet,
121
+ promptGuidelines: DEF_PROMPT_METADATA.promptGuidelines,
122
+ parameters: Type.Object({
123
+ name: Type.Optional(Type.String({ description: "Qualified or unqualified symbol name" })),
124
+ path: searchPathParam(),
125
+ lang: langParam(),
126
+ fromIdentify: Type.Optional(Type.Boolean({ description: "Read identify output from stdin to choose the symbol name" })),
127
+ ...readseekGitSearchParams(),
128
+ }),
129
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
130
+ return executeDef({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
131
+ },
132
+ renderCall(args: any, theme: any, ...rest: any[]) {
133
+ return renderReadSeekSearchCall(args, theme, rest, {
134
+ label: "def",
135
+ accent: args.name,
136
+ flags: [args.fromIdentify && "from-identify", args.cached && "cached", args.others && "others", args.ignored && "ignored"],
137
+ });
138
+ },
139
+ renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
140
+ return renderAnchoredFilesResult(result, options, theme, rest, {
141
+ pendingLabel: "pending def",
142
+ emptyLabel: "no definitions",
143
+ unitSingular: "definition",
144
+ unitPlural: "definitions",
145
+ });
146
+ },
147
+ });
148
+ }
package/src/hover.ts ADDED
@@ -0,0 +1,131 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import type { ExtensionAPI, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
4
+ import { Type } from "@sinclair/typebox";
5
+ import { Text } from "@earendil-works/pi-tui";
6
+
7
+ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
8
+ import { buildToolErrorResult } from "./readseek-value.js";
9
+ import { resolveToCwd } from "./path-utils.js";
10
+ import { classifyReadSeekFailure, readseekIdentify } from "./readseek-client.js";
11
+ import { filePathParam, registerReadSeekTool } from "./register-tool.js";
12
+
13
+ import { clampLinesToWidth, linkToolPath, renderPendingResult, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
14
+
15
+ const HOVER_PROMPT_METADATA = defineToolPromptMetadata({
16
+ promptUrl: new URL("../prompts/hover.md", import.meta.url),
17
+ promptSnippet: "Identify the identifier and enclosing symbol at a cursor position",
18
+ });
19
+
20
+ const hoverSchema = Type.Object({
21
+ path: filePathParam(),
22
+ line: Type.Number({ description: "One-based cursor line" }),
23
+ column: Type.Optional(Type.Number({ description: "One-based cursor byte column" })),
24
+ });
25
+
26
+ interface HoverParams {
27
+ path: string;
28
+ line: number;
29
+ column?: number;
30
+ }
31
+
32
+ export interface ExecuteHoverOptions {
33
+ params: unknown;
34
+ signal: AbortSignal | undefined;
35
+ cwd: string;
36
+ }
37
+
38
+ export async function executeHover(opts: ExecuteHoverOptions): Promise<any> {
39
+ const { params, signal, cwd } = opts;
40
+ const p = params as HoverParams;
41
+
42
+ if (!Number.isSafeInteger(p.line) || p.line < 1) {
43
+ return buildToolErrorResult("hover", "invalid-parameter", "hover parameter 'line' must be a positive integer");
44
+ }
45
+
46
+ const filePath = resolveToCwd(p.path, cwd);
47
+
48
+ let content: string;
49
+ try {
50
+ content = await readFile(filePath, "utf-8");
51
+ } catch {
52
+ return buildToolErrorResult("hover", "file-not-found", `hover could not read ${p.path}`);
53
+ }
54
+
55
+ try {
56
+ const output = await readseekIdentify(filePath, content, {
57
+ line: p.line,
58
+ column: p.column,
59
+ signal,
60
+ });
61
+
62
+ const lines: string[] = [];
63
+ if (output.identifier) {
64
+ lines.push(`identifier: ${output.identifier.text}`);
65
+ }
66
+ if (output.symbol) {
67
+ lines.push(`symbol: ${output.symbol.name}`);
68
+ lines.push(`kind: ${output.symbol.kind}`);
69
+ lines.push(`qualified: ${output.symbol.qualified_name}`);
70
+ }
71
+ lines.push(`file: ${output.file}`);
72
+ lines.push(`language: ${output.language}`);
73
+ lines.push(`location: ${output.line}:${output.column}`);
74
+
75
+ return {
76
+ content: [{ type: "text", text: lines.join("\n") }],
77
+ details: {
78
+ readseekValue: {
79
+ tool: "hover",
80
+ ok: true,
81
+ path: filePath,
82
+ output,
83
+ },
84
+ },
85
+ };
86
+ } catch (err: any) {
87
+ const failure = classifyReadSeekFailure(err);
88
+ return buildToolErrorResult("hover", failure.code, failure.message, failure.hint ? { hint: failure.hint } : {});
89
+ }
90
+ }
91
+
92
+ export function registerHoverTool(pi: ExtensionAPI) {
93
+ registerReadSeekTool(pi, {
94
+ policy: "read-only",
95
+ pythonName: "hover",
96
+ defaultExposure: "opt-in",
97
+ }, {
98
+ name: "hover",
99
+ label: "Hover",
100
+ description: HOVER_PROMPT_METADATA.description,
101
+ promptSnippet: HOVER_PROMPT_METADATA.promptSnippet,
102
+ promptGuidelines: HOVER_PROMPT_METADATA.promptGuidelines,
103
+ parameters: hoverSchema,
104
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
105
+ return executeHover({ params, signal, cwd: ctx.cwd });
106
+ },
107
+ renderCall(args: any, theme: any, ...rest: any[]) {
108
+ const context = rest[0] ?? {};
109
+ const cwd = context.cwd ?? process.cwd();
110
+ const displayPath = typeof args?.path === "string" ? args.path : "?";
111
+ let text = theme.fg("toolTitle", theme.bold("hover"));
112
+ text += ` ${linkToolPath(theme.fg("accent", displayPath), displayPath, cwd)}`;
113
+ if (args?.line) text += theme.fg("dim", `:${args.line}`);
114
+ return text;
115
+ },
116
+ renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
117
+ const { isPartial, isError, width } = resolveRenderResultContext(options, rest);
118
+
119
+ if (isPartial) return renderPendingResult("pending hover", width);
120
+
121
+ const content = result.content?.[0];
122
+ const textContent = content?.type === "text" ? content.text : "";
123
+
124
+ if (isError || result.isError) {
125
+ return new Text(textContent || "hover failed", 0, 0);
126
+ }
127
+
128
+ return new Text(textContent.split("\n")[0] || "", 0, 0);
129
+ },
130
+ });
131
+ }
@@ -708,3 +708,270 @@ export async function readseekDetect(
708
708
  args.push(filePath);
709
709
  return parseDetectOutput(await runReadSeek(args, { signal: options.signal }));
710
710
  }
711
+
712
+ // --- Rename ---
713
+
714
+ export interface RenameConflict {
715
+ line: number;
716
+ column: number;
717
+ reason: string;
718
+ }
719
+
720
+ export interface RenameEdit {
721
+ line: number;
722
+ start_column: number;
723
+ end_column: number;
724
+ start_byte: number;
725
+ end_byte: number;
726
+ occurrence: string;
727
+ line_hash: string;
728
+ text: string;
729
+ }
730
+
731
+ export interface RenameFileOutput {
732
+ file: string;
733
+ language: string;
734
+ engine?: string;
735
+ file_hash: string;
736
+ conflicts: RenameConflict[];
737
+ edits: RenameEdit[];
738
+ }
739
+
740
+ export interface RenameOutput {
741
+ file: string;
742
+ language: string;
743
+ engine?: string;
744
+ file_hash: string;
745
+ old_name: string;
746
+ new_name: string;
747
+ applied: boolean;
748
+ conflicts: RenameConflict[];
749
+ edits: RenameEdit[];
750
+ others: RenameFileOutput[];
751
+ }
752
+
753
+ interface RenameOptions {
754
+ to: string;
755
+ line: number;
756
+ column?: number;
757
+ workspace?: string;
758
+ apply?: boolean;
759
+ language?: string;
760
+ cached?: boolean;
761
+ others?: boolean;
762
+ ignored?: boolean;
763
+ signal?: AbortSignal;
764
+ }
765
+
766
+ function parseRenameOutput(value: unknown): RenameOutput {
767
+ if (!value || typeof value !== "object") throw new Error("invalid readseek rename output");
768
+ const output = value as Record<string, unknown>;
769
+ return {
770
+ file: requireString(output.file, "file"),
771
+ language: requireString(output.language, "language"),
772
+ engine: optionalString(output.engine, "engine"),
773
+ file_hash: requireString(output.file_hash, "file_hash"),
774
+ old_name: requireString(output.old_name, "old_name"),
775
+ new_name: requireString(output.new_name, "new_name"),
776
+ applied: requireBoolean(output.applied, "applied"),
777
+ conflicts: (output.conflicts as any[] | undefined)?.map((c: Record<string, unknown>) => ({
778
+ line: requireNumber(c.line, "conflict.line"),
779
+ column: requireNumber(c.column, "conflict.column"),
780
+ reason: requireString(c.reason, "conflict.reason"),
781
+ })) ?? [],
782
+ edits: (output.edits as any[] | undefined)?.map((e: Record<string, unknown>) => ({
783
+ line: requireNumber(e.line, "edit.line"),
784
+ start_column: requireNumber(e.start_column, "edit.start_column"),
785
+ end_column: requireNumber(e.end_column, "edit.end_column"),
786
+ start_byte: requireNumber(e.start_byte, "edit.start_byte"),
787
+ end_byte: requireNumber(e.end_byte, "edit.end_byte"),
788
+ occurrence: requireString(e.occurrence, "edit.occurrence"),
789
+ line_hash: requireString(e.line_hash, "edit.line_hash"),
790
+ text: requireString(e.text, "edit.text"),
791
+ })) ?? [],
792
+ others: (output.others as any[] | undefined)?.map((o: Record<string, unknown>) => ({
793
+ file: requireString(o.file, "other.file"),
794
+ language: requireString(o.language, "other.language"),
795
+ engine: optionalString(o.engine, "other.engine"),
796
+ file_hash: requireString(o.file_hash, "other.file_hash"),
797
+ conflicts: (o.conflicts as any[] | undefined)?.map((c: Record<string, unknown>) => ({
798
+ line: requireNumber(c.line, "conflict.line"),
799
+ column: requireNumber(c.column, "conflict.column"),
800
+ reason: requireString(c.reason, "conflict.reason"),
801
+ })) ?? [],
802
+ edits: (o.edits as any[] | undefined)?.map((e: Record<string, unknown>) => ({
803
+ line: requireNumber(e.line, "edit.line"),
804
+ start_column: requireNumber(e.start_column, "edit.start_column"),
805
+ end_column: requireNumber(e.end_column, "edit.end_column"),
806
+ start_byte: requireNumber(e.start_byte, "edit.start_byte"),
807
+ end_byte: requireNumber(e.end_byte, "edit.end_byte"),
808
+ occurrence: requireString(e.occurrence, "edit.occurrence"),
809
+ line_hash: requireString(e.line_hash, "edit.line_hash"),
810
+ text: requireString(e.text, "edit.text"),
811
+ })) ?? [],
812
+ })) ?? [],
813
+ };
814
+ }
815
+
816
+ export async function readseekRename(
817
+ filePath: string,
818
+ options: RenameOptions,
819
+ ): Promise<RenameOutput> {
820
+ const args = ["rename", filePath, "--line", String(options.line)];
821
+ if (options.column !== undefined) args.push("--column", String(options.column));
822
+ args.push("--to", options.to);
823
+ if (options.apply) args.push("--apply");
824
+ if (options.workspace) args.push("--workspace", options.workspace);
825
+ if (options.language) args.push("--language", options.language);
826
+ if (options.cached) args.push("--cached");
827
+ if (options.others) args.push("--others");
828
+ if (options.ignored) args.push("--ignored");
829
+ return parseRenameOutput(await runReadSeek(args, { signal: options.signal }));
830
+ }
831
+
832
+ // --- Identify ---
833
+
834
+ export interface IdentifierOutput {
835
+ text: string;
836
+ start_column: number;
837
+ end_column: number;
838
+ start_byte: number;
839
+ end_byte: number;
840
+ }
841
+
842
+ export interface IdentifyOutput {
843
+ file: string;
844
+ language: string;
845
+ engine?: string;
846
+ line_count: number;
847
+ file_hash: string;
848
+ line: number;
849
+ column: number;
850
+ line_hash: string;
851
+ identifier?: IdentifierOutput;
852
+ symbol?: {
853
+ name: string;
854
+ kind: string;
855
+ qualified_name: string;
856
+ start_line: number;
857
+ end_line: number;
858
+ };
859
+ }
860
+
861
+ interface IdentifyOptions {
862
+ line?: number;
863
+ column?: number;
864
+ language?: string;
865
+ signal?: AbortSignal;
866
+ }
867
+
868
+ function parseIdentifyOutput(value: unknown): IdentifyOutput {
869
+ if (!value || typeof value !== "object") throw new Error("invalid readseek identify output");
870
+ const output = value as Record<string, unknown>;
871
+ const identifier = output.identifier;
872
+ const symbol = output.symbol;
873
+ return {
874
+ file: requireString(output.file, "file"),
875
+ language: requireString(output.language, "language"),
876
+ engine: optionalString(output.engine, "engine"),
877
+ line_count: requireNumber(output.line_count, "line_count"),
878
+ file_hash: requireString(output.file_hash, "file_hash"),
879
+ line: requireNumber(output.line, "line"),
880
+ column: requireNumber(output.column, "column"),
881
+ line_hash: requireString(output.line_hash, "line_hash"),
882
+ identifier: identifier && typeof identifier === "object"
883
+ ? {
884
+ text: requireString((identifier as Record<string, unknown>).text, "identifier.text"),
885
+ start_column: requireNumber((identifier as Record<string, unknown>).start_column, "identifier.start_column"),
886
+ end_column: requireNumber((identifier as Record<string, unknown>).end_column, "identifier.end_column"),
887
+ start_byte: requireNumber((identifier as Record<string, unknown>).start_byte, "identifier.start_byte"),
888
+ end_byte: requireNumber((identifier as Record<string, unknown>).end_byte, "identifier.end_byte"),
889
+ }
890
+ : undefined,
891
+ symbol: symbol && typeof symbol === "object"
892
+ ? {
893
+ name: requireString((symbol as Record<string, unknown>).name, "symbol.name"),
894
+ kind: requireString((symbol as Record<string, unknown>).kind, "symbol.kind"),
895
+ qualified_name: requireString((symbol as Record<string, unknown>).qualified_name, "symbol.qualified_name"),
896
+ start_line: requireNumber((symbol as Record<string, unknown>).start_line, "symbol.start_line"),
897
+ end_line: requireNumber((symbol as Record<string, unknown>).end_line, "symbol.end_line"),
898
+ }
899
+ : undefined,
900
+ };
901
+ }
902
+
903
+ export async function readseekIdentify(
904
+ filePath: string,
905
+ content: string,
906
+ options: IdentifyOptions = {},
907
+ ): Promise<IdentifyOutput> {
908
+ const args = ["identify", "--stdin", filePath];
909
+ if (options.line !== undefined) args.push("--line", String(options.line));
910
+ if (options.column !== undefined) args.push("--column", String(options.column));
911
+ if (options.language) args.push("--language", options.language);
912
+ return parseIdentifyOutput(await runReadSeek(args, { signal: options.signal, stdin: content }));
913
+ }
914
+
915
+ // --- Def ---
916
+
917
+ export interface DefLocation {
918
+ file: string;
919
+ line: number;
920
+ column: number;
921
+ line_hash: string;
922
+ text: string;
923
+ kind?: string;
924
+ name?: string;
925
+ qualified_name?: string;
926
+ }
927
+
928
+ interface DefOptions {
929
+ name?: string;
930
+ fromIdentify?: boolean;
931
+ identifyInput?: string;
932
+ language?: string;
933
+ cached?: boolean;
934
+ others?: boolean;
935
+ ignored?: boolean;
936
+ signal?: AbortSignal;
937
+ }
938
+
939
+ function parseDefCompact(value: unknown): DefLocation[] {
940
+ if (!value || typeof value !== "object") throw new Error("invalid readseek def output");
941
+ const output = value as Record<string, unknown>;
942
+ const locations = output.locations;
943
+ if (!Array.isArray(locations)) throw new Error("invalid readseek def locations");
944
+ return locations.map((loc) => {
945
+ if (!loc || typeof loc !== "object") throw new Error("invalid readseek def location");
946
+ const item = loc as Record<string, unknown>;
947
+ return {
948
+ file: requireString(item.file, "location.file"),
949
+ line: requireNumber(item.line, "location.line"),
950
+ column: requireNumber(item.column, "location.column"),
951
+ line_hash: requireString(item.line_hash, "location.line_hash"),
952
+ text: requireString(item.text, "location.text"),
953
+ kind: optionalString(item.kind, "location.kind"),
954
+ name: optionalString(item.name, "location.name"),
955
+ qualified_name: optionalString(item.qualified_name, "location.qualified_name"),
956
+ };
957
+ });
958
+ }
959
+
960
+ export async function readseekDef(
961
+ target: string,
962
+ options: DefOptions = {},
963
+ ): Promise<DefLocation[]> {
964
+ const args = ["def", target, "--format", "plain"];
965
+ if (options.fromIdentify) {
966
+ args.push("--from-identify");
967
+ }
968
+ if (options.name) {
969
+ args.push(options.name);
970
+ }
971
+ if (options.language) args.push("--language", options.language);
972
+ if (options.cached) args.push("--cached");
973
+ if (options.others) args.push("--others");
974
+ if (options.ignored) args.push("--ignored");
975
+ const stdin = options.fromIdentify && options.identifyInput ? options.identifyInput : undefined;
976
+ return parseDefCompact(await runReadSeek(args, { signal: options.signal, stdin }));
977
+ }
package/src/rename.ts ADDED
@@ -0,0 +1,167 @@
1
+ import type { ExtensionAPI, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import path from "node:path";
4
+ import { Text } from "@earendil-works/pi-tui";
5
+
6
+ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
7
+ import { buildToolErrorResult } from "./readseek-value.js";
8
+ import { resolveToCwd } from "./path-utils.js";
9
+ import { classifyReadSeekFailure, readseekRename, type RenameOutput } from "./readseek-client.js";
10
+ import { filePathParam, registerReadSeekTool } from "./register-tool.js";
11
+
12
+ import { clampLinesToWidth, linkToolPath, renderPendingResult, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
13
+
14
+ const RENAME_PROMPT_METADATA = defineToolPromptMetadata({
15
+ promptUrl: new URL("../prompts/rename.md", import.meta.url),
16
+ promptSnippet: "Rename an identifier with binding-accurate readseek rename",
17
+ });
18
+
19
+ const renameSchema = Type.Object({
20
+ path: filePathParam(),
21
+ line: Type.Number({ description: "One-based cursor line of the binding to rename" }),
22
+ column: Type.Optional(Type.Number({ description: "One-based cursor byte column of the binding to rename" })),
23
+ to: Type.String({ description: "New name for the binding" }),
24
+ workspace: Type.Optional(Type.Boolean({ description: "Expand rename across project root (name-based outside cursor file)" })),
25
+ apply: Type.Optional(Type.Boolean({ description: "Write the planned edits to disk after verifying line hashes" })),
26
+ });
27
+
28
+ interface RenameParams {
29
+ path: string;
30
+ line: number;
31
+ column?: number;
32
+ to: string;
33
+ workspace?: boolean;
34
+ apply?: boolean;
35
+ }
36
+
37
+ export interface ExecuteRenameOptions {
38
+ params: unknown;
39
+ signal: AbortSignal | undefined;
40
+ cwd: string;
41
+ }
42
+
43
+ export async function executeRename(opts: ExecuteRenameOptions): Promise<any> {
44
+ const { params, signal, cwd } = opts;
45
+ const p = params as RenameParams;
46
+
47
+ if (!p.to.trim()) {
48
+ return buildToolErrorResult("rename", "invalid-parameter", "rename parameter 'to' must not be empty");
49
+ }
50
+ if (!Number.isSafeInteger(p.line) || p.line < 1) {
51
+ return buildToolErrorResult("rename", "invalid-parameter", "rename parameter 'line' must be a positive integer");
52
+ }
53
+
54
+ const filePath = resolveToCwd(p.path, cwd);
55
+
56
+ try {
57
+ const output = await readseekRename(filePath, {
58
+ to: p.to,
59
+ line: p.line,
60
+ column: p.column,
61
+ workspace: p.workspace ? cwd : undefined,
62
+ apply: p.apply ?? true,
63
+ signal,
64
+ });
65
+
66
+ const files: string[] = [output.file];
67
+ for (const other of output.others) {
68
+ const abs = path.isAbsolute(other.file) ? other.file : path.resolve(cwd, other.file);
69
+ if (!files.includes(abs)) files.push(abs);
70
+ }
71
+
72
+ const totalEdits = output.edits.length + output.others.reduce((sum, o) => sum + o.edits.length, 0);
73
+ const totalConflicts = output.conflicts.length + output.others.reduce((sum, o) => sum + o.conflicts.length, 0);
74
+
75
+ const parts: string[] = [];
76
+ if (totalConflicts > 0) {
77
+ parts.push(`${totalConflicts} naming conflict(s)`);
78
+ }
79
+ parts.push(`renamed ${output.old_name} to ${output.new_name} in ${totalEdits} location(s) across ${files.length} file(s)`);
80
+
81
+ let text = parts.join("; ");
82
+ if (output.applied) text += " (applied)";
83
+ else text += " (dry-run)";
84
+
85
+ // Surface first conflict reason for visibility
86
+ const firstConflict = output.conflicts[0]
87
+ ?? output.others.find((o) => o.conflicts.length > 0)?.conflicts[0];
88
+ if (firstConflict) {
89
+ text += `\nFirst conflict at ${firstConflict.line}:${firstConflict.column}: ${firstConflict.reason}`;
90
+ }
91
+
92
+ return {
93
+ content: [{ type: "text", text }],
94
+ details: {
95
+ readseekValue: {
96
+ tool: "rename",
97
+ ok: true,
98
+ path: filePath,
99
+ output,
100
+ },
101
+ },
102
+ };
103
+ } catch (err: any) {
104
+ const failure = classifyReadSeekFailure(err);
105
+ return buildToolErrorResult("rename", failure.code, failure.message, failure.hint ? { hint: failure.hint } : {});
106
+ }
107
+ }
108
+
109
+ export function registerRenameTool(pi: ExtensionAPI) {
110
+ registerReadSeekTool(pi, {
111
+ policy: "mutating",
112
+ pythonName: "rename",
113
+ defaultExposure: "not-safe-by-default",
114
+ }, {
115
+ name: "rename",
116
+ label: "Rename",
117
+ description: RENAME_PROMPT_METADATA.description,
118
+ promptSnippet: RENAME_PROMPT_METADATA.promptSnippet,
119
+ promptGuidelines: RENAME_PROMPT_METADATA.promptGuidelines,
120
+ parameters: renameSchema,
121
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
122
+ return executeRename({ params, signal, cwd: ctx.cwd });
123
+ },
124
+ renderCall(args: any, theme: any, ...rest: any[]) {
125
+ const context = rest[0] ?? {};
126
+ const cwd = context.cwd ?? process.cwd();
127
+ const displayPath = typeof args?.path === "string" ? args.path : "?";
128
+ const oldName = typeof args?.to === "string" ? "" : "";
129
+ let text = theme.fg("toolTitle", theme.bold("rename"));
130
+ text += ` ${linkToolPath(theme.fg("accent", displayPath), displayPath, cwd)}`;
131
+ if (args?.to) text += theme.fg("dim", ` → ${args.to}`);
132
+ return text;
133
+ },
134
+ renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
135
+ const { isPartial, isError, expanded, width } = resolveRenderResultContext(options, rest);
136
+
137
+ if (isPartial) return renderPendingResult("pending rename", width);
138
+
139
+ const content = result.content?.[0];
140
+ const textContent = content?.type === "text" ? content.text : "";
141
+ const readseekValue = (result.details as any)?.readseekValue;
142
+ const output = readseekValue?.output as RenameOutput | undefined;
143
+
144
+ if (isError || result.isError) {
145
+ return new Text(textContent || "rename failed", 0, 0);
146
+ }
147
+
148
+ let text = summaryLine(
149
+ output?.applied
150
+ ? `renamed ${output.old_name} → ${output.new_name}`
151
+ : `rename plan for ${output?.old_name ?? "?"} → ${output?.new_name ?? "?"} (dry-run)`,
152
+ );
153
+
154
+ if (expanded && output) {
155
+ const files = new Set<string>();
156
+ files.add(output.file);
157
+ for (const o of output.others) files.add(o.file);
158
+ for (const f of files) {
159
+ text += `\n ${f}`;
160
+ }
161
+ }
162
+
163
+ const lines = clampLinesToWidth(text.split("\n"), width);
164
+ return new Text(lines.join("\n"), 0, 0);
165
+ },
166
+ });
167
+ }