@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Popsuevich
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# pi-lector
|
|
2
|
+
|
|
3
|
+
Pi host adapter for Lector: overrides `read`, `write`, and `edit` with a
|
|
4
|
+
daemon-backed, hash-guarded filesystem. Requires a running Lector daemon
|
|
5
|
+
(`lector serve`) — no auto-spawn.
|
|
6
|
+
|
|
7
|
+
`populate_symbol_graph` submits bounded background work and waits briefly. If the
|
|
8
|
+
graph is still loading, it returns a job id immediately; `job_status` polls that id
|
|
9
|
+
later without forcing the agent into a blocking or blind polling loop.
|
|
10
|
+
|
|
11
|
+
When a session starts inside a Git repository, pi-lector checks a durable, bounded
|
|
12
|
+
source-content manifest without blocking startup. The footer reports not cached,
|
|
13
|
+
caching, or cached; completion also emits a one-shot notification. Session shutdown
|
|
14
|
+
stops polling, and the agent receives each state transition once in its context.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Diagnostic, DocumentSymbolEntry } from "@danypops/lector";
|
|
2
|
+
|
|
3
|
+
/** Only "error"/"warning" ever surface -- an "info"/"hint" on every keystroke would make the hint noisier than the edit it's attached to. */
|
|
4
|
+
function isNoteworthy(diagnostic: Diagnostic): boolean {
|
|
5
|
+
return diagnostic.severity === "error" || diagnostic.severity === "warning";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A one-line note appended after edit/write on an LSP-supported file with a
|
|
10
|
+
* warm index already available -- mirrors pi-lsp-lite's real precedent of
|
|
11
|
+
* surfacing diagnostics inline with the edit result, so a caught type error
|
|
12
|
+
* doesn't wait for a separate diagnostics call to be noticed. Undefined
|
|
13
|
+
* when there is nothing noteworthy to say.
|
|
14
|
+
*/
|
|
15
|
+
export function buildPostEditDiagnosticsHint(diagnostics: readonly Diagnostic[]): string | undefined {
|
|
16
|
+
const noteworthy = diagnostics.filter(isNoteworthy);
|
|
17
|
+
if (noteworthy.length === 0) return undefined;
|
|
18
|
+
const errorCount = noteworthy.filter((d) => d.severity === "error").length;
|
|
19
|
+
const warningCount = noteworthy.length - errorCount;
|
|
20
|
+
const parts: string[] = [];
|
|
21
|
+
if (errorCount > 0) parts.push(`${errorCount} error${errorCount === 1 ? "" : "s"}`);
|
|
22
|
+
if (warningCount > 0) parts.push(`${warningCount} warning${warningCount === 1 ? "" : "s"}`);
|
|
23
|
+
return `Lector: ${parts.join(", ")} on this file (see the diagnostics tool for detail).`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Below this, a read already shows the whole structure at a glance -- a hint would just be noise. */
|
|
27
|
+
const MIN_SYMBOLS_FOR_HINT = 8;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A one-line note appended after reading a large, LSP-supported file with a
|
|
31
|
+
* warm index already available -- nudges toward document_symbols/
|
|
32
|
+
* find_references/go_to_definition instead of re-reading the whole file for
|
|
33
|
+
* a targeted question next time.
|
|
34
|
+
*/
|
|
35
|
+
export function buildPostReadStructureHint(symbols: readonly DocumentSymbolEntry[]): string | undefined {
|
|
36
|
+
if (symbols.length < MIN_SYMBOLS_FOR_HINT) return undefined;
|
|
37
|
+
return `Lector: this file has ${symbols.length} top-level symbols -- document_symbols/find_references/go_to_definition can target one directly instead of rereading the whole file.`;
|
|
38
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CallHierarchyEntry,
|
|
3
|
+
Diagnostic,
|
|
4
|
+
DocumentSymbolEntry,
|
|
5
|
+
Hover,
|
|
6
|
+
IncomingCall,
|
|
7
|
+
JobSnapshot,
|
|
8
|
+
OutgoingCall,
|
|
9
|
+
PopulateSymbolGraphResult,
|
|
10
|
+
SymbolEdgeKind,
|
|
11
|
+
SymbolNode,
|
|
12
|
+
WorkspaceLocation,
|
|
13
|
+
} from "@danypops/lector";
|
|
14
|
+
import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath } from "./lector-client.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Thin wrappers over Lector's code-intelligence operations: goToDefinition,
|
|
18
|
+
* findReferences, hover, documentSymbols. Position-based (path + 1-indexed
|
|
19
|
+
* line + character), not symbol-name-based:
|
|
20
|
+
* an agent already has an exact position from a prior read or find_symbols
|
|
21
|
+
* call, so these compose directly off that rather than requiring a second,
|
|
22
|
+
* ambiguity-prone "which occurrence of this name" lookup.
|
|
23
|
+
*
|
|
24
|
+
* `path` resolves its own workspace per call (workspaceForCodeIntelligencePath,
|
|
25
|
+
* falling back to the file's own containing directory, never the filesystem
|
|
26
|
+
* root -- every one of these operations spawns a real language server,
|
|
27
|
+
* unlike read/write/edit) -- never a value captured once at session start.
|
|
28
|
+
*/
|
|
29
|
+
export interface CodeIntelligenceOperations {
|
|
30
|
+
goToDefinition(path: string, line: number, character: number): Promise<readonly WorkspaceLocation[]>;
|
|
31
|
+
goToImplementation(path: string, line: number, character: number): Promise<readonly WorkspaceLocation[]>;
|
|
32
|
+
findReferences(path: string, line: number, character: number, includeDeclaration: boolean): Promise<readonly WorkspaceLocation[]>;
|
|
33
|
+
hover(path: string, line: number, character: number): Promise<Hover | undefined>;
|
|
34
|
+
documentSymbols(path: string): Promise<readonly DocumentSymbolEntry[]>;
|
|
35
|
+
diagnostics(path: string): Promise<readonly Diagnostic[]>;
|
|
36
|
+
prepareCallHierarchy(path: string, line: number, character: number): Promise<readonly CallHierarchyEntry[]>;
|
|
37
|
+
incomingCalls(path: string, line: number, character: number): Promise<readonly IncomingCall[]>;
|
|
38
|
+
outgoingCalls(path: string, line: number, character: number): Promise<readonly OutgoingCall[]>;
|
|
39
|
+
populateSymbolGraph(path: string, maxFiles: number, maxSymbolsPerFile: number, waitMs?: number): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
|
|
40
|
+
jobStatus(jobId: string): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
|
|
41
|
+
reachableFrom(path: string, line: number, character: number, maxDepth: number, kind?: SymbolEdgeKind): Promise<readonly SymbolNode[]>;
|
|
42
|
+
/** Never spawns a symbol index -- safe to call opportunistically (e.g. before deciding whether to enrich a result). */
|
|
43
|
+
hasWarmIndex(path: string): Promise<boolean>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createLectorCodeIntelligenceOperations(): CodeIntelligenceOperations {
|
|
47
|
+
return {
|
|
48
|
+
async goToDefinition(path, line, character) {
|
|
49
|
+
return withWorkspace(
|
|
50
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
51
|
+
async ({ workspaceId }) => {
|
|
52
|
+
const client = await lectorClient();
|
|
53
|
+
const { locations } = await client.call("workspace.goToDefinition", { workspaceId, path, line, character });
|
|
54
|
+
return locations;
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
async goToImplementation(path, line, character) {
|
|
59
|
+
return withWorkspace(
|
|
60
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
61
|
+
async ({ workspaceId }) => {
|
|
62
|
+
const client = await lectorClient();
|
|
63
|
+
const { locations } = await client.call("workspace.goToImplementation", { workspaceId, path, line, character });
|
|
64
|
+
return locations;
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
},
|
|
68
|
+
async findReferences(path, line, character, includeDeclaration) {
|
|
69
|
+
return withWorkspace(
|
|
70
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
71
|
+
async ({ workspaceId }) => {
|
|
72
|
+
const client = await lectorClient();
|
|
73
|
+
const { locations } = await client.call("workspace.findReferences", { workspaceId, path, line, character, includeDeclaration });
|
|
74
|
+
return locations;
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
async hover(path, line, character) {
|
|
79
|
+
return withWorkspace(
|
|
80
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
81
|
+
async ({ workspaceId }) => {
|
|
82
|
+
const client = await lectorClient();
|
|
83
|
+
const { hover } = await client.call("workspace.hover", { workspaceId, path, line, character });
|
|
84
|
+
return hover;
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
async documentSymbols(path) {
|
|
89
|
+
return withWorkspace(
|
|
90
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
91
|
+
async ({ workspaceId }) => {
|
|
92
|
+
const client = await lectorClient();
|
|
93
|
+
const { symbols } = await client.call("workspace.documentSymbols", { workspaceId, path });
|
|
94
|
+
return symbols;
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
},
|
|
98
|
+
async diagnostics(path) {
|
|
99
|
+
return withWorkspace(
|
|
100
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
101
|
+
async ({ workspaceId }) => {
|
|
102
|
+
const client = await lectorClient();
|
|
103
|
+
const { diagnostics } = await client.call("workspace.diagnostics", { workspaceId, path });
|
|
104
|
+
return diagnostics;
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
async prepareCallHierarchy(path, line, character) {
|
|
109
|
+
return withWorkspace(
|
|
110
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
111
|
+
async ({ workspaceId }) => {
|
|
112
|
+
const client = await lectorClient();
|
|
113
|
+
const { items } = await client.call("workspace.prepareCallHierarchy", { workspaceId, path, line, character });
|
|
114
|
+
return items;
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
async incomingCalls(path, line, character) {
|
|
119
|
+
return withWorkspace(
|
|
120
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
121
|
+
async ({ workspaceId }) => {
|
|
122
|
+
const client = await lectorClient();
|
|
123
|
+
const { calls } = await client.call("workspace.incomingCalls", { workspaceId, path, line, character });
|
|
124
|
+
return calls;
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
async outgoingCalls(path, line, character) {
|
|
129
|
+
return withWorkspace(
|
|
130
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
131
|
+
async ({ workspaceId }) => {
|
|
132
|
+
const client = await lectorClient();
|
|
133
|
+
const { calls } = await client.call("workspace.outgoingCalls", { workspaceId, path, line, character });
|
|
134
|
+
return calls;
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
async populateSymbolGraph(path, maxFiles, maxSymbolsPerFile, waitMs = 500) {
|
|
139
|
+
return withWorkspace(
|
|
140
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
141
|
+
async ({ workspaceId }) => {
|
|
142
|
+
const client = await lectorClient();
|
|
143
|
+
const { job } = await client.call("job.submit", {
|
|
144
|
+
operation: "workspace.populateSymbolGraph",
|
|
145
|
+
input: { workspaceId, maxFiles, maxSymbolsPerFile },
|
|
146
|
+
waitMs,
|
|
147
|
+
});
|
|
148
|
+
return job;
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
async jobStatus(jobId) {
|
|
153
|
+
const client = await lectorClient();
|
|
154
|
+
const { job } = await client.call("job.status", { jobId });
|
|
155
|
+
return job;
|
|
156
|
+
},
|
|
157
|
+
async reachableFrom(path, line, character, maxDepth, kind) {
|
|
158
|
+
return withWorkspace(
|
|
159
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
160
|
+
async ({ workspaceId }) => {
|
|
161
|
+
const client = await lectorClient();
|
|
162
|
+
const { symbols } = await client.call("workspace.reachableFrom", { workspaceId, path, line, character, maxDepth, kind });
|
|
163
|
+
return symbols;
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
},
|
|
167
|
+
async hasWarmIndex(path) {
|
|
168
|
+
return withWorkspace(
|
|
169
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
170
|
+
async ({ workspaceId }) => {
|
|
171
|
+
const client = await lectorClient();
|
|
172
|
+
const { warm } = await client.call("workspace.hasWarmIndex", { workspaceId });
|
|
173
|
+
return warm;
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CallHierarchyEntry,
|
|
3
|
+
Diagnostic,
|
|
4
|
+
DocumentSymbolEntry,
|
|
5
|
+
Hover,
|
|
6
|
+
IncomingCall,
|
|
7
|
+
JobSnapshot,
|
|
8
|
+
OutgoingCall,
|
|
9
|
+
PopulateSymbolGraphResult,
|
|
10
|
+
SymbolNode,
|
|
11
|
+
WorkspaceLocation,
|
|
12
|
+
} from "@danypops/lector";
|
|
13
|
+
import { keyHint, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { colorForKind, formatLocation, type LectorTheme } from "./lector-tui-theme.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Custom TUI rendering for the code-intelligence tools: go_to_definition,
|
|
18
|
+
* find_references, hover, document_symbols. None of these have a built-in
|
|
19
|
+
* pi-coding-agent equivalent to inherit
|
|
20
|
+
* rendering from, exactly like find_symbols -- same theme.fg/theme.bold/
|
|
21
|
+
* keyHint approach, sharing find_symbols' own kind-coloring and location
|
|
22
|
+
* formatting via lector-tui-theme.ts rather than redefining it four times.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const DEFAULT_VISIBLE_LOCATIONS = 8;
|
|
26
|
+
const DEFAULT_VISIBLE_SYMBOLS = 12;
|
|
27
|
+
const DEFAULT_VISIBLE_DIAGNOSTICS = 12;
|
|
28
|
+
const DEFAULT_VISIBLE_CALLS = 12;
|
|
29
|
+
|
|
30
|
+
const DIAGNOSTIC_SEVERITY_COLOR: Record<Diagnostic["severity"], ThemeColor> = {
|
|
31
|
+
error: "error",
|
|
32
|
+
warning: "warning",
|
|
33
|
+
information: "muted",
|
|
34
|
+
hint: "dim",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function formatPositionalCall(toolName: string, args: { path?: unknown; line?: unknown; character?: unknown }, theme: LectorTheme): string {
|
|
38
|
+
const path = typeof args.path === "string" ? args.path : "";
|
|
39
|
+
const line = typeof args.line === "number" ? args.line : "?";
|
|
40
|
+
const character = typeof args.character === "number" ? args.character : "?";
|
|
41
|
+
return `${theme.fg("toolTitle", theme.bold(toolName))} ${theme.fg("accent", `${path}:${line}:${character}`)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatLocationList(locations: readonly WorkspaceLocation[] | undefined, emptyMessage: string, expanded: boolean, theme: LectorTheme): string {
|
|
45
|
+
if (!locations || locations.length === 0) return theme.fg("dim", emptyMessage);
|
|
46
|
+
|
|
47
|
+
const displayCount = expanded ? locations.length : Math.min(locations.length, DEFAULT_VISIBLE_LOCATIONS);
|
|
48
|
+
const lines = [theme.fg("muted", `${locations.length} location${locations.length === 1 ? "" : "s"}:`)];
|
|
49
|
+
for (const location of locations.slice(0, displayCount)) {
|
|
50
|
+
lines.push(` ${formatLocation(theme, location.path, location.line, location.character)}`);
|
|
51
|
+
}
|
|
52
|
+
const remaining = locations.length - displayCount;
|
|
53
|
+
if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function formatGoToDefinitionCall(args: { path?: unknown; line?: unknown; character?: unknown }, theme: LectorTheme): string {
|
|
58
|
+
return formatPositionalCall("go_to_definition", args, theme);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatGoToDefinitionResult(locations: readonly WorkspaceLocation[] | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
62
|
+
return formatLocationList(locations, "No definition found.", expanded, theme);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function formatGoToImplementationCall(args: { path?: unknown; line?: unknown; character?: unknown }, theme: LectorTheme): string {
|
|
66
|
+
return formatPositionalCall("go_to_implementation", args, theme);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatGoToImplementationResult(locations: readonly WorkspaceLocation[] | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
70
|
+
return formatLocationList(locations, "No implementation found.", expanded, theme);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatFindReferencesCall(args: { path?: unknown; line?: unknown; character?: unknown }, theme: LectorTheme): string {
|
|
74
|
+
return formatPositionalCall("find_references", args, theme);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatFindReferencesResult(locations: readonly WorkspaceLocation[] | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
78
|
+
return formatLocationList(locations, "No references found.", expanded, theme);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatHoverCall(args: { path?: unknown; line?: unknown; character?: unknown }, theme: LectorTheme): string {
|
|
82
|
+
return formatPositionalCall("hover", args, theme);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const HOVER_COLLAPSED_LINE_COUNT = 6;
|
|
86
|
+
|
|
87
|
+
export function formatHoverResult(hover: Hover | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
88
|
+
if (!hover) return theme.fg("dim", "No hover information available.");
|
|
89
|
+
const lines = hover.contents.split("\n");
|
|
90
|
+
if (expanded || lines.length <= HOVER_COLLAPSED_LINE_COUNT) return hover.contents;
|
|
91
|
+
const remaining = lines.length - HOVER_COLLAPSED_LINE_COUNT;
|
|
92
|
+
return `${lines.slice(0, HOVER_COLLAPSED_LINE_COUNT).join("\n")}\n${theme.fg("dim", `... ${remaining} more line${remaining === 1 ? "" : "s"} (${keyHint("app.tools.expand", "to expand")})`)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function formatDocumentSymbolsCall(args: { path?: unknown }, theme: LectorTheme): string {
|
|
96
|
+
const path = typeof args.path === "string" ? args.path : "";
|
|
97
|
+
return `${theme.fg("toolTitle", theme.bold("document_symbols"))} ${theme.fg("accent", path)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Flattens a hierarchical DocumentSymbolEntry[] into (depth, entry) pairs, depth-first, for bounded rendering. */
|
|
101
|
+
function flattenSymbols(entries: readonly DocumentSymbolEntry[], depth = 0): Array<{ depth: number; entry: DocumentSymbolEntry }> {
|
|
102
|
+
const flattened: Array<{ depth: number; entry: DocumentSymbolEntry }> = [];
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
flattened.push({ depth, entry });
|
|
105
|
+
if (entry.children) flattened.push(...flattenSymbols(entry.children, depth + 1));
|
|
106
|
+
}
|
|
107
|
+
return flattened;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatDocumentSymbolsResult(symbols: readonly DocumentSymbolEntry[] | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
111
|
+
if (!symbols || symbols.length === 0) return theme.fg("dim", "No symbols found.");
|
|
112
|
+
|
|
113
|
+
const flattened = flattenSymbols(symbols);
|
|
114
|
+
const kindColumnWidth = Math.max(...flattened.map(({ entry }) => entry.kind.length));
|
|
115
|
+
const displayCount = expanded ? flattened.length : Math.min(flattened.length, DEFAULT_VISIBLE_SYMBOLS);
|
|
116
|
+
const lines = [theme.fg("muted", `${flattened.length} symbol${flattened.length === 1 ? "" : "s"}:`)];
|
|
117
|
+
|
|
118
|
+
for (const { depth, entry } of flattened.slice(0, displayCount)) {
|
|
119
|
+
const indent = " ".repeat(depth + 1);
|
|
120
|
+
const kind = theme.fg(colorForKind(entry.kind), entry.kind.padEnd(kindColumnWidth));
|
|
121
|
+
const name = theme.fg("text", theme.bold(entry.name));
|
|
122
|
+
const location = formatLocation(theme, entry.range.path, entry.selectionRange.start.line, entry.selectionRange.start.character);
|
|
123
|
+
lines.push(`${indent}${kind} ${name} ${location}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const remaining = flattened.length - displayCount;
|
|
127
|
+
if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
|
|
128
|
+
return lines.join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function formatDiagnosticsCall(args: { path?: unknown }, theme: LectorTheme): string {
|
|
132
|
+
const path = typeof args.path === "string" ? args.path : "";
|
|
133
|
+
return `${theme.fg("toolTitle", theme.bold("diagnostics"))} ${theme.fg("accent", path)}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function formatDiagnosticsResult(diagnostics: readonly Diagnostic[] | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
137
|
+
if (!diagnostics || diagnostics.length === 0) return theme.fg("success", "No diagnostics.");
|
|
138
|
+
|
|
139
|
+
const displayCount = expanded ? diagnostics.length : Math.min(diagnostics.length, DEFAULT_VISIBLE_DIAGNOSTICS);
|
|
140
|
+
const lines = [theme.fg("muted", `${diagnostics.length} diagnostic${diagnostics.length === 1 ? "" : "s"}:`)];
|
|
141
|
+
|
|
142
|
+
for (const diagnostic of diagnostics.slice(0, displayCount)) {
|
|
143
|
+
const severity = theme.fg(DIAGNOSTIC_SEVERITY_COLOR[diagnostic.severity] ?? "muted", theme.bold(diagnostic.severity));
|
|
144
|
+
const location = formatLocation(theme, diagnostic.range.path, diagnostic.range.start.line, diagnostic.range.start.character);
|
|
145
|
+
const origin = diagnostic.source ? theme.fg("dim", ` (${diagnostic.source}${diagnostic.code !== undefined ? ` ${diagnostic.code}` : ""})`) : "";
|
|
146
|
+
lines.push(` ${severity} ${location} -- ${diagnostic.message}${origin}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const remaining = diagnostics.length - displayCount;
|
|
150
|
+
if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function formatCallHierarchyEntry(entry: { kind: string; name: string; location: WorkspaceLocation }, theme: LectorTheme): string {
|
|
155
|
+
const kind = theme.fg(colorForKind(entry.kind), entry.kind);
|
|
156
|
+
const name = theme.fg("text", theme.bold(entry.name));
|
|
157
|
+
const location = formatLocation(theme, entry.location.path, entry.location.line, entry.location.character);
|
|
158
|
+
return `${kind} ${name} -- ${location}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function formatPrepareCallHierarchyCall(args: { path?: unknown; line?: unknown; character?: unknown }, theme: LectorTheme): string {
|
|
162
|
+
return formatPositionalCall("prepare_call_hierarchy", args, theme);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function formatPrepareCallHierarchyResult(items: readonly CallHierarchyEntry[] | undefined, theme: LectorTheme): string {
|
|
166
|
+
if (!items || items.length === 0) return theme.fg("dim", "No call-hierarchy root at this position.");
|
|
167
|
+
return items.map((item) => formatCallHierarchyEntry(item, theme)).join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function formatIncomingCallsCall(args: { path?: unknown; line?: unknown; character?: unknown }, theme: LectorTheme): string {
|
|
171
|
+
return formatPositionalCall("incoming_calls", args, theme);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function formatIncomingCallsResult(calls: readonly IncomingCall[] | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
175
|
+
if (!calls || calls.length === 0) return theme.fg("dim", "No incoming calls found.");
|
|
176
|
+
|
|
177
|
+
const displayCount = expanded ? calls.length : Math.min(calls.length, DEFAULT_VISIBLE_CALLS);
|
|
178
|
+
const lines = [theme.fg("muted", `${calls.length} caller${calls.length === 1 ? "" : "s"}:`)];
|
|
179
|
+
for (const call of calls.slice(0, displayCount)) lines.push(` ${formatCallHierarchyEntry(call.from, theme)}`);
|
|
180
|
+
|
|
181
|
+
const remaining = calls.length - displayCount;
|
|
182
|
+
if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
|
|
183
|
+
return lines.join("\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function formatOutgoingCallsCall(args: { path?: unknown; line?: unknown; character?: unknown }, theme: LectorTheme): string {
|
|
187
|
+
return formatPositionalCall("outgoing_calls", args, theme);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function formatOutgoingCallsResult(calls: readonly OutgoingCall[] | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
191
|
+
if (!calls || calls.length === 0) return theme.fg("dim", "No outgoing calls found.");
|
|
192
|
+
|
|
193
|
+
const displayCount = expanded ? calls.length : Math.min(calls.length, DEFAULT_VISIBLE_CALLS);
|
|
194
|
+
const lines = [theme.fg("muted", `${calls.length} callee${calls.length === 1 ? "" : "s"}:`)];
|
|
195
|
+
for (const call of calls.slice(0, displayCount)) lines.push(` ${formatCallHierarchyEntry(call.to, theme)}`);
|
|
196
|
+
|
|
197
|
+
const remaining = calls.length - displayCount;
|
|
198
|
+
if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
|
|
199
|
+
return lines.join("\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function formatPopulateSymbolGraphCall(args: { path?: unknown; maxFiles?: unknown; maxSymbolsPerFile?: unknown }, theme: LectorTheme): string {
|
|
203
|
+
const path = typeof args.path === "string" ? args.path : "";
|
|
204
|
+
return `${theme.fg("toolTitle", theme.bold("populate_symbol_graph"))} ${theme.fg("accent", path)}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function describePopulateSymbolGraphJob(job: JobSnapshot<PopulateSymbolGraphResult>): string {
|
|
208
|
+
if (job.status === "queued") return `Source workspace is registered; symbol graph is queued and still loading (job ${job.id}). Poll job_status.`;
|
|
209
|
+
if (job.status === "running") return `Source workspace is registered; symbol graph is still loading (job ${job.id}). Poll job_status.`;
|
|
210
|
+
if (job.status === "failed") return `Job ${job.id} failed [${job.error.code}] -- ${job.error.message}`;
|
|
211
|
+
const result = job.result;
|
|
212
|
+
return `Job ${job.id} cached ${result.filesProcessed} file${result.filesProcessed === 1 ? "" : "s"}, ${result.symbolsProcessed} symbol${result.symbolsProcessed === 1 ? "" : "s"}, ${result.nodesAdded} node${result.nodesAdded === 1 ? "" : "s"}, ${result.edgesAdded} edge${result.edgesAdded === 1 ? "" : "s"}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function formatPopulateSymbolGraphResult(job: JobSnapshot<PopulateSymbolGraphResult> | undefined, theme: LectorTheme): string {
|
|
216
|
+
if (!job) return theme.fg("dim", "No job result.");
|
|
217
|
+
const color = job.status === "failed" ? "error" : job.status === "succeeded" ? "muted" : "warning";
|
|
218
|
+
return theme.fg(color, describePopulateSymbolGraphJob(job));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function formatReachableFromCall(args: { path?: unknown; line?: unknown; character?: unknown; maxDepth?: unknown }, theme: LectorTheme): string {
|
|
222
|
+
const base = formatPositionalCall("reachable_from", args, theme);
|
|
223
|
+
const maxDepth = typeof args.maxDepth === "number" ? args.maxDepth : "?";
|
|
224
|
+
return `${base} ${theme.fg("dim", `(depth ${maxDepth})`)}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function formatReachableFromResult(symbols: readonly SymbolNode[] | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
228
|
+
if (!symbols || symbols.length === 0) return theme.fg("dim", "Nothing reachable at this position (has the graph been populated for this workspace?).");
|
|
229
|
+
|
|
230
|
+
const displayCount = expanded ? symbols.length : Math.min(symbols.length, DEFAULT_VISIBLE_CALLS);
|
|
231
|
+
const lines = [theme.fg("muted", `${symbols.length} reachable symbol${symbols.length === 1 ? "" : "s"}:`)];
|
|
232
|
+
for (const symbol of symbols.slice(0, displayCount)) lines.push(` ${formatCallHierarchyEntry(symbol, theme)}`);
|
|
233
|
+
|
|
234
|
+
const remaining = symbols.length - displayCount;
|
|
235
|
+
if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
|
|
236
|
+
return lines.join("\n");
|
|
237
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { TextSearchResult, WorkspaceQueryOutcome, WorkspaceSymbol } from "@danypops/lector";
|
|
2
|
+
import { lectorClient, workspaceForDirectory } from "./lector-client.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fans out across explicitly-named directories only -- never the daemon's own "every registered
|
|
6
|
+
* workspace" default. Lector's daemon is a shared, system-wide service: leaving workspaceIds
|
|
7
|
+
* unset would search every project any other concurrent Pi session has ever registered against
|
|
8
|
+
* it, not just this session's own (confirmed live, not assumed -- a real fetched jittor workspace
|
|
9
|
+
* from an unrelated session showed up in an early test of this exact feature). `directories` is
|
|
10
|
+
* required, same "no implicit fallback" convention as find_symbols/search_code.
|
|
11
|
+
*/
|
|
12
|
+
export interface CrossWorkspaceSearchOperations {
|
|
13
|
+
findSymbols(
|
|
14
|
+
query: string,
|
|
15
|
+
directories: readonly string[],
|
|
16
|
+
timeoutMs?: number,
|
|
17
|
+
): Promise<readonly WorkspaceQueryOutcome<{ symbols: readonly WorkspaceSymbol[] }>[]>;
|
|
18
|
+
searchText(
|
|
19
|
+
query: string,
|
|
20
|
+
directories: readonly string[],
|
|
21
|
+
maxMatches: number,
|
|
22
|
+
maxBytes: number,
|
|
23
|
+
timeoutMs?: number,
|
|
24
|
+
): Promise<readonly WorkspaceQueryOutcome<TextSearchResult>[]>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function resolveWorkspaceIds(directories: readonly string[]): Promise<readonly string[]> {
|
|
28
|
+
const resolved = await Promise.all(directories.map((directory) => workspaceForDirectory(directory)));
|
|
29
|
+
return resolved.map((r) => r.workspaceId);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createLectorCrossWorkspaceSearchOperations(): CrossWorkspaceSearchOperations {
|
|
33
|
+
return {
|
|
34
|
+
async findSymbols(query, directories, timeoutMs) {
|
|
35
|
+
const workspaceIds = await resolveWorkspaceIds(directories);
|
|
36
|
+
const client = await lectorClient();
|
|
37
|
+
const { results } = await client.call("search.symbols", { query, workspaceIds, timeoutMs });
|
|
38
|
+
return results;
|
|
39
|
+
},
|
|
40
|
+
async searchText(query, directories, maxMatches, maxBytes, timeoutMs) {
|
|
41
|
+
const workspaceIds = await resolveWorkspaceIds(directories);
|
|
42
|
+
const client = await lectorClient();
|
|
43
|
+
const { results } = await client.call("search.text", { query, maxMatches, maxBytes, workspaceIds, timeoutMs });
|
|
44
|
+
return results;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { TextSearchResult, WorkspaceQueryOutcome, WorkspaceSymbol } 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_PER_WORKSPACE = 10;
|
|
6
|
+
|
|
7
|
+
export function formatCrossWorkspaceCall(args: { directories?: unknown; query?: unknown }, theme: LectorTheme): string {
|
|
8
|
+
const directories = Array.isArray(args.directories) ? args.directories.filter((d): d is string => typeof d === "string") : [];
|
|
9
|
+
const query = typeof args.query === "string" ? args.query : "";
|
|
10
|
+
return `${theme.fg("accent", `"${query}"`)} ${theme.fg("dim", `across ${directories.length} project(s)`)}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatOutcomeHeader(outcome: WorkspaceQueryOutcome<unknown>, theme: LectorTheme): string {
|
|
14
|
+
if (outcome.status === "ready") return theme.fg("accent", outcome.workspaceId);
|
|
15
|
+
if (outcome.status === "loading") return `${theme.fg("warning", outcome.workspaceId)} ${theme.fg("warning", `-- still loading: ${outcome.message}`)}`;
|
|
16
|
+
return `${theme.fg("error", outcome.workspaceId)} ${theme.fg("error", `-- ${outcome.message}`)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatFindSymbolsAcrossProjectsResult(
|
|
20
|
+
results: readonly WorkspaceQueryOutcome<{ symbols: readonly WorkspaceSymbol[] }>[] | undefined,
|
|
21
|
+
expanded: boolean,
|
|
22
|
+
theme: LectorTheme,
|
|
23
|
+
): string {
|
|
24
|
+
if (!results || results.length === 0) return theme.fg("dim", "No projects to search.");
|
|
25
|
+
const lines: string[] = [];
|
|
26
|
+
for (const outcome of results) {
|
|
27
|
+
lines.push(formatOutcomeHeader(outcome, theme));
|
|
28
|
+
if (outcome.status !== "ready") continue;
|
|
29
|
+
if (outcome.result.symbols.length === 0) {
|
|
30
|
+
lines.push(theme.fg("dim", " no symbols matched"));
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const displayCount = expanded ? outcome.result.symbols.length : Math.min(DEFAULT_VISIBLE_PER_WORKSPACE, outcome.result.symbols.length);
|
|
34
|
+
for (const symbol of outcome.result.symbols.slice(0, displayCount)) {
|
|
35
|
+
lines.push(` ${symbol.kind} ${symbol.name} -- ${symbol.location.path}:${symbol.location.line}:${symbol.location.character}`);
|
|
36
|
+
}
|
|
37
|
+
const remaining = outcome.result.symbols.length - displayCount;
|
|
38
|
+
if (remaining > 0) lines.push(theme.fg("dim", ` ... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
|
|
39
|
+
}
|
|
40
|
+
return lines.join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatSearchTextAcrossProjectsResult(
|
|
44
|
+
results: readonly WorkspaceQueryOutcome<TextSearchResult>[] | undefined,
|
|
45
|
+
expanded: boolean,
|
|
46
|
+
theme: LectorTheme,
|
|
47
|
+
): string {
|
|
48
|
+
if (!results || results.length === 0) return theme.fg("dim", "No projects to search.");
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
for (const outcome of results) {
|
|
51
|
+
lines.push(formatOutcomeHeader(outcome, theme));
|
|
52
|
+
if (outcome.status !== "ready") continue;
|
|
53
|
+
if (outcome.result.matches.length === 0) {
|
|
54
|
+
lines.push(theme.fg("dim", " no matches"));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const displayCount = expanded ? outcome.result.matches.length : Math.min(DEFAULT_VISIBLE_PER_WORKSPACE, outcome.result.matches.length);
|
|
58
|
+
for (const match of outcome.result.matches.slice(0, displayCount)) {
|
|
59
|
+
lines.push(` ${match.path}:${match.lineNumber}: ${match.line.replace(/\n$/, "")}`);
|
|
60
|
+
}
|
|
61
|
+
const remaining = outcome.result.matches.length - displayCount;
|
|
62
|
+
if (remaining > 0) lines.push(theme.fg("dim", ` ... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
|
|
63
|
+
if (outcome.result.truncated) lines.push(theme.fg("warning", " (this workspace's search was itself truncated by maxMatches/maxBytes)"));
|
|
64
|
+
}
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|