@danypops/pi-lector 0.12.14 → 0.13.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/extension/src/bounded-poll.ts +19 -0
- package/extension/src/git/operations.ts +65 -0
- package/extension/src/git/rendering.ts +75 -2
- package/extension/src/index.ts +63 -7
- package/extension/src/workspace-cache/caching-overlay.ts +124 -0
- package/extension/src/workspace-cache/caching-widget.ts +73 -0
- package/package.json +3 -3
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotent start/stop wrapper over setInterval -- a second start() is a no-op rather than a
|
|
3
|
+
* competing timer, and stop() is safe to call even if never started. Same shape as pi-papyrus's,
|
|
4
|
+
* pi-pipes', pi-packed's, and pi-tickets' own BoundedPoll.
|
|
5
|
+
*/
|
|
6
|
+
export class BoundedPoll {
|
|
7
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
8
|
+
|
|
9
|
+
start(intervalMs: number, tick: () => void): void {
|
|
10
|
+
if (this.timer) return;
|
|
11
|
+
this.timer = setInterval(tick, intervalMs);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
stop(): void {
|
|
15
|
+
if (!this.timer) return;
|
|
16
|
+
clearInterval(this.timer);
|
|
17
|
+
this.timer = undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -5,6 +5,8 @@ import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle
|
|
|
5
5
|
type SymbolComparison = OperationOutputs["workspace.compareSymbolAcrossVersions"];
|
|
6
6
|
type GitWorktreeAddResult = OperationOutputs["workspace.gitWorktreeAdd"];
|
|
7
7
|
type GitWorktreeRemoveResult = OperationOutputs["workspace.gitWorktreeRemove"];
|
|
8
|
+
type GitGrepResult = OperationOutputs["workspace.gitGrep"];
|
|
9
|
+
type GitListFilesResult = OperationOutputs["workspace.gitListFiles"];
|
|
8
10
|
|
|
9
11
|
/** Matches GIT_READ_PERMISSIONS' own declared value server-side (git/operation-registration.ts). */
|
|
10
12
|
const GIT_READ_PERMISSIONS = ["workspace:read"];
|
|
@@ -28,6 +30,22 @@ export interface GitOperations {
|
|
|
28
30
|
log(directory: string, maxCount: number, call: LectorVehicleCall): Promise<readonly GitLogEntry[]>;
|
|
29
31
|
diff(directory: string, ref: string | undefined, maxBytes: number, call: LectorVehicleCall): Promise<GitDiffResult>;
|
|
30
32
|
compareSymbol(directory: string, path: string, symbolName: string, fromRef: string, toRef: string | undefined, maxBytes: number): Promise<SymbolComparison>;
|
|
33
|
+
/** A path's exact blob content at `ref`, without checking anything out -- Tier 1's own showFile, undefined content meaning the path did not exist there. */
|
|
34
|
+
showFile(directory: string, ref: string, path: string, call: LectorVehicleCall): Promise<string | undefined>;
|
|
35
|
+
/** Text search across `ref`'s own tree, no checkout -- the ref-scoped equivalent of search_code. pathspecs narrows the search (glob-based, e.g. "*.go"). */
|
|
36
|
+
grep(
|
|
37
|
+
directory: string,
|
|
38
|
+
ref: string,
|
|
39
|
+
pattern: string,
|
|
40
|
+
pathspecs: readonly string[] | undefined,
|
|
41
|
+
maxMatches: number,
|
|
42
|
+
maxBytes: number,
|
|
43
|
+
call: LectorVehicleCall,
|
|
44
|
+
): Promise<GitGrepResult>;
|
|
45
|
+
/** Every file path in `ref`'s own tree, no checkout -- pathspecs narrows the listing (prefix-based, not glob-based like grep's). */
|
|
46
|
+
listFiles(directory: string, ref: string, pathspecs: readonly string[] | undefined, maxResults: number, call: LectorVehicleCall): Promise<GitListFilesResult>;
|
|
47
|
+
/** True iff ancestorRef is a real ancestor of (or the exact same commit as) ref -- the backport/reachability check "was this fix ported to this branch" actually needs. */
|
|
48
|
+
isAncestor(directory: string, ancestorRef: string, ref: string, call: LectorVehicleCall): Promise<boolean>;
|
|
31
49
|
/**
|
|
32
50
|
* Materializes a real, disposable, read-only project at `ref` via a detached git worktree.
|
|
33
51
|
* The returned `path` is a real directory every other pi-lector tool already accepts as its
|
|
@@ -95,5 +113,52 @@ export function createLectorGitOperations(): GitOperations {
|
|
|
95
113
|
invokeLectorVehicleOperation<GitWorktreeRemoveResult>("workspace.gitWorktreeRemove", { workspaceId }, GIT_WORKTREE_WRITE_PERMISSIONS, call),
|
|
96
114
|
);
|
|
97
115
|
},
|
|
116
|
+
async showFile(directory, ref, path, call) {
|
|
117
|
+
return withWorkspace(
|
|
118
|
+
() => workspaceForDirectory(directory),
|
|
119
|
+
async ({ workspaceId }) => {
|
|
120
|
+
const { content } = await invokeLectorVehicleOperation<{ content: string | undefined }>(
|
|
121
|
+
"workspace.gitShowFile",
|
|
122
|
+
{ workspaceId, ref, path },
|
|
123
|
+
GIT_READ_PERMISSIONS,
|
|
124
|
+
call,
|
|
125
|
+
);
|
|
126
|
+
return content;
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
async grep(directory, ref, pattern, pathspecs, maxMatches, maxBytes, call) {
|
|
131
|
+
return withWorkspace(
|
|
132
|
+
() => workspaceForDirectory(directory),
|
|
133
|
+
({ workspaceId }) =>
|
|
134
|
+
invokeLectorVehicleOperation<GitGrepResult>(
|
|
135
|
+
"workspace.gitGrep",
|
|
136
|
+
{ workspaceId, ref, pattern, pathspecs, maxMatches, maxBytes },
|
|
137
|
+
GIT_READ_PERMISSIONS,
|
|
138
|
+
call,
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
async listFiles(directory, ref, pathspecs, maxResults, call) {
|
|
143
|
+
return withWorkspace(
|
|
144
|
+
() => workspaceForDirectory(directory),
|
|
145
|
+
({ workspaceId }) =>
|
|
146
|
+
invokeLectorVehicleOperation<GitListFilesResult>("workspace.gitListFiles", { workspaceId, ref, pathspecs, maxResults }, GIT_READ_PERMISSIONS, call),
|
|
147
|
+
);
|
|
148
|
+
},
|
|
149
|
+
async isAncestor(directory, ancestorRef, ref, call) {
|
|
150
|
+
return withWorkspace(
|
|
151
|
+
() => workspaceForDirectory(directory),
|
|
152
|
+
async ({ workspaceId }) => {
|
|
153
|
+
const { isAncestor } = await invokeLectorVehicleOperation<{ isAncestor: boolean }>(
|
|
154
|
+
"workspace.gitIsAncestor",
|
|
155
|
+
{ workspaceId, ancestorRef, ref },
|
|
156
|
+
GIT_READ_PERMISSIONS,
|
|
157
|
+
call,
|
|
158
|
+
);
|
|
159
|
+
return isAncestor;
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
},
|
|
98
163
|
};
|
|
99
164
|
}
|
|
@@ -11,11 +11,13 @@ const DEFAULT_VISIBLE_FILES = 20;
|
|
|
11
11
|
const DEFAULT_VISIBLE_COMMITS = 10;
|
|
12
12
|
const DEFAULT_VISIBLE_DIFF_LINES = 60;
|
|
13
13
|
|
|
14
|
-
export type GitAction = "status" | "log" | "diff" | "compare-symbol" | "worktree-add" | "worktree-remove";
|
|
14
|
+
export type GitAction = "status" | "log" | "diff" | "compare-symbol" | "worktree-add" | "worktree-remove" | "show" | "grep-ref" | "ls-ref" | "is-ancestor";
|
|
15
15
|
|
|
16
16
|
type SymbolComparison = OperationOutputs["workspace.compareSymbolAcrossVersions"];
|
|
17
17
|
type GitWorktreeAddResult = OperationOutputs["workspace.gitWorktreeAdd"];
|
|
18
18
|
type GitWorktreeRemoveResult = OperationOutputs["workspace.gitWorktreeRemove"];
|
|
19
|
+
type GitGrepResult = OperationOutputs["workspace.gitGrep"];
|
|
20
|
+
type GitListFilesResult = OperationOutputs["workspace.gitListFiles"];
|
|
19
21
|
|
|
20
22
|
export interface GitToolDetails {
|
|
21
23
|
readonly action: GitAction;
|
|
@@ -25,10 +27,24 @@ export interface GitToolDetails {
|
|
|
25
27
|
readonly comparison?: SymbolComparison;
|
|
26
28
|
readonly worktreeAdd?: GitWorktreeAddResult;
|
|
27
29
|
readonly worktreeRemove?: GitWorktreeRemoveResult;
|
|
30
|
+
readonly showFile?: { readonly ref: string; readonly path: string; readonly content: string | undefined };
|
|
31
|
+
readonly grep?: GitGrepResult;
|
|
32
|
+
readonly listFiles?: GitListFilesResult;
|
|
33
|
+
readonly isAncestor?: { readonly ancestorRef: string; readonly ref: string; readonly result: boolean };
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export function formatGitCall(
|
|
31
|
-
args: {
|
|
37
|
+
args: {
|
|
38
|
+
action?: unknown;
|
|
39
|
+
directory?: unknown;
|
|
40
|
+
ref?: unknown;
|
|
41
|
+
path?: unknown;
|
|
42
|
+
symbol?: unknown;
|
|
43
|
+
fromRef?: unknown;
|
|
44
|
+
toRef?: unknown;
|
|
45
|
+
pattern?: unknown;
|
|
46
|
+
ancestorRef?: unknown;
|
|
47
|
+
},
|
|
32
48
|
theme: LectorTheme,
|
|
33
49
|
): string {
|
|
34
50
|
const action = typeof args.action === "string" ? args.action : "";
|
|
@@ -40,6 +56,21 @@ export function formatGitCall(
|
|
|
40
56
|
const toRef = typeof args.toRef === "string" ? ` -> ${args.toRef}` : "";
|
|
41
57
|
return `${theme.fg("toolTitle", theme.bold("git"))} ${theme.fg("muted", action)} ${theme.fg("accent", `${directory}/${path}`)} (${symbol}) ${fromRef}${toRef}`;
|
|
42
58
|
}
|
|
59
|
+
if (action === "is-ancestor") {
|
|
60
|
+
const ancestorRef = typeof args.ancestorRef === "string" ? args.ancestorRef : "";
|
|
61
|
+
const ref = typeof args.ref === "string" ? args.ref : "";
|
|
62
|
+
return `${theme.fg("toolTitle", theme.bold("git"))} ${theme.fg("muted", action)} ${theme.fg("accent", directory)} ${ancestorRef} -> ${ref}`;
|
|
63
|
+
}
|
|
64
|
+
if (action === "grep-ref") {
|
|
65
|
+
const ref = typeof args.ref === "string" ? args.ref : "";
|
|
66
|
+
const pattern = typeof args.pattern === "string" ? args.pattern : "";
|
|
67
|
+
return `${theme.fg("toolTitle", theme.bold("git"))} ${theme.fg("muted", action)} ${theme.fg("accent", directory)} ${ref} "${pattern}"`;
|
|
68
|
+
}
|
|
69
|
+
if (action === "show") {
|
|
70
|
+
const ref = typeof args.ref === "string" ? args.ref : "";
|
|
71
|
+
const path = typeof args.path === "string" ? args.path : "";
|
|
72
|
+
return `${theme.fg("toolTitle", theme.bold("git"))} ${theme.fg("muted", action)} ${theme.fg("accent", `${directory}/${path}`)} @ ${ref}`;
|
|
73
|
+
}
|
|
43
74
|
const ref = typeof args.ref === "string" ? ` ${args.ref}` : "";
|
|
44
75
|
return `${theme.fg("toolTitle", theme.bold("git"))} ${theme.fg("muted", action)} ${theme.fg("accent", directory)}${ref}`;
|
|
45
76
|
}
|
|
@@ -55,6 +86,44 @@ function formatGitWorktreeRemoveResult(result: GitWorktreeRemoveResult | undefin
|
|
|
55
86
|
return theme.fg("accent", "worktree removed");
|
|
56
87
|
}
|
|
57
88
|
|
|
89
|
+
function formatGitShowFileResult(details: GitToolDetails["showFile"], theme: LectorTheme): string {
|
|
90
|
+
if (!details) return theme.fg("dim", "No result.");
|
|
91
|
+
if (details.content === undefined) return theme.fg("dim", `"${details.path}" does not exist at ${details.ref}`);
|
|
92
|
+
return details.content;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatGitGrepResult(result: GitGrepResult | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
96
|
+
if (!result || result.matches.length === 0) return theme.fg("dim", "No matches.");
|
|
97
|
+
const lines = renderTruncatedList({
|
|
98
|
+
items: result.matches,
|
|
99
|
+
expanded,
|
|
100
|
+
visibleCount: DEFAULT_VISIBLE_FILES,
|
|
101
|
+
formatItem: (match) => `${theme.fg("accent", `${match.path}:${match.line}`)}:${match.text}`,
|
|
102
|
+
moreLine: moreLine(theme),
|
|
103
|
+
truncationWarning: result.truncated ? theme.fg("warning", "(also bounded by maxMatches/maxBytes)") : undefined,
|
|
104
|
+
});
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatGitListFilesResult(result: GitListFilesResult | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
109
|
+
if (!result || result.paths.length === 0) return theme.fg("dim", "No files.");
|
|
110
|
+
const lines = renderTruncatedList({
|
|
111
|
+
items: result.paths,
|
|
112
|
+
expanded,
|
|
113
|
+
visibleCount: DEFAULT_VISIBLE_FILES,
|
|
114
|
+
formatItem: (path) => path,
|
|
115
|
+
moreLine: moreLine(theme),
|
|
116
|
+
truncationWarning: result.truncated ? theme.fg("warning", "(bounded by maxResults)") : undefined,
|
|
117
|
+
});
|
|
118
|
+
return lines.join("\n");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function formatGitIsAncestorResult(details: GitToolDetails["isAncestor"], theme: LectorTheme): string {
|
|
122
|
+
if (!details) return theme.fg("dim", "No result.");
|
|
123
|
+
const verb = details.result ? "is" : "is not";
|
|
124
|
+
return theme.fg("accent", `${details.ancestorRef} ${verb} an ancestor of ${details.ref}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
58
127
|
export function formatGitResult(details: GitToolDetails | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
59
128
|
if (!details) return theme.fg("dim", "No result.");
|
|
60
129
|
if (details.action === "status") return formatGitStatusResult(details.summary, expanded, theme);
|
|
@@ -62,6 +131,10 @@ export function formatGitResult(details: GitToolDetails | undefined, expanded: b
|
|
|
62
131
|
if (details.action === "compare-symbol") return formatCompareSymbolResult(details.comparison, expanded, theme);
|
|
63
132
|
if (details.action === "worktree-add") return formatGitWorktreeAddResult(details.worktreeAdd, theme);
|
|
64
133
|
if (details.action === "worktree-remove") return formatGitWorktreeRemoveResult(details.worktreeRemove, theme);
|
|
134
|
+
if (details.action === "show") return formatGitShowFileResult(details.showFile, theme);
|
|
135
|
+
if (details.action === "grep-ref") return formatGitGrepResult(details.grep, expanded, theme);
|
|
136
|
+
if (details.action === "ls-ref") return formatGitListFilesResult(details.listFiles, expanded, theme);
|
|
137
|
+
if (details.action === "is-ancestor") return formatGitIsAncestorResult(details.isAncestor, theme);
|
|
65
138
|
return formatGitDiffResult(details.result, expanded, theme);
|
|
66
139
|
}
|
|
67
140
|
|
package/extension/src/index.ts
CHANGED
|
@@ -129,6 +129,7 @@ import { formatSearchCall, formatSearchResult } from "./search/rendering.ts";
|
|
|
129
129
|
import { type AnnotationAnchorInput, createLectorSymbolAnnotationOperations } from "./symbol-annotation/operations.ts";
|
|
130
130
|
import { formatAnnotationDetail, formatAnnotationListSummary, formatAnnotationSummary } from "./symbol-annotation/rendering.ts";
|
|
131
131
|
import type { LectorVehicleCall } from "./vehicle-client.ts";
|
|
132
|
+
import { CachingOverlay } from "./workspace-cache/caching-overlay.ts";
|
|
132
133
|
import {
|
|
133
134
|
type CachePresentationState,
|
|
134
135
|
cacheContextMessage,
|
|
@@ -278,6 +279,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
278
279
|
};
|
|
279
280
|
});
|
|
280
281
|
|
|
282
|
+
let cachingOverlay: CachingOverlay | undefined;
|
|
281
283
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
282
284
|
sessionGeneration++;
|
|
283
285
|
cacheStatesByRoot.clear();
|
|
@@ -285,6 +287,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
285
287
|
lastInjectedSummary = undefined;
|
|
286
288
|
uiContext = undefined;
|
|
287
289
|
ctx.ui.setStatus("lector-cache", undefined);
|
|
290
|
+
cachingOverlay?.dispose();
|
|
288
291
|
});
|
|
289
292
|
|
|
290
293
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -295,6 +298,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
295
298
|
lastInjectedSummary = undefined;
|
|
296
299
|
uiContext = ctx;
|
|
297
300
|
setNewWorkspaceObserver((root) => startMonitoringRoot(root, ctx));
|
|
301
|
+
if (ctx.hasUI) {
|
|
302
|
+
// The persistent widget counterpart to the single-line "lector-cache" status above --
|
|
303
|
+
// enumerates EVERY workspace currently caching, not just this session's own cwd root.
|
|
304
|
+
cachingOverlay ??= new CachingOverlay();
|
|
305
|
+
cachingOverlay.setUI(ctx.ui);
|
|
306
|
+
void cachingOverlay.refresh();
|
|
307
|
+
cachingOverlay.startPolling();
|
|
308
|
+
}
|
|
298
309
|
const thisGeneration = sessionGeneration;
|
|
299
310
|
void nearestGitWorkspaceRoot(cwd)
|
|
300
311
|
.then((projectRoot) => {
|
|
@@ -1246,25 +1257,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
1246
1257
|
name: "git",
|
|
1247
1258
|
label: "Git",
|
|
1248
1259
|
description:
|
|
1249
|
-
"Working tree status, recent commit log, unified diff, one symbol's own declaration diff across two versions, and a real disposable checkout at another ref, for a real git repository, in one tool. Fails clearly if `directory` is not inside a git repository. ACTIONS: status (working tree state, ahead/behind tracking), log (recent commits, bounded by maxCount), diff (unified diff against `ref`, defaulting to HEAD, bounded by maxBytes), compare-symbol (a named symbol's own declaration text diffed between fromRef and toRef, or fromRef and the current working tree when toRef is omitted -- tree-sitter syntactic tier only, TypeScript/JavaScript files only, no project-aware cross-reference resolution), worktree-add (materializes `ref` as a real, read-only project via a detached git worktree and returns its own `directory` -- pass that straight to find_symbols/search_code/this tool itself for full semantic queries against another branch/commit, not just text), worktree-remove (releases and deletes a worktree-add-created checkout -- `directory` is that checkout's own returned directory, not the source repo's).",
|
|
1250
|
-
promptSnippet:
|
|
1260
|
+
"Working tree status, recent commit log, unified diff, one symbol's own declaration diff across two versions, ref-scoped blob/text/ancestry queries with no checkout, and a real disposable checkout at another ref, for a real git repository, in one tool. Fails clearly if `directory` is not inside a git repository. ACTIONS: status (working tree state, ahead/behind tracking), log (recent commits, bounded by maxCount), diff (unified diff against `ref`, defaulting to HEAD, bounded by maxBytes), compare-symbol (a named symbol's own declaration text diffed between fromRef and toRef, or fromRef and the current working tree when toRef is omitted -- tree-sitter syntactic tier only, TypeScript/JavaScript files only, no project-aware cross-reference resolution), show (a path's exact blob content at `ref`, no checkout), grep-ref (text search across `ref`'s own tree, no checkout -- the ref-scoped equivalent of search_code), ls-ref (every file path in `ref`'s own tree, no checkout), is-ancestor (is `ancestorRef` a real ancestor of, or the same commit as, `ref` -- the backport/reachability check \"was this fix ported to this branch\" actually needs), worktree-add (materializes `ref` as a real, read-only project via a detached git worktree and returns its own `directory` -- pass that straight to find_symbols/search_code/this tool itself for full semantic queries against another branch/commit, not just text), worktree-remove (releases and deletes a worktree-add-created checkout -- `directory` is that checkout's own returned directory, not the source repo's).",
|
|
1261
|
+
promptSnippet:
|
|
1262
|
+
"Show a repository's status, log, diff, one symbol's diff across versions, a ref-scoped blob/text/ancestry query, or a real checkout at another ref",
|
|
1251
1263
|
promptGuidelines: [
|
|
1252
|
-
"maxCount is required for action=log; maxBytes is required for action=diff/compare-symbol -- every bounded query needs its bound stated explicitly, never defaulted silently.",
|
|
1264
|
+
"maxCount is required for action=log; maxBytes is required for action=diff/compare-symbol/grep-ref -- every bounded query needs its bound stated explicitly, never defaulted silently.",
|
|
1253
1265
|
"path, symbol, and fromRef are required for action=compare-symbol; toRef is optional and means 'the current working tree' when omitted.",
|
|
1254
1266
|
"ref is required for action=worktree-add. A repeated worktree-add for the same (directory, ref) reuses the existing checkout unless forceRefresh is set -- use that when ref is a branch that may have moved.",
|
|
1255
1267
|
"action=worktree-remove's directory is worktree-add's own returned directory, never the source repo's -- always call it once done with a worktree to reclaim disk.",
|
|
1268
|
+
"ref and path are required for action=show; ref and pattern (maxMatches, maxBytes) are required for action=grep-ref; ref (maxResults) is required for action=ls-ref; ancestorRef and ref are required for action=is-ancestor. None of the four checks anything out -- prefer them over worktree-add/find_symbols for a quick existence/text/ancestry answer.",
|
|
1256
1269
|
],
|
|
1257
1270
|
parameters: Type.Object({
|
|
1258
|
-
action: Type.String({ description: "status | log | diff | compare-symbol | worktree-add | worktree-remove" }),
|
|
1271
|
+
action: Type.String({ description: "status | log | diff | compare-symbol | show | grep-ref | ls-ref | is-ancestor | worktree-add | worktree-remove" }),
|
|
1259
1272
|
directory: Type.String({ description: "Directory inside the repository to check, absolute or relative to the current working directory" }),
|
|
1260
1273
|
maxCount: Type.Optional(Type.Number({ description: "Maximum number of commits to return, most recent first -- required for action=log" })),
|
|
1261
1274
|
ref: Type.Optional(
|
|
1262
|
-
Type.String({
|
|
1275
|
+
Type.String({
|
|
1276
|
+
description:
|
|
1277
|
+
"Ref to diff against (defaults to HEAD) for action=diff, or to check out/query for action=worktree-add/show/grep-ref/ls-ref/is-ancestor (required for those)",
|
|
1278
|
+
}),
|
|
1263
1279
|
),
|
|
1264
1280
|
maxBytes: Type.Optional(
|
|
1265
|
-
Type.Number({
|
|
1281
|
+
Type.Number({
|
|
1282
|
+
description: "Maximum diff/comparison/grep output size in bytes before truncating -- required for action=diff/compare-symbol/grep-ref",
|
|
1283
|
+
}),
|
|
1284
|
+
),
|
|
1285
|
+
path: Type.Optional(
|
|
1286
|
+
Type.String({ description: "File path (relative to directory) containing the symbol, or to read -- required for action=compare-symbol/show" }),
|
|
1266
1287
|
),
|
|
1267
|
-
path: Type.Optional(Type.String({ description: "File path (relative to directory) containing the symbol -- required for action=compare-symbol" })),
|
|
1268
1288
|
symbol: Type.Optional(Type.String({ description: "Exact symbol name to compare -- required for action=compare-symbol" })),
|
|
1269
1289
|
fromRef: Type.Optional(Type.String({ description: "Git ref for the 'before' version -- required for action=compare-symbol" })),
|
|
1270
1290
|
toRef: Type.Optional(
|
|
@@ -1275,6 +1295,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1275
1295
|
description: "action=worktree-add only: recreate an already-reused worktree at ref's current tip instead of returning the existing one",
|
|
1276
1296
|
}),
|
|
1277
1297
|
),
|
|
1298
|
+
pattern: Type.Optional(Type.String({ description: "Text pattern to search for -- required for action=grep-ref" })),
|
|
1299
|
+
pathspecs: Type.Optional(
|
|
1300
|
+
Type.Array(Type.String(), {
|
|
1301
|
+
description:
|
|
1302
|
+
'Narrows action=grep-ref (glob-based, e.g. "*.go") or action=ls-ref (prefix-based, e.g. "pkg/dpll"); omitted searches/lists the whole tree',
|
|
1303
|
+
}),
|
|
1304
|
+
),
|
|
1305
|
+
maxMatches: Type.Optional(Type.Number({ description: "Maximum grep matches to return -- required for action=grep-ref" })),
|
|
1306
|
+
maxResults: Type.Optional(Type.Number({ description: "Maximum file paths to return -- required for action=ls-ref" })),
|
|
1307
|
+
ancestorRef: Type.Optional(Type.String({ description: "The candidate ancestor ref -- required for action=is-ancestor" })),
|
|
1278
1308
|
}),
|
|
1279
1309
|
async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<GitToolDetails>> {
|
|
1280
1310
|
const directory = resolve(cwd, params.directory);
|
|
@@ -1316,6 +1346,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
1316
1346
|
const details: GitToolDetails = { action: "worktree-remove", worktreeRemove };
|
|
1317
1347
|
return { content: [{ type: "text", text: JSON.stringify(worktreeRemove) }], details };
|
|
1318
1348
|
}
|
|
1349
|
+
if (params.action === "show") {
|
|
1350
|
+
if (!params.ref || !params.path) throw new Error("git action=show requires ref and path");
|
|
1351
|
+
const content = await gitOperations.showFile(directory, params.ref, params.path, vehicleCall);
|
|
1352
|
+
const details: GitToolDetails = { action: "show", showFile: { ref: params.ref, path: params.path, content } };
|
|
1353
|
+
return { content: [{ type: "text", text: content ?? `"${params.path}" does not exist at ${params.ref}` }], details };
|
|
1354
|
+
}
|
|
1355
|
+
if (params.action === "grep-ref") {
|
|
1356
|
+
if (!params.ref || !params.pattern) throw new Error("git action=grep-ref requires ref and pattern");
|
|
1357
|
+
if (params.maxMatches === undefined || params.maxBytes === undefined) throw new Error("git action=grep-ref requires maxMatches and maxBytes");
|
|
1358
|
+
const grep = await gitOperations.grep(directory, params.ref, params.pattern, params.pathspecs, params.maxMatches, params.maxBytes, vehicleCall);
|
|
1359
|
+
const details: GitToolDetails = { action: "grep-ref", grep };
|
|
1360
|
+
return { content: [{ type: "text", text: JSON.stringify(grep) }], details };
|
|
1361
|
+
}
|
|
1362
|
+
if (params.action === "ls-ref") {
|
|
1363
|
+
if (!params.ref) throw new Error("git action=ls-ref requires ref");
|
|
1364
|
+
if (params.maxResults === undefined) throw new Error("git action=ls-ref requires maxResults");
|
|
1365
|
+
const listFiles = await gitOperations.listFiles(directory, params.ref, params.pathspecs, params.maxResults, vehicleCall);
|
|
1366
|
+
const details: GitToolDetails = { action: "ls-ref", listFiles };
|
|
1367
|
+
return { content: [{ type: "text", text: JSON.stringify(listFiles) }], details };
|
|
1368
|
+
}
|
|
1369
|
+
if (params.action === "is-ancestor") {
|
|
1370
|
+
if (!params.ancestorRef || !params.ref) throw new Error("git action=is-ancestor requires ancestorRef and ref");
|
|
1371
|
+
const result = await gitOperations.isAncestor(directory, params.ancestorRef, params.ref, vehicleCall);
|
|
1372
|
+
const details: GitToolDetails = { action: "is-ancestor", isAncestor: { ancestorRef: params.ancestorRef, ref: params.ref, result } };
|
|
1373
|
+
return { content: [{ type: "text", text: JSON.stringify({ isAncestor: result }) }], details };
|
|
1374
|
+
}
|
|
1319
1375
|
throw new Error(`unknown git action: ${String(params.action)}`);
|
|
1320
1376
|
},
|
|
1321
1377
|
renderCall(args, theme, context) {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent above-editor widget for currently-active symbol-graph caching jobs -- mirrors
|
|
3
|
+
* pi-papyrus's own TaskOverlay/NoteOverlay, pi-pipes' own JobsOverlay, and pi-packed's own
|
|
4
|
+
* DoctorOverlay: factory-form ctx.ui.setWidget registration, requestRender on refresh, hides the
|
|
5
|
+
* widget entirely (setWidget(key, undefined)) rather than an empty box once nothing is caching.
|
|
6
|
+
*
|
|
7
|
+
* workspace.activeCachingJobs enumerates every workspace with a currently active (queued/
|
|
8
|
+
* running) population job -- see packages/lector/src/service/symbol-graph/cache-query-handlers.ts.
|
|
9
|
+
*/
|
|
10
|
+
import type { ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
12
|
+
import { AutoRotatingWindow } from "malevich-tui-components";
|
|
13
|
+
import { BoundedPoll } from "../bounded-poll.js";
|
|
14
|
+
import { lectorClient, type RetryingLectorClient } from "../lector-client.js";
|
|
15
|
+
import { buildCachingWidgetProjection, type CachingWidgetProjection, LECTOR_CACHING_WIDGET_VISIBLE_ROWS, renderCachingWidgetLines } from "./caching-widget.js";
|
|
16
|
+
|
|
17
|
+
const WIDGET_KEY = "pi-lector-caching";
|
|
18
|
+
|
|
19
|
+
/** Matches pi-papyrus's/pi-pipes' own 15-20s cadence -- workspace.activeCachingJobs is a cheap in-memory read. */
|
|
20
|
+
export const CACHING_WIDGET_POLL_INTERVAL_MS = 15_000;
|
|
21
|
+
|
|
22
|
+
/** How often the widget's own auto-rotating overflow page advances. */
|
|
23
|
+
export const CACHING_WIDGET_ROTATION_INTERVAL_MS = 6_000;
|
|
24
|
+
|
|
25
|
+
const EMPTY_PROJECTION: CachingWidgetProjection = { rows: [], total: 0 };
|
|
26
|
+
|
|
27
|
+
export class CachingOverlay {
|
|
28
|
+
private uiCtx: ExtensionUIContext | undefined;
|
|
29
|
+
private registered = false;
|
|
30
|
+
private tui: TUI | undefined;
|
|
31
|
+
private projection: CachingWidgetProjection = EMPTY_PROJECTION;
|
|
32
|
+
private readonly poll = new BoundedPoll();
|
|
33
|
+
/** Repaint-only ticker (no data refetch) so the widget's own auto-rotating page visibly
|
|
34
|
+
* advances even when nothing else has changed. */
|
|
35
|
+
private readonly rotationPoll = new BoundedPoll();
|
|
36
|
+
private readonly rotation = new AutoRotatingWindow({
|
|
37
|
+
totalRows: 0,
|
|
38
|
+
pageSize: LECTOR_CACHING_WIDGET_VISIBLE_ROWS,
|
|
39
|
+
intervalMs: CACHING_WIDGET_ROTATION_INTERVAL_MS,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
constructor(private readonly connect: () => Promise<RetryingLectorClient> = lectorClient) {}
|
|
43
|
+
|
|
44
|
+
setUI(ctx: ExtensionUIContext): void {
|
|
45
|
+
if (ctx !== this.uiCtx) {
|
|
46
|
+
this.uiCtx = ctx;
|
|
47
|
+
this.registered = false;
|
|
48
|
+
this.tui = undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Never throws: called from a poll timer and from session_start, neither of which should turn
|
|
53
|
+
* a best-effort status widget into a crashed extension host over a daemon that isn't running
|
|
54
|
+
* yet or a rendering bug. */
|
|
55
|
+
async refresh(): Promise<void> {
|
|
56
|
+
try {
|
|
57
|
+
const client = await this.connect();
|
|
58
|
+
const result = await client.call("workspace.activeCachingJobs", {});
|
|
59
|
+
this.projection = buildCachingWidgetProjection(result.jobs);
|
|
60
|
+
} catch {
|
|
61
|
+
this.projection = EMPTY_PROJECTION;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
this.render();
|
|
65
|
+
} catch {
|
|
66
|
+
// A rendering bug must not crash the extension host over a best-effort status widget.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private render(): void {
|
|
71
|
+
if (!this.uiCtx) return;
|
|
72
|
+
|
|
73
|
+
if (this.projection.total === 0) {
|
|
74
|
+
if (this.registered) {
|
|
75
|
+
this.uiCtx.setWidget(WIDGET_KEY, undefined);
|
|
76
|
+
this.registered = false;
|
|
77
|
+
this.tui = undefined;
|
|
78
|
+
this.rotationPoll.stop();
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!this.registered) {
|
|
84
|
+
this.uiCtx.setWidget(
|
|
85
|
+
WIDGET_KEY,
|
|
86
|
+
(tui: TUI, theme: Theme) => {
|
|
87
|
+
this.tui = tui;
|
|
88
|
+
return {
|
|
89
|
+
render: (width: number) => renderCachingWidgetLines(theme, this.projection, width, this.rotation),
|
|
90
|
+
invalidate: () => {
|
|
91
|
+
// Theme changed -- force re-registration, matching every other overlay in this ecosystem.
|
|
92
|
+
this.registered = false;
|
|
93
|
+
this.tui = undefined;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
{ placement: "aboveEditor" },
|
|
98
|
+
);
|
|
99
|
+
this.registered = true;
|
|
100
|
+
this.rotationPoll.start(CACHING_WIDGET_ROTATION_INTERVAL_MS, () => this.tui?.requestRender());
|
|
101
|
+
} else {
|
|
102
|
+
this.tui?.requestRender();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
startPolling(intervalMs: number = CACHING_WIDGET_POLL_INTERVAL_MS): void {
|
|
107
|
+
this.poll.start(intervalMs, () => {
|
|
108
|
+
void this.refresh();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
stopPolling(): void {
|
|
113
|
+
this.poll.stop();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
dispose(): void {
|
|
117
|
+
this.stopPolling();
|
|
118
|
+
this.rotationPoll.stop();
|
|
119
|
+
this.uiCtx?.setWidget(WIDGET_KEY, undefined);
|
|
120
|
+
this.registered = false;
|
|
121
|
+
this.tui = undefined;
|
|
122
|
+
this.uiCtx = undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure projection/render pair for the Caching widget -- mirrors pi-papyrus's own task-widget.ts /
|
|
3
|
+
* pi-pipes' own jobs-widget.ts / pi-packed's own doctor-widget.ts split: the daemon's
|
|
4
|
+
* workspace.activeCachingJobs result in, a bounded intermediate shape out, no I/O, no TUI, fully
|
|
5
|
+
* unit-testable without a real daemon or terminal. See caching-overlay.ts for the stateful
|
|
6
|
+
* ctx.ui.setWidget-registered class that drives these from a live poll.
|
|
7
|
+
*/
|
|
8
|
+
import { vehicleWidgetTitle } from "@danypops/vehicle-client-pi/widget-header";
|
|
9
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
10
|
+
import { type AutoRotatingWindow, renderCardRow, type TextMeasure } from "malevich-tui-components";
|
|
11
|
+
|
|
12
|
+
const measure: TextMeasure = { visibleWidth, truncateToWidth, wrapTextWithAnsi };
|
|
13
|
+
|
|
14
|
+
/** The daemon's own manifest name (see packages/lector/src/service.ts's `new VehicleRegistry({ name: "lector" })`). */
|
|
15
|
+
const VEHICLE_NAME = "lector";
|
|
16
|
+
|
|
17
|
+
/** Visible rows per page before the auto-rotating overflow hint pages to the next. */
|
|
18
|
+
export const LECTOR_CACHING_WIDGET_VISIBLE_ROWS = 5;
|
|
19
|
+
|
|
20
|
+
export interface CachingWidgetRow {
|
|
21
|
+
workspaceId: string;
|
|
22
|
+
status: "queued" | "running" | "waiting-for-resources";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CachingWidgetProjection {
|
|
26
|
+
rows: CachingWidgetRow[];
|
|
27
|
+
total: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildCachingWidgetProjection(jobs: readonly CachingWidgetRow[]): CachingWidgetProjection {
|
|
31
|
+
return { rows: [...jobs], total: jobs.length };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cachingRowLine(theme: { fg(color: string, text: string): string }, row: CachingWidgetRow, width: number): string {
|
|
35
|
+
const glyph =
|
|
36
|
+
row.status === "waiting-for-resources"
|
|
37
|
+
? theme.fg("warning", "\u23f8")
|
|
38
|
+
: row.status === "queued"
|
|
39
|
+
? theme.fg("muted", "\u2022")
|
|
40
|
+
: theme.fg("accent", "\u25b6");
|
|
41
|
+
return truncateToWidth(`${glyph} ${row.workspaceId}`, width, "\u2026");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** "Lector · Caching · <N>", plus a "page/total ⟳" suffix once genuinely paging. */
|
|
45
|
+
function cachingCardLabel(projection: CachingWidgetProjection, rotation?: AutoRotatingWindow): string {
|
|
46
|
+
const base = vehicleWidgetTitle(VEHICLE_NAME, "Caching", `${projection.total}`);
|
|
47
|
+
return rotation?.isPaging ? `${base} \u00b7 ${rotation.pageIndex + 1}/${rotation.pageCount} \u27f3` : base;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Renders the widget as a single bordered card -- `[]` (hide the whole widget) when nothing is
|
|
51
|
+
* currently caching, matching every other overlay's own "hide when nothing to show" convention. */
|
|
52
|
+
export function renderCachingWidgetLines(
|
|
53
|
+
theme: { fg(color: string, text: string): string },
|
|
54
|
+
projection: CachingWidgetProjection,
|
|
55
|
+
width: number,
|
|
56
|
+
rotation?: AutoRotatingWindow,
|
|
57
|
+
): string[] {
|
|
58
|
+
if (projection.total === 0) return [];
|
|
59
|
+
rotation?.setTotalRows(projection.rows.length);
|
|
60
|
+
const { start, end } = rotation?.currentPageBounds() ?? { start: 0, end: projection.rows.length };
|
|
61
|
+
const visibleRows = projection.rows.slice(start, end);
|
|
62
|
+
|
|
63
|
+
return renderCardRow(
|
|
64
|
+
[
|
|
65
|
+
{
|
|
66
|
+
label: cachingCardLabel(projection, rotation),
|
|
67
|
+
render: (innerWidth: number) => visibleRows.map((row) => cachingRowLine(theme, row, innerWidth)),
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
width,
|
|
71
|
+
{ measure, frameStyle: (s) => theme.fg("borderMuted", s) },
|
|
72
|
+
);
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-lector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Pi host adapter for Lector: overrides read/write/edit with a daemon-backed, hash-guarded filesystem",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
"@danypops/vehicle-client-pi": "^0.43.0"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
+
"@danypops/lector": "workspace:*",
|
|
25
26
|
"@danypops/vehicle-client": "^0.10.3",
|
|
26
27
|
"@danypops/vehicle-core": "^0.17.1",
|
|
27
|
-
"
|
|
28
|
-
"malevich-tui-components": "^0.25.0",
|
|
28
|
+
"malevich-tui-components": "^0.32.1",
|
|
29
29
|
"picomatch": "^4.0.5"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|