@danypops/pi-lector 0.1.0
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/LICENSE +21 -0
- package/README.md +14 -0
- package/extension/src/code-intelligence-hints.ts +38 -0
- package/extension/src/code-intelligence-operations.ts +178 -0
- package/extension/src/code-intelligence-rendering.ts +237 -0
- package/extension/src/cross-workspace-search-operations.ts +47 -0
- package/extension/src/cross-workspace-search-rendering.ts +66 -0
- package/extension/src/edit-operations.ts +77 -0
- package/extension/src/find-symbols-operations.ts +34 -0
- package/extension/src/find-symbols-rendering.ts +54 -0
- package/extension/src/git-operations.ts +46 -0
- package/extension/src/git-rendering.ts +64 -0
- package/extension/src/index.ts +943 -0
- package/extension/src/lector-client.ts +175 -0
- package/extension/src/lector-tui-theme.ts +35 -0
- package/extension/src/nearest-workspace-root.ts +34 -0
- package/extension/src/read-operations.ts +73 -0
- package/extension/src/repo-fetch-operations.ts +20 -0
- package/extension/src/repo-fetch-rendering.ts +21 -0
- package/extension/src/search-operations.ts +24 -0
- package/extension/src/search-rendering.ts +21 -0
- package/extension/src/workspace-cache-operations.ts +97 -0
- package/extension/src/workspace-relative-path.ts +17 -0
- package/extension/src/write-operations.ts +61 -0
- package/package.json +40 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type ContentHash, remoteErrorIs } from "@danypops/lector";
|
|
2
|
+
import type { EditOperations } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { lectorClient, withWorkspace, workspaceForPath } from "./lector-client.ts";
|
|
4
|
+
import { toWorkspaceRelativePath } from "./workspace-relative-path.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* EditOperations backed by Lector's hash-guarded exactEdit. The workspace
|
|
8
|
+
* for each call is resolved from the absolute path being edited, not a
|
|
9
|
+
* fixed cwd -- see workspaceForPath.
|
|
10
|
+
*
|
|
11
|
+
* pi's own EditOperations interface has no seam for passing a hash from
|
|
12
|
+
* readFile to writeFile -- it calls ops.readFile, computes the oldText/
|
|
13
|
+
* newText replacement itself, then calls ops.writeFile(absolutePath, content)
|
|
14
|
+
* with no memory of what was read. readFile() here stashes the hash it just
|
|
15
|
+
* observed in a short-lived per-absolutePath slot; writeFile() consumes
|
|
16
|
+
* (and clears) it as exactEdit's expectedHash. This is safe because pi's
|
|
17
|
+
* built-in edit tool already serializes readFile-then-writeFile for one
|
|
18
|
+
* absolutePath through withFileMutationQueue -- no other mutation on the
|
|
19
|
+
* same path can land between this readFile and this writeFile.
|
|
20
|
+
*
|
|
21
|
+
* A StaleExpectedHash here means the file changed on disk after the model's
|
|
22
|
+
* oldText was computed against a specific earlier read -- it surfaces as a
|
|
23
|
+
* real edit failure, not a silent retry: retrying would mean re-reading
|
|
24
|
+
* fresh content the model never saw and applying an oldText match computed
|
|
25
|
+
* against stale content, which could silently corrupt the file.
|
|
26
|
+
*/
|
|
27
|
+
export function createLectorEditOperations(): EditOperations {
|
|
28
|
+
const observedHashByPath = new Map<string, ContentHash>();
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
async readFile(absolutePath) {
|
|
32
|
+
return withWorkspace(
|
|
33
|
+
() => workspaceForPath(absolutePath),
|
|
34
|
+
async ({ workspaceId, root }) => {
|
|
35
|
+
const client = await lectorClient();
|
|
36
|
+
const relativePath = toWorkspaceRelativePath(root, absolutePath);
|
|
37
|
+
const { content, hash } = await client.call("workspace.rawRead", { workspaceId, path: relativePath });
|
|
38
|
+
observedHashByPath.set(absolutePath, hash);
|
|
39
|
+
return Buffer.from(content, "utf-8");
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async writeFile(absolutePath, content) {
|
|
45
|
+
const expectedHash = observedHashByPath.get(absolutePath) ?? null;
|
|
46
|
+
observedHashByPath.delete(absolutePath);
|
|
47
|
+
await withWorkspace(
|
|
48
|
+
() => workspaceForPath(absolutePath),
|
|
49
|
+
async ({ workspaceId, root }) => {
|
|
50
|
+
const client = await lectorClient();
|
|
51
|
+
const relativePath = toWorkspaceRelativePath(root, absolutePath);
|
|
52
|
+
try {
|
|
53
|
+
await client.call("workspace.exactEdit", { workspaceId, path: relativePath, expectedHash, content });
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (remoteErrorIs(error, "StaleExpectedHash")) {
|
|
56
|
+
throw new Error(`"${relativePath}" changed on disk since it was last read; re-read the file and retry the edit.`);
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async access(absolutePath) {
|
|
65
|
+
// workspace.rawRead itself rejects a missing entry -- exactly the "not accessible"
|
|
66
|
+
// signal pi's edit tool expects access() to throw for.
|
|
67
|
+
await withWorkspace(
|
|
68
|
+
() => workspaceForPath(absolutePath),
|
|
69
|
+
async ({ workspaceId, root }) => {
|
|
70
|
+
const client = await lectorClient();
|
|
71
|
+
const relativePath = toWorkspaceRelativePath(root, absolutePath);
|
|
72
|
+
await client.call("workspace.rawRead", { workspaceId, path: relativePath });
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { WorkspaceSymbol } from "@danypops/lector";
|
|
2
|
+
import { lectorClient, withWorkspace, workspaceForDirectory } from "./lector-client.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Thin wrapper over workspace.findSymbols. No seedFile parameter here or
|
|
6
|
+
* anywhere above this call: Lector's discoverSeedFile() bounded
|
|
7
|
+
* auto-discovery fully absorbs that tsserver implementation detail, so a
|
|
8
|
+
* pi tool schema (and the model calling it) never needs to know it exists.
|
|
9
|
+
*
|
|
10
|
+
* `directory` is required, not an optional override with a hidden default:
|
|
11
|
+
* exactly like rawRead/exactEdit require an explicit path rather than
|
|
12
|
+
* defaulting to "whatever file was last touched," a symbol query requires
|
|
13
|
+
* an explicit project rather than silently defaulting to the session's own
|
|
14
|
+
* cwd. The caller passes cwd itself to search the current project -- there
|
|
15
|
+
* is no implicit fallback anywhere in this module.
|
|
16
|
+
*/
|
|
17
|
+
export interface FindSymbolsOperations {
|
|
18
|
+
findSymbols(query: string, directory: string): Promise<readonly WorkspaceSymbol[]>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createLectorFindSymbolsOperations(): FindSymbolsOperations {
|
|
22
|
+
return {
|
|
23
|
+
async findSymbols(query, directory) {
|
|
24
|
+
return withWorkspace(
|
|
25
|
+
() => workspaceForDirectory(directory),
|
|
26
|
+
async ({ workspaceId }) => {
|
|
27
|
+
const client = await lectorClient();
|
|
28
|
+
const { symbols } = await client.call("workspace.findSymbols", { workspaceId, query });
|
|
29
|
+
return symbols;
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { WorkspaceSymbol } from "@danypops/lector";
|
|
2
|
+
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { colorForKind, formatLocation, type LectorTheme } from "./lector-tui-theme.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Custom TUI rendering for find_symbols -- the one Lector-backed tool with
|
|
7
|
+
* no built-in pi-coding-agent equivalent to inherit rendering from. read/
|
|
8
|
+
* write/edit get syntax highlighting, diffs, and truncation banners for
|
|
9
|
+
* free via createReadToolDefinition/etc.; this tool needs its own, built
|
|
10
|
+
* the same way pi's own built-ins are (see @earendil-works/pi-coding-agent's
|
|
11
|
+
* read.js/write.js/edit.js and the todo.ts example) -- theme.fg/theme.bold
|
|
12
|
+
* plus keyHint for the expand affordance, not ad-hoc plain text.
|
|
13
|
+
*/
|
|
14
|
+
export type FindSymbolsTheme = LectorTheme;
|
|
15
|
+
|
|
16
|
+
const DEFAULT_VISIBLE_RESULTS = 8;
|
|
17
|
+
|
|
18
|
+
export function formatFindSymbolsCall(args: { query?: unknown; directory?: unknown }, theme: FindSymbolsTheme): string {
|
|
19
|
+
const query = typeof args.query === "string" ? args.query : "";
|
|
20
|
+
let content = `${theme.fg("toolTitle", theme.bold("find_symbols"))} ${theme.fg("accent", `"${query}"`)}`;
|
|
21
|
+
if (typeof args.directory === "string" && args.directory.length > 0) {
|
|
22
|
+
content += ` ${theme.fg("muted", `in ${args.directory}`)}`;
|
|
23
|
+
}
|
|
24
|
+
return content;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One result line, kind-padded to the widest kind actually present so the name column lines up. */
|
|
28
|
+
function formatSymbolLine(symbol: WorkspaceSymbol, theme: FindSymbolsTheme, kindColumnWidth: number): string {
|
|
29
|
+
const kind = theme.fg(colorForKind(symbol.kind), symbol.kind.padEnd(kindColumnWidth));
|
|
30
|
+
const name = theme.fg("text", theme.bold(symbol.name));
|
|
31
|
+
const location = formatLocation(theme, symbol.location.path, symbol.location.line, symbol.location.character);
|
|
32
|
+
return `${kind} ${name} ${location}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatFindSymbolsResult(symbols: readonly WorkspaceSymbol[] | undefined, query: string, expanded: boolean, theme: FindSymbolsTheme): string {
|
|
36
|
+
if (!symbols || symbols.length === 0) {
|
|
37
|
+
return theme.fg("dim", `No symbols found matching "${query}".`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const kindColumnWidth = Math.max(...symbols.map((symbol) => symbol.kind.length));
|
|
41
|
+
const displayCount = expanded ? symbols.length : Math.min(symbols.length, DEFAULT_VISIBLE_RESULTS);
|
|
42
|
+
const lines = [theme.fg("muted", `${symbols.length} symbol${symbols.length === 1 ? "" : "s"} matching "${query}":`)];
|
|
43
|
+
|
|
44
|
+
for (const symbol of symbols.slice(0, displayCount)) {
|
|
45
|
+
lines.push(formatSymbolLine(symbol, theme, kindColumnWidth));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const remaining = symbols.length - displayCount;
|
|
49
|
+
if (remaining > 0) {
|
|
50
|
+
lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { GitDiffResult, GitLogEntry, GitStatusSummary } from "@danypops/lector";
|
|
2
|
+
import { lectorClient, withWorkspace, workspaceForDirectory } from "./lector-client.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Thin wrappers over Lector's read-only git operations. `directory` is
|
|
6
|
+
* required, same convention as find_symbols -- no implicit "whatever the
|
|
7
|
+
* session's cwd is" fallback.
|
|
8
|
+
*/
|
|
9
|
+
export interface GitOperations {
|
|
10
|
+
status(directory: string): Promise<GitStatusSummary>;
|
|
11
|
+
log(directory: string, maxCount: number): Promise<readonly GitLogEntry[]>;
|
|
12
|
+
diff(directory: string, ref: string | undefined, maxBytes: number): Promise<GitDiffResult>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createLectorGitOperations(): GitOperations {
|
|
16
|
+
return {
|
|
17
|
+
async status(directory) {
|
|
18
|
+
return withWorkspace(
|
|
19
|
+
() => workspaceForDirectory(directory),
|
|
20
|
+
async ({ workspaceId }) => {
|
|
21
|
+
const client = await lectorClient();
|
|
22
|
+
return client.call("workspace.gitStatus", { workspaceId });
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
},
|
|
26
|
+
async log(directory, maxCount) {
|
|
27
|
+
return withWorkspace(
|
|
28
|
+
() => workspaceForDirectory(directory),
|
|
29
|
+
async ({ workspaceId }) => {
|
|
30
|
+
const client = await lectorClient();
|
|
31
|
+
const { entries } = await client.call("workspace.gitLog", { workspaceId, maxCount });
|
|
32
|
+
return entries;
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
async diff(directory, ref, maxBytes) {
|
|
37
|
+
return withWorkspace(
|
|
38
|
+
() => workspaceForDirectory(directory),
|
|
39
|
+
async ({ workspaceId }) => {
|
|
40
|
+
const client = await lectorClient();
|
|
41
|
+
return client.call("workspace.gitDiff", { workspaceId, ref, maxBytes });
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { GitDiffResult, GitLogEntry, GitStatusSummary } from "@danypops/lector";
|
|
2
|
+
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { LectorTheme } from "./lector-tui-theme.ts";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_VISIBLE_FILES = 20;
|
|
6
|
+
const DEFAULT_VISIBLE_COMMITS = 10;
|
|
7
|
+
const DEFAULT_VISIBLE_DIFF_LINES = 60;
|
|
8
|
+
|
|
9
|
+
export function formatGitStatusCall(args: { directory?: unknown }, theme: LectorTheme): string {
|
|
10
|
+
const directory = typeof args.directory === "string" ? args.directory : "";
|
|
11
|
+
return `${theme.fg("toolTitle", theme.bold("git_status"))} ${theme.fg("accent", directory)}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatGitStatusResult(summary: GitStatusSummary | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
15
|
+
if (!summary) return theme.fg("dim", "No status available.");
|
|
16
|
+
const branch = summary.current ?? "(detached)";
|
|
17
|
+
const tracking = summary.tracking ? `, tracking ${summary.tracking} (+${summary.ahead}/-${summary.behind})` : "";
|
|
18
|
+
const lines = [theme.fg("accent", `On branch ${branch}${tracking}`)];
|
|
19
|
+
if (summary.files.length === 0) {
|
|
20
|
+
lines.push(theme.fg("dim", "working tree clean"));
|
|
21
|
+
return lines.join("\n");
|
|
22
|
+
}
|
|
23
|
+
const displayCount = expanded ? summary.files.length : Math.min(DEFAULT_VISIBLE_FILES, summary.files.length);
|
|
24
|
+
for (const file of summary.files.slice(0, displayCount)) {
|
|
25
|
+
const code = `${file.indexStatus}${file.workingDirStatus}`;
|
|
26
|
+
lines.push(file.renamedFrom ? `${code} ${file.renamedFrom} -> ${file.path}` : `${code} ${file.path}`);
|
|
27
|
+
}
|
|
28
|
+
const remaining = summary.files.length - displayCount;
|
|
29
|
+
if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
|
|
30
|
+
return lines.join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatGitLogCall(args: { directory?: unknown; maxCount?: unknown }, theme: LectorTheme): string {
|
|
34
|
+
const directory = typeof args.directory === "string" ? args.directory : "";
|
|
35
|
+
return `${theme.fg("toolTitle", theme.bold("git_log"))} ${theme.fg("accent", directory)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function formatGitLogResult(entries: readonly GitLogEntry[] | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
39
|
+
if (!entries || entries.length === 0) return theme.fg("dim", "No commits found.");
|
|
40
|
+
const displayCount = expanded ? entries.length : Math.min(DEFAULT_VISIBLE_COMMITS, entries.length);
|
|
41
|
+
const lines = entries
|
|
42
|
+
.slice(0, displayCount)
|
|
43
|
+
.map((entry) => `${theme.fg("accent", entry.sha.slice(0, 8))} ${entry.authoredAt} ${entry.authorName} -- ${entry.message}`);
|
|
44
|
+
const remaining = entries.length - displayCount;
|
|
45
|
+
if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
|
|
46
|
+
return lines.join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatGitDiffCall(args: { directory?: unknown; ref?: unknown }, theme: LectorTheme): string {
|
|
50
|
+
const directory = typeof args.directory === "string" ? args.directory : "";
|
|
51
|
+
const ref = typeof args.ref === "string" ? ` ${args.ref}` : "";
|
|
52
|
+
return `${theme.fg("toolTitle", theme.bold("git_diff"))} ${theme.fg("accent", directory)}${ref}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatGitDiffResult(result: GitDiffResult | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
56
|
+
if (!result || result.diff.length === 0) return theme.fg("dim", "No differences.");
|
|
57
|
+
const lines = result.diff.split("\n");
|
|
58
|
+
const displayCount = expanded ? lines.length : Math.min(DEFAULT_VISIBLE_DIFF_LINES, lines.length);
|
|
59
|
+
const shown = lines.slice(0, displayCount).join("\n");
|
|
60
|
+
const remaining = lines.length - displayCount;
|
|
61
|
+
const truncationNote = remaining > 0 ? `\n${theme.fg("dim", `... ${remaining} more lines (${keyHint("app.tools.expand", "to expand")})`)}` : "";
|
|
62
|
+
const boundedNote = result.truncated ? `\n${theme.fg("warning", "(diff output itself was truncated by maxBytes)")}` : "";
|
|
63
|
+
return shown + truncationNote + boundedNote;
|
|
64
|
+
}
|