@danypops/pi-lector 0.13.9 → 0.13.11

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.
@@ -1,5 +1,9 @@
1
1
  import type { JobSnapshot, OperationInputs, OperationOutputs, PopulateSymbolGraphResult, SymbolEdgeKind, SymbolNode } from "@danypops/lector";
2
2
  import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath, workspaceForPathOrDirectory } from "../lector-client.ts";
3
+ import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts";
4
+
5
+ const CODE_ACTION_PREVIEW_PERMISSIONS = ["workspace:read"];
6
+ const CODE_ACTION_APPLY_PERMISSIONS = ["workspace:write"];
3
7
 
4
8
  /**
5
9
  * Thin wrappers over Lector's code-intelligence operations: goToDefinition,
@@ -34,9 +38,50 @@ export interface CodeIntelligenceOperations {
34
38
  hover(path: string, line: number, character: number): Promise<OperationOutputs["workspace.hover"]>;
35
39
  documentSymbols(path: string): Promise<OperationOutputs["workspace.documentSymbols"]>;
36
40
  diagnostics(path: string): Promise<OperationOutputs["workspace.diagnostics"]>;
41
+ previewCodeActions(
42
+ path: string,
43
+ input: Omit<OperationInputs["workspace.previewCodeActions"], "workspaceId" | "path">,
44
+ call: LectorVehicleCall,
45
+ ): Promise<OperationOutputs["workspace.previewCodeActions"]>;
46
+ applyCodeAction(
47
+ path: string,
48
+ previewId: OperationInputs["workspace.applyCodeAction"]["previewId"],
49
+ call: LectorVehicleCall,
50
+ ): Promise<OperationOutputs["workspace.applyCodeAction"]>;
51
+ diagnosticDelta(
52
+ path: string,
53
+ source: OperationInputs["workspace.diagnosticDelta"]["source"],
54
+ bounds?: Omit<OperationInputs["workspace.diagnosticDelta"], "workspaceId" | "source">,
55
+ ): Promise<OperationOutputs["workspace.diagnosticDelta"]>;
37
56
  prepareCallHierarchy(path: string, line: number, character: number): Promise<OperationOutputs["workspace.prepareCallHierarchy"]>;
38
57
  incomingCalls(path: string, line: number, character: number): Promise<OperationOutputs["workspace.incomingCalls"]>;
39
58
  outgoingCalls(path: string, line: number, character: number): Promise<OperationOutputs["workspace.outgoingCalls"]>;
59
+ prepareTypeHierarchy(
60
+ path: string,
61
+ line: number,
62
+ character: number,
63
+ bounds?: Pick<OperationInputs["workspace.prepareTypeHierarchy"], "maxResults" | "maxBytes" | "deadlineMs">,
64
+ ): Promise<OperationOutputs["workspace.prepareTypeHierarchy"]>;
65
+ supertypes(
66
+ path: string,
67
+ line: number,
68
+ character: number,
69
+ bounds?: Pick<OperationInputs["workspace.supertypes"], "maxResults" | "maxBytes" | "deadlineMs">,
70
+ ): Promise<OperationOutputs["workspace.supertypes"]>;
71
+ subtypes(
72
+ path: string,
73
+ line: number,
74
+ character: number,
75
+ bounds?: Pick<OperationInputs["workspace.subtypes"], "maxResults" | "maxBytes" | "deadlineMs">,
76
+ ): Promise<OperationOutputs["workspace.subtypes"]>;
77
+ impactAnalysis(
78
+ path: string,
79
+ source: OperationInputs["workspace.impactAnalysis"]["source"],
80
+ bounds: Pick<
81
+ OperationInputs["workspace.impactAnalysis"],
82
+ "maxDepth" | "maxNodes" | "maxEdges" | "maxBytes" | "deadlineMs" | "coverage" | "autoPopulate" | "maxFiles" | "maxSymbolsPerFile"
83
+ >,
84
+ ): Promise<OperationOutputs["workspace.impactAnalysis"]>;
40
85
  /**
41
86
  * Not exposed as a standalone Pi tool -- every workspace auto-populates on first touch via
42
87
  * monitorWorkspaceCache (workspace-cache/operations.ts). Kept here as an internal capability
@@ -46,7 +91,14 @@ export interface CodeIntelligenceOperations {
46
91
  populateSymbolGraph(path: string, maxFiles: number, maxSymbolsPerFile: number, waitMs?: number): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
47
92
  /** Not exposed as a standalone Pi tool -- see populateSymbolGraph. */
48
93
  jobStatus(jobId: string): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
49
- reachableFrom(path: string, line: number, character: number, maxDepth: number, kind?: SymbolEdgeKind): Promise<readonly SymbolNode[]>;
94
+ reachableFrom(
95
+ path: string,
96
+ line: number,
97
+ character: number,
98
+ maxDepth: number,
99
+ kind?: SymbolEdgeKind,
100
+ autoPopulation?: Pick<OperationInputs["workspace.reachableFrom"], "autoPopulate" | "maxFiles" | "maxSymbolsPerFile">,
101
+ ): Promise<readonly SymbolNode[]>;
50
102
  /** Never spawns a symbol index -- safe to call opportunistically (e.g. before deciding whether to enrich a result). */
51
103
  hasWarmIndex(path: string): Promise<boolean>;
52
104
  workspaceMap(path: string, maxNodes: number, maxEdges: number, maxEntries: number, maxBytes: number): Promise<OperationOutputs["workspace.map"]>;
@@ -120,6 +172,36 @@ export function createLectorCodeIntelligenceOperations(ownerId?: string): CodeIn
120
172
  },
121
173
  );
122
174
  },
175
+ async previewCodeActions(path, input, call) {
176
+ return withWorkspace(
177
+ () => workspaceForCodeIntelligencePath(path),
178
+ ({ workspaceId }) =>
179
+ invokeLectorVehicleOperation<OperationOutputs["workspace.previewCodeActions"]>(
180
+ "workspace.previewCodeActions",
181
+ { workspaceId, path, ...input },
182
+ CODE_ACTION_PREVIEW_PERMISSIONS,
183
+ call,
184
+ ),
185
+ );
186
+ },
187
+ async applyCodeAction(path, previewId, call) {
188
+ return withWorkspace(
189
+ () => workspaceForCodeIntelligencePath(path),
190
+ ({ workspaceId }) =>
191
+ invokeLectorVehicleOperation<OperationOutputs["workspace.applyCodeAction"]>(
192
+ "workspace.applyCodeAction",
193
+ { workspaceId, previewId },
194
+ CODE_ACTION_APPLY_PERMISSIONS,
195
+ call,
196
+ ),
197
+ );
198
+ },
199
+ async diagnosticDelta(path, source, bounds) {
200
+ return withWorkspace(
201
+ () => workspaceForPathOrDirectory(path),
202
+ async ({ workspaceId }) => (await lectorClient()).call("workspace.diagnosticDelta", { workspaceId, source, ...bounds }),
203
+ );
204
+ },
123
205
  async prepareCallHierarchy(path, line, character) {
124
206
  return withWorkspace(
125
207
  () => workspaceForCodeIntelligencePath(path),
@@ -147,6 +229,30 @@ export function createLectorCodeIntelligenceOperations(ownerId?: string): CodeIn
147
229
  },
148
230
  );
149
231
  },
232
+ async prepareTypeHierarchy(path, line, character, bounds) {
233
+ return withWorkspace(
234
+ () => workspaceForCodeIntelligencePath(path),
235
+ async ({ workspaceId }) => (await lectorClient()).call("workspace.prepareTypeHierarchy", { workspaceId, path, line, character, ...bounds }),
236
+ );
237
+ },
238
+ async supertypes(path, line, character, bounds) {
239
+ return withWorkspace(
240
+ () => workspaceForCodeIntelligencePath(path),
241
+ async ({ workspaceId }) => (await lectorClient()).call("workspace.supertypes", { workspaceId, path, line, character, ...bounds }),
242
+ );
243
+ },
244
+ async subtypes(path, line, character, bounds) {
245
+ return withWorkspace(
246
+ () => workspaceForCodeIntelligencePath(path),
247
+ async ({ workspaceId }) => (await lectorClient()).call("workspace.subtypes", { workspaceId, path, line, character, ...bounds }),
248
+ );
249
+ },
250
+ async impactAnalysis(path, source, bounds) {
251
+ return withWorkspace(
252
+ () => workspaceForPathOrDirectory(path),
253
+ async ({ workspaceId }) => (await lectorClient()).call("workspace.impactAnalysis", { workspaceId, source, ...bounds }),
254
+ );
255
+ },
150
256
  async populateSymbolGraph(path, maxFiles, maxSymbolsPerFile, waitMs = 500) {
151
257
  return withWorkspace(
152
258
  () => workspaceForPathOrDirectory(path),
@@ -167,12 +273,12 @@ export function createLectorCodeIntelligenceOperations(ownerId?: string): CodeIn
167
273
  const { job } = await client.call("job.status", { jobId });
168
274
  return job;
169
275
  },
170
- async reachableFrom(path, line, character, maxDepth, kind) {
276
+ async reachableFrom(path, line, character, maxDepth, kind, autoPopulation) {
171
277
  return withWorkspace(
172
278
  () => workspaceForCodeIntelligencePath(path),
173
279
  async ({ workspaceId }) => {
174
280
  const client = await lectorClient();
175
- const { symbols } = await client.call("workspace.reachableFrom", { workspaceId, path, line, character, maxDepth, kind });
281
+ const { symbols } = await client.call("workspace.reachableFrom", { workspaceId, path, line, character, maxDepth, kind, ...autoPopulation });
176
282
  return symbols;
177
283
  },
178
284
  );
@@ -5,6 +5,7 @@ import type {
5
5
  Hover,
6
6
  IncomingCall,
7
7
  IntelligenceProvenance,
8
+ OperationOutputs,
8
9
  OutgoingCall,
9
10
  SymbolNode,
10
11
  WorkspaceLocation,
@@ -27,6 +28,7 @@ const DEFAULT_VISIBLE_LOCATIONS = 8;
27
28
  const DEFAULT_VISIBLE_SYMBOLS = 12;
28
29
  const DEFAULT_VISIBLE_DIAGNOSTICS = 12;
29
30
  const DEFAULT_VISIBLE_CALLS = 12;
31
+ const DEFAULT_VISIBLE_CHANGES = 12;
30
32
 
31
33
  const DIAGNOSTIC_SEVERITY_COLOR: Record<Diagnostic["severity"], ThemeColor> = {
32
34
  error: "error",
@@ -171,6 +173,123 @@ export function formatDiagnosticsResult(diagnostics: readonly Diagnostic[] | und
171
173
  return lines.join("\n");
172
174
  }
173
175
 
176
+ function formatPathCall(toolName: string, args: { path?: unknown }, theme: LectorTheme, qualifier = ""): string {
177
+ const path = typeof args.path === "string" ? args.path : "";
178
+ return `${theme.fg("toolTitle", theme.bold(toolName))}${qualifier ? ` ${theme.fg("muted", qualifier)}` : ""} ${theme.fg("accent", path)}`;
179
+ }
180
+
181
+ export function formatCodeActionPreviewCall(
182
+ args: { path?: unknown; startLine?: unknown; startCharacter?: unknown; endLine?: unknown; endCharacter?: unknown },
183
+ theme: LectorTheme,
184
+ ): string {
185
+ const range = `${typeof args.startLine === "number" ? args.startLine : "?"}:${typeof args.startCharacter === "number" ? args.startCharacter : "?"}-${typeof args.endLine === "number" ? args.endLine : "?"}:${typeof args.endCharacter === "number" ? args.endCharacter : "?"}`;
186
+ return formatPathCall("code_action_preview", args, theme, range);
187
+ }
188
+
189
+ export function formatCodeActionPreviewResult(
190
+ result: OperationOutputs["workspace.previewCodeActions"] | undefined,
191
+ expanded: boolean,
192
+ theme: LectorTheme,
193
+ ): string {
194
+ if (!result || result.actions.length === 0) return theme.fg("dim", "No code actions.");
195
+ return [
196
+ theme.fg("muted", `${result.actions.length} code action${result.actions.length === 1 ? "" : "s"}:`),
197
+ ...renderTruncatedList({
198
+ items: result.actions,
199
+ expanded,
200
+ visibleCount: DEFAULT_VISIBLE_CHANGES,
201
+ formatItem: (action) => {
202
+ const state = action.disabledReason
203
+ ? theme.fg("warning", `disabled: ${action.disabledReason}`)
204
+ : action.preferred
205
+ ? theme.fg("success", "preferred")
206
+ : "";
207
+ const paths = action.affectedPaths.length > 0 ? action.affectedPaths.join(", ") : "command only";
208
+ return ` ${theme.bold(action.title)}${action.kind ? ` (${action.kind})` : ""} -- ${paths}${state ? ` -- ${state}` : ""}`;
209
+ },
210
+ moreLine: moreLine(theme),
211
+ truncationWarning: result.truncated || result.deadlineReached ? theme.fg("warning", "results truncated by the requested bounds") : undefined,
212
+ }),
213
+ ].join("\n");
214
+ }
215
+
216
+ export function formatCodeActionApplyCall(args: { path?: unknown }, theme: LectorTheme): string {
217
+ return formatPathCall("code_action_apply", args, theme);
218
+ }
219
+
220
+ export function formatCodeActionApplyResult(result: OperationOutputs["workspace.applyCodeAction"] | undefined, theme: LectorTheme): string {
221
+ if (!result) return theme.fg("dim", "No code-action result.");
222
+ const lines = result.touchedPaths.map((path) => ` ${path}`);
223
+ if (result.transactionId) lines.push(theme.fg("muted", `transaction ${result.transactionId}`));
224
+ if (result.pendingCommand) lines.push(theme.fg("warning", `pending command ${result.pendingCommand.command}`));
225
+ return lines.length > 0 ? lines.join("\n") : theme.fg("dim", "Code action made no file changes.");
226
+ }
227
+
228
+ export function formatDiagnosticDeltaCall(args: { path?: unknown; source?: unknown }, theme: LectorTheme): string {
229
+ return formatPathCall("diagnostic_delta", args, theme, typeof args.source === "string" ? args.source : "");
230
+ }
231
+
232
+ export function formatDiagnosticDeltaResult(result: OperationOutputs["workspace.diagnosticDelta"] | undefined, expanded: boolean, theme: LectorTheme): string {
233
+ if (!result) return theme.fg("dim", "No diagnostic delta.");
234
+ const changes = [
235
+ ...result.introduced.map((diagnostic) => ({ label: "introduced", diagnostic })),
236
+ ...result.resolved.map((diagnostic) => ({ label: "resolved", diagnostic })),
237
+ ...result.changed.map(({ after }) => ({ label: "changed", diagnostic: after })),
238
+ ];
239
+ if (changes.length === 0) return theme.fg("success", "No diagnostic changes.");
240
+ return renderTruncatedList({
241
+ items: changes,
242
+ expanded,
243
+ visibleCount: DEFAULT_VISIBLE_CHANGES,
244
+ formatItem: ({ label, diagnostic }) =>
245
+ `${theme.fg(label === "introduced" ? "error" : label === "resolved" ? "success" : "warning", label)} ${formatLocation(theme, diagnostic.range.path, diagnostic.range.start.line, diagnostic.range.start.character)} -- ${diagnostic.message}`,
246
+ moreLine: moreLine(theme),
247
+ truncationWarning: result.truncated ? theme.fg("warning", "results truncated by maxResults/maxBytes") : undefined,
248
+ }).join("\n");
249
+ }
250
+
251
+ export function formatTypeHierarchyCall(args: { direction?: unknown; path?: unknown; line?: unknown; character?: unknown }, theme: LectorTheme): string {
252
+ return `${formatPositionalCall("type_hierarchy", args, theme)} ${theme.fg("muted", typeof args.direction === "string" ? args.direction : "")}`;
253
+ }
254
+
255
+ export function formatTypeHierarchyResult(
256
+ result: OperationOutputs["workspace.prepareTypeHierarchy"] | undefined,
257
+ expanded: boolean,
258
+ theme: LectorTheme,
259
+ ): string {
260
+ if (!result || result.items.length === 0) return theme.fg("dim", "No type-hierarchy items found.");
261
+ return renderTruncatedList({
262
+ items: result.items,
263
+ expanded,
264
+ visibleCount: DEFAULT_VISIBLE_CALLS,
265
+ formatItem: (item) => ` ${formatCallHierarchyEntry(item, theme)}${item.detail ? theme.fg("dim", ` -- ${item.detail}`) : ""}`,
266
+ moreLine: moreLine(theme),
267
+ truncationWarning: result.truncated ? theme.fg("warning", "results truncated by the requested bounds") : undefined,
268
+ }).join("\n");
269
+ }
270
+
271
+ export function formatImpactAnalysisCall(args: { path?: unknown; source?: unknown }, theme: LectorTheme): string {
272
+ return formatPathCall("impact_analysis", args, theme, typeof args.source === "string" ? args.source : "");
273
+ }
274
+
275
+ export function formatImpactAnalysisResult(result: OperationOutputs["workspace.impactAnalysis"] | undefined, expanded: boolean, theme: LectorTheme): string {
276
+ if (!result) return theme.fg("dim", "No impact-analysis result.");
277
+ const impacts = [
278
+ ...result.changedSymbols.map(({ symbol, side }) => ({ label: `changed ${side}`, symbol })),
279
+ ...result.impactedSymbols.map(({ symbol, depth }) => ({ label: `impact depth=${depth}`, symbol })),
280
+ ...result.relatedTests.map(({ symbol, evidence }) => ({ label: `test ${evidence.kind}`, symbol })),
281
+ ];
282
+ if (impacts.length === 0) return theme.fg("dim", "No changed symbols resolved.");
283
+ return renderTruncatedList({
284
+ items: impacts,
285
+ expanded,
286
+ visibleCount: DEFAULT_VISIBLE_CHANGES,
287
+ formatItem: ({ label, symbol }) => `${theme.fg("muted", label)} ${formatCallHierarchyEntry(symbol, theme)}`,
288
+ moreLine: moreLine(theme),
289
+ truncationWarning: result.truncated || result.deadlineReached ? theme.fg("warning", "analysis truncated by the requested bounds") : undefined,
290
+ }).join("\n");
291
+ }
292
+
174
293
  function formatCallHierarchyEntry(entry: { kind: string; name: string; location: WorkspaceLocation }, theme: LectorTheme): string {
175
294
  const kind = theme.fg(colorForKind(entry.kind), entry.kind);
176
295
  const name = theme.fg("text", theme.bold(entry.name));
@@ -83,6 +83,9 @@ export function formatSearchTextAcrossProjectsResult(
83
83
  lines.push(formatOutcomeHeader(entry, theme));
84
84
  const { outcome } = entry;
85
85
  if (outcome.status !== "ready") continue;
86
+ if (outcome.result.provenance) {
87
+ lines.push(theme.fg("dim", ` lexical via ${outcome.result.provenance.backend} (${outcome.result.provenance.indexState})`));
88
+ }
86
89
  if (outcome.result.matches.length === 0) {
87
90
  lines.push(theme.fg("dim", " no matches"));
88
91
  continue;
@@ -4,6 +4,26 @@ export type EditorMode = "normal" | "insert" | "command";
4
4
 
5
5
  export type EditorAction = { kind: "save" } | { kind: "save-and-quit" } | { kind: "quit" } | { kind: "hover" };
6
6
 
7
+ export interface EditorSourcePosition {
8
+ /** One-based line number. */
9
+ readonly line: number;
10
+ /** One-based UTF-16 code-unit position within the line. */
11
+ readonly character: number;
12
+ }
13
+
14
+ export type EditorPositionErrorCode = "line-out-of-range" | "character-out-of-range";
15
+
16
+ /** Reports why a requested source position cannot identify a cursor location in the current buffer. */
17
+ export class EditorPositionError extends Error {
18
+ constructor(
19
+ readonly code: EditorPositionErrorCode,
20
+ message: string,
21
+ ) {
22
+ super(`${code}: ${message}`);
23
+ this.name = "EditorPositionError";
24
+ }
25
+ }
26
+
7
27
  const BACKSPACE_KEYS = new Set(["\x7f", "\b"]);
8
28
 
9
29
  /**
@@ -27,8 +47,25 @@ export class EditorState {
27
47
  private insertPendingJ = false;
28
48
  private yankedLine: string | undefined;
29
49
 
30
- constructor(content: string) {
50
+ constructor(content: string, initialPosition?: EditorSourcePosition) {
31
51
  this.buffer = new LiveBuffer(content);
52
+ if (initialPosition) this.moveToInitialPosition(initialPosition);
53
+ }
54
+
55
+ private moveToInitialPosition(position: EditorSourcePosition): void {
56
+ if (!Number.isSafeInteger(position.line) || position.line < 1 || position.line > this.buffer.lineCount) {
57
+ throw new EditorPositionError("line-out-of-range", `Line ${position.line} is outside this ${this.buffer.lineCount}-line buffer`);
58
+ }
59
+ const lineLength = this.buffer.lineText(position.line).length;
60
+ const maxCharacter = Math.max(lineLength, 1);
61
+ if (!Number.isSafeInteger(position.character) || position.character < 1 || position.character > maxCharacter) {
62
+ throw new EditorPositionError(
63
+ "character-out-of-range",
64
+ `Character ${position.character} is outside line ${position.line}'s ${lineLength} UTF-16 code units`,
65
+ );
66
+ }
67
+ this.cursorLine = position.line;
68
+ this.cursorCharacter = position.character;
32
69
  }
33
70
 
34
71
  get currentLineText(): string {
@@ -29,7 +29,14 @@
29
29
  */
30
30
 
31
31
  export type { DirectoryExplorerSession } from "./directory-explorer-operations.ts";
32
- export { type EditorAction, type EditorMode, EditorState } from "./editor-state.ts";
32
+ export {
33
+ type EditorAction,
34
+ type EditorMode,
35
+ EditorPositionError,
36
+ type EditorPositionErrorCode,
37
+ type EditorSourcePosition,
38
+ EditorState,
39
+ } from "./editor-state.ts";
33
40
  export type { EditorTheme } from "./editor-theme.ts";
34
41
  export { ExplorerComponent, type ExplorerResult, type ExplorerViewState, joinExplorerPath } from "./explorer-component.ts";
35
42
  // runExplorerFlow is pure orchestration over the two interfaces above (browse, open a file into
@@ -44,4 +51,5 @@ export {
44
51
  type EditorHoverRequest,
45
52
  ModalEditorComponent,
46
53
  type ModalEditorHost,
54
+ type ModalEditorOptions,
47
55
  } from "./modal-editor-component.ts";
@@ -4,7 +4,7 @@ import { contentHashOf, highlightSpans } from "@danypops/lector";
4
4
  import type { ThemeColor } from "@earendil-works/pi-coding-agent";
5
5
  import type { Component, TUI } from "@earendil-works/pi-tui";
6
6
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
- import type { EditorAction } from "./editor-state.ts";
7
+ import type { EditorAction, EditorSourcePosition } from "./editor-state.ts";
8
8
  import { EditorState } from "./editor-state.ts";
9
9
  import type { EditorTheme } from "./editor-theme.ts";
10
10
 
@@ -26,6 +26,11 @@ export type EditorHoverOutcome =
26
26
  | { readonly kind: "ready"; readonly hover?: { readonly contents: string } }
27
27
  | { readonly kind: "stale-active-buffer"; readonly bufferHash: ContentHash };
28
28
 
29
+ export interface ModalEditorOptions {
30
+ /** Positions the cursor using one-based UTF-16 coordinates and scrolls it into the first viewport. */
31
+ readonly initialPosition?: EditorSourcePosition;
32
+ }
33
+
29
34
  export interface ModalEditorHost {
30
35
  filePath: string;
31
36
  /** Saves the buffer's current text through Lector's hash-guarded write. Throws (surfaced as a status message, not a crash) on a genuinely concurrent external change. */
@@ -65,13 +70,14 @@ export class ModalEditorComponent implements Component {
65
70
  private statusMessage = "";
66
71
  private highlightCache: { text: string; spans: readonly HighlightSpan[] } | undefined;
67
72
 
68
- constructor(tui: TUI, theme: EditorTheme, host: ModalEditorHost, content: string, done: () => void) {
73
+ constructor(tui: TUI, theme: EditorTheme, host: ModalEditorHost, content: string, done: () => void, options: ModalEditorOptions = {}) {
69
74
  this.tui = tui;
70
75
  this.theme = theme;
71
76
  this.host = host;
72
77
  this.done = done;
73
78
  this.extension = extname(host.filePath);
74
- this.state = new EditorState(content);
79
+ this.state = new EditorState(content, options.initialPosition);
80
+ this.scrollToKeepCursorVisible();
75
81
  this.refreshHighlightsSafely();
76
82
  }
77
83
 
@@ -1,4 +1,4 @@
1
- import type { GitDiffResult, GitLogEntry, GitStatusSummary, OperationOutputs } from "@danypops/lector";
1
+ import type { GitDiffResult, GitLogEntry, GitStatusSummary, OperationInputs, OperationOutputs } from "@danypops/lector";
2
2
  import { lectorClient, withWorkspace, workspaceForDirectory } from "../lector-client.ts";
3
3
  import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts";
4
4
 
@@ -6,6 +6,8 @@ type SymbolComparison = OperationOutputs["workspace.compareSymbolAcrossVersions"
6
6
  type GitWorktreeAddResult = OperationOutputs["workspace.gitWorktreeAdd"];
7
7
  type GitWorktreeRemoveResult = OperationOutputs["workspace.gitWorktreeRemove"];
8
8
  type GitGrepResult = OperationOutputs["workspace.gitGrep"];
9
+ type GitHistoryGrepResult = OperationOutputs["workspace.gitGrepHistory"];
10
+ type GitHistoryGrepBounds = Omit<OperationInputs["workspace.gitGrepHistory"], "workspaceId" | "pattern" | "pathspecs">;
9
11
  type GitListFilesResult = OperationOutputs["workspace.gitListFiles"];
10
12
 
11
13
  /** Matches GIT_READ_PERMISSIONS' own declared value server-side (git/operation-registration.ts). */
@@ -42,6 +44,14 @@ export interface GitOperations {
42
44
  maxBytes: number,
43
45
  call: LectorVehicleCall,
44
46
  ): Promise<GitGrepResult>;
47
+ /** Bounded extended-regex search across commit trees reachable from every ref, with deterministic topological paging and exact-match deduplication. */
48
+ grepHistory(
49
+ directory: string,
50
+ pattern: string,
51
+ pathspecs: readonly string[] | undefined,
52
+ bounds: GitHistoryGrepBounds,
53
+ call: LectorVehicleCall,
54
+ ): Promise<GitHistoryGrepResult>;
45
55
  /** Every file path in `ref`'s own tree, no checkout -- pathspecs narrows the listing (prefix-based, not glob-based like grep's). */
46
56
  listFiles(directory: string, ref: string, pathspecs: readonly string[] | undefined, maxResults: number, call: LectorVehicleCall): Promise<GitListFilesResult>;
47
57
  /** 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. */
@@ -139,6 +149,18 @@ export function createLectorGitOperations(): GitOperations {
139
149
  ),
140
150
  );
141
151
  },
152
+ async grepHistory(directory, pattern, pathspecs, bounds, call) {
153
+ return withWorkspace(
154
+ () => workspaceForDirectory(directory),
155
+ ({ workspaceId }) =>
156
+ invokeLectorVehicleOperation<GitHistoryGrepResult>(
157
+ "workspace.gitGrepHistory",
158
+ { workspaceId, pattern, pathspecs, ...bounds },
159
+ GIT_READ_PERMISSIONS,
160
+ call,
161
+ ),
162
+ );
163
+ },
142
164
  async listFiles(directory, ref, pathspecs, maxResults, call) {
143
165
  return withWorkspace(
144
166
  () => workspaceForDirectory(directory),
@@ -11,12 +11,24 @@ 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" | "show" | "grep-ref" | "ls-ref" | "is-ancestor";
14
+ export type GitAction =
15
+ | "status"
16
+ | "log"
17
+ | "diff"
18
+ | "compare-symbol"
19
+ | "worktree-add"
20
+ | "worktree-remove"
21
+ | "show"
22
+ | "grep-ref"
23
+ | "grep-history"
24
+ | "ls-ref"
25
+ | "is-ancestor";
15
26
 
16
27
  type SymbolComparison = OperationOutputs["workspace.compareSymbolAcrossVersions"];
17
28
  type GitWorktreeAddResult = OperationOutputs["workspace.gitWorktreeAdd"];
18
29
  type GitWorktreeRemoveResult = OperationOutputs["workspace.gitWorktreeRemove"];
19
30
  type GitGrepResult = OperationOutputs["workspace.gitGrep"];
31
+ type GitHistoryGrepResult = OperationOutputs["workspace.gitGrepHistory"];
20
32
  type GitListFilesResult = OperationOutputs["workspace.gitListFiles"];
21
33
 
22
34
  export interface GitToolDetails {
@@ -29,6 +41,7 @@ export interface GitToolDetails {
29
41
  readonly worktreeRemove?: GitWorktreeRemoveResult;
30
42
  readonly showFile?: { readonly ref: string; readonly path: string; readonly content: string | undefined };
31
43
  readonly grep?: GitGrepResult;
44
+ readonly historyGrep?: GitHistoryGrepResult;
32
45
  readonly listFiles?: GitListFilesResult;
33
46
  readonly isAncestor?: { readonly ancestorRef: string; readonly ref: string; readonly result: boolean };
34
47
  }
@@ -61,10 +74,10 @@ export function formatGitCall(
61
74
  const ref = typeof args.ref === "string" ? args.ref : "";
62
75
  return `${theme.fg("toolTitle", theme.bold("git"))} ${theme.fg("muted", action)} ${theme.fg("accent", directory)} ${ancestorRef} -> ${ref}`;
63
76
  }
64
- if (action === "grep-ref") {
65
- const ref = typeof args.ref === "string" ? args.ref : "";
77
+ if (action === "grep-ref" || action === "grep-history") {
78
+ const ref = action === "grep-ref" && typeof args.ref === "string" ? `${args.ref} ` : "";
66
79
  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}"`;
80
+ return `${theme.fg("toolTitle", theme.bold("git"))} ${theme.fg("muted", action)} ${theme.fg("accent", directory)} ${ref}"${pattern}"`;
68
81
  }
69
82
  if (action === "show") {
70
83
  const ref = typeof args.ref === "string" ? args.ref : "";
@@ -105,6 +118,30 @@ function formatGitGrepResult(result: GitGrepResult | undefined, expanded: boolea
105
118
  return lines.join("\n");
106
119
  }
107
120
 
121
+ function formatGitHistoryGrepResult(result: GitHistoryGrepResult | undefined, expanded: boolean, theme: LectorTheme): string {
122
+ if (!result || result.matches.length === 0) {
123
+ if (result?.deadlineReached) return theme.fg("warning", "No matches before the deadline.");
124
+ return theme.fg("dim", "No historical matches.");
125
+ }
126
+ const lines = renderTruncatedList({
127
+ items: result.matches,
128
+ expanded,
129
+ visibleCount: DEFAULT_VISIBLE_FILES,
130
+ formatItem: (match) => {
131
+ const occurrences = match.occurrences > 1 ? ` (${match.occurrences} commits)` : "";
132
+ return `${theme.fg("muted", match.commit.slice(0, 8))} ${theme.fg("accent", `${match.path}:${match.line}`)}:${match.text}${occurrences}`;
133
+ },
134
+ moreLine: moreLine(theme),
135
+ truncationWarning: result.deadlineReached
136
+ ? theme.fg("warning", "(deadline reached)")
137
+ : result.truncated
138
+ ? theme.fg("warning", "(bounded by maxMatches/maxBytes)")
139
+ : undefined,
140
+ });
141
+ if (result.nextCommitOffset !== undefined) lines.push(theme.fg("muted", `next commit offset: ${result.nextCommitOffset}`));
142
+ return lines.join("\n");
143
+ }
144
+
108
145
  function formatGitListFilesResult(result: GitListFilesResult | undefined, expanded: boolean, theme: LectorTheme): string {
109
146
  if (!result || result.paths.length === 0) return theme.fg("dim", "No files.");
110
147
  const lines = renderTruncatedList({
@@ -133,6 +170,7 @@ export function formatGitResult(details: GitToolDetails | undefined, expanded: b
133
170
  if (details.action === "worktree-remove") return formatGitWorktreeRemoveResult(details.worktreeRemove, theme);
134
171
  if (details.action === "show") return formatGitShowFileResult(details.showFile, theme);
135
172
  if (details.action === "grep-ref") return formatGitGrepResult(details.grep, expanded, theme);
173
+ if (details.action === "grep-history") return formatGitHistoryGrepResult(details.historyGrep, expanded, theme);
136
174
  if (details.action === "ls-ref") return formatGitListFilesResult(details.listFiles, expanded, theme);
137
175
  if (details.action === "is-ancestor") return formatGitIsAncestorResult(details.isAncestor, theme);
138
176
  return formatGitDiffResult(details.result, expanded, theme);
@@ -31,7 +31,7 @@ import type {
31
31
  WorkspaceLocation,
32
32
  WorkspaceMapResult,
33
33
  } from "@danypops/lector";
34
- import { DEFAULT_EXTERNAL_SEARCH_MAX_RESULTS, PACKAGE_ECOSYSTEMS } from "@danypops/lector";
34
+ import { codeActionPreviewId, DEFAULT_EXTERNAL_SEARCH_MAX_RESULTS, PACKAGE_ECOSYSTEMS } from "@danypops/lector";
35
35
  import {
36
36
  type AgentToolResult,
37
37
  createEditToolDefinition,
@@ -56,6 +56,12 @@ import {
56
56
  type CallHierarchyToolDetails,
57
57
  formatCallHierarchyCall,
58
58
  formatCallHierarchyResult,
59
+ formatCodeActionApplyCall,
60
+ formatCodeActionApplyResult,
61
+ formatCodeActionPreviewCall,
62
+ formatCodeActionPreviewResult,
63
+ formatDiagnosticDeltaCall,
64
+ formatDiagnosticDeltaResult,
59
65
  formatDiagnosticsCall,
60
66
  formatDiagnosticsResult,
61
67
  formatDocumentSymbolsCall,
@@ -68,8 +74,12 @@ import {
68
74
  formatGoToImplementationResult,
69
75
  formatHoverCall,
70
76
  formatHoverResult,
77
+ formatImpactAnalysisCall,
78
+ formatImpactAnalysisResult,
71
79
  formatReachableFromCall,
72
80
  formatReachableFromResult,
81
+ formatTypeHierarchyCall,
82
+ formatTypeHierarchyResult,
73
83
  formatWorkspaceMapCall,
74
84
  formatWorkspaceMapResult,
75
85
  } from "./code-intelligence/rendering.ts";
@@ -774,6 +784,186 @@ export default function (pi: ExtensionAPI) {
774
784
  },
775
785
  });
776
786
 
787
+ registerLectorTool({
788
+ name: "code_action_preview",
789
+ label: "Code Action Preview",
790
+ description:
791
+ "Request bounded language-server code actions for one range. Returns opaque preview ids, affected files, exact edits, provenance, and truncation status. This operation is read-only; use code_action_apply separately to mutate files.",
792
+ promptSnippet: "Preview bounded language-server fixes for one range",
793
+ promptGuidelines: ["Preview first, inspect every affected path/edit, then pass exactly one returned previewId to code_action_apply."],
794
+ parameters: Type.Object({
795
+ path: Type.String({ description: "Absolute or cwd-relative file path" }),
796
+ startLine: Type.Number({ description: "1-indexed range start line" }),
797
+ startCharacter: Type.Number({ description: "1-indexed range start character" }),
798
+ endLine: Type.Number({ description: "1-indexed range end line" }),
799
+ endCharacter: Type.Number({ description: "1-indexed range end character" }),
800
+ only: Type.Optional(Type.Array(Type.String(), { maxItems: 20, description: "Code-action kinds such as quickfix" })),
801
+ includeCommandActions: Type.Optional(Type.Boolean({ description: "Include command-only actions in preview; guarded apply still denies them" })),
802
+ maxActions: Type.Number({ description: "Maximum actions" }),
803
+ maxEdits: Type.Number({ description: "Maximum edits per action" }),
804
+ maxFiles: Type.Number({ description: "Maximum affected files per action" }),
805
+ maxBytes: Type.Number({ description: "Maximum response JSON bytes" }),
806
+ deadlineMs: Type.Number({ description: "Wall-clock deadline in milliseconds" }),
807
+ }),
808
+ async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<OperationOutputs["workspace.previewCodeActions"]>> {
809
+ const path = resolve(cwd, params.path);
810
+ const vehicleCall: LectorVehicleCall = { toolName: "code_action_preview", toolCallId, signal, context: ctx };
811
+ const result = await codeIntelligenceOperations.previewCodeActions(
812
+ path,
813
+ {
814
+ range: {
815
+ start: { line: params.startLine, character: params.startCharacter },
816
+ end: { line: params.endLine, character: params.endCharacter },
817
+ },
818
+ ...(params.only ? { only: params.only } : {}),
819
+ ...(params.includeCommandActions !== undefined ? { includeCommandActions: params.includeCommandActions } : {}),
820
+ maxActions: params.maxActions,
821
+ maxEdits: params.maxEdits,
822
+ maxFiles: params.maxFiles,
823
+ maxBytes: params.maxBytes,
824
+ deadlineMs: params.deadlineMs,
825
+ },
826
+ vehicleCall,
827
+ );
828
+ const text = result.actions
829
+ .map((action) => `${action.id} ${action.kind ?? "action"} ${action.title} -- ${action.affectedPaths.join(", ") || "command only"}`)
830
+ .join("\n");
831
+ return { content: [{ type: "text", text: text || "No code actions." }], details: result };
832
+ },
833
+ renderCall(args, theme, context) {
834
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
835
+ text.setText(formatCodeActionPreviewCall(args, theme));
836
+ return text;
837
+ },
838
+ renderResult(result, { expanded, isPartial }, theme, context) {
839
+ if (isPartial) return new Text(theme.fg("warning", "Finding code actions..."), 0, 0);
840
+ if (context.isError) {
841
+ const errorText = result.content
842
+ .filter((block) => block.type === "text")
843
+ .map((block) => block.text)
844
+ .join("\n");
845
+ return new Text(theme.fg("error", errorText || "code_action_preview failed"), 0, 0);
846
+ }
847
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
848
+ text.setText(formatCodeActionPreviewResult(result.details, expanded, theme));
849
+ return text;
850
+ },
851
+ });
852
+
853
+ registerLectorTool({
854
+ name: "code_action_apply",
855
+ label: "Apply Code Action",
856
+ description:
857
+ "Apply one previously previewed language-server WorkspaceEdit atomically with workspace containment, document-version and content-hash guards. Returns a transaction id for exact transaction-aware revert. Command-only actions are denied.",
858
+ promptSnippet: "Atomically apply one previewed language-server edit",
859
+ promptGuidelines: ["Use only after inspecting code_action_preview; retain transactionId for diagnostic_delta and revert."],
860
+ parameters: Type.Object({
861
+ path: Type.String({ description: "The same file path used to obtain the preview" }),
862
+ previewId: Type.String({ description: "Opaque id returned by code_action_preview" }),
863
+ }),
864
+ async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<OperationOutputs["workspace.applyCodeAction"]>> {
865
+ const vehicleCall: LectorVehicleCall = { toolName: "code_action_apply", toolCallId, signal, context: ctx };
866
+ const result = await codeIntelligenceOperations.applyCodeAction(resolve(cwd, params.path), codeActionPreviewId(params.previewId), vehicleCall);
867
+ const text = [
868
+ ...result.touchedPaths,
869
+ ...(result.transactionId ? [`transaction ${result.transactionId}`] : []),
870
+ ...(result.pendingCommand ? [`pending command ${result.pendingCommand.command}`] : []),
871
+ ].join("\n");
872
+ return { content: [{ type: "text", text: text || "Code action made no file changes." }], details: result };
873
+ },
874
+ renderCall(args, theme, context) {
875
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
876
+ text.setText(formatCodeActionApplyCall(args, theme));
877
+ return text;
878
+ },
879
+ renderResult(result, { isPartial }, theme, context) {
880
+ if (isPartial) return new Text(theme.fg("warning", "Applying code action..."), 0, 0);
881
+ if (context.isError) {
882
+ const errorText = result.content
883
+ .filter((block) => block.type === "text")
884
+ .map((block) => block.text)
885
+ .join("\n");
886
+ return new Text(theme.fg("error", errorText || "code_action_apply failed"), 0, 0);
887
+ }
888
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
889
+ text.setText(formatCodeActionApplyResult(result.details, theme));
890
+ return text;
891
+ },
892
+ });
893
+
894
+ registerLectorTool({
895
+ name: "diagnostic_delta",
896
+ label: "Diagnostic Delta",
897
+ description:
898
+ "Return only diagnostics introduced, resolved, or changed by one atomic mutation transaction or git-ref diff across its bounded affected-file cone, with completeness and provenance. Transaction results include the exact safe revert operation.",
899
+ promptSnippet: "Inspect diagnostic changes caused by a transaction or git diff",
900
+ promptGuidelines: [
901
+ "Use source=transaction with the id returned by an atomic mutation, or source=git with a baseline ref; unchanged diagnostics are omitted.",
902
+ ],
903
+ parameters: Type.Object({
904
+ path: Type.String({ description: "Project directory or a path inside it" }),
905
+ source: Type.String({ description: "transaction | git" }),
906
+ sourceId: Type.String({ description: "Mutation transaction id or git baseline ref" }),
907
+ maxResults: Type.Optional(Type.Number({ description: "Maximum results per delta class" })),
908
+ maxBytes: Type.Optional(Type.Number({ description: "Maximum response JSON bytes" })),
909
+ maxDepth: Type.Optional(Type.Number({ description: "Git source: maximum impact depth" })),
910
+ maxNodes: Type.Optional(Type.Number({ description: "Git source: maximum graph nodes" })),
911
+ maxEdges: Type.Optional(Type.Number({ description: "Git source: maximum graph edges" })),
912
+ deadlineMs: Type.Optional(Type.Number({ description: "Git source: wall-clock deadline" })),
913
+ maxFiles: Type.Optional(Type.Number({ description: "Git source: maximum graph files" })),
914
+ maxSymbolsPerFile: Type.Optional(Type.Number({ description: "Git source: maximum declarations per file" })),
915
+ autoPopulate: Type.Optional(Type.Boolean({ description: "Git source: populate a missing/stale graph" })),
916
+ }),
917
+ async execute(_toolCallId, params): Promise<AgentToolResult<OperationOutputs["workspace.diagnosticDelta"]>> {
918
+ const source =
919
+ params.source === "transaction"
920
+ ? ({ kind: "transaction", transactionId: params.sourceId } as const)
921
+ : params.source === "git"
922
+ ? ({ kind: "git", ref: params.sourceId } as const)
923
+ : undefined;
924
+ if (!source) throw new TypeError("diagnostic_delta source must be transaction or git");
925
+ const result = await codeIntelligenceOperations.diagnosticDelta(resolve(cwd, params.path), source, {
926
+ maxResults: params.maxResults,
927
+ maxBytes: params.maxBytes,
928
+ maxDepth: params.maxDepth,
929
+ maxNodes: params.maxNodes,
930
+ maxEdges: params.maxEdges,
931
+ deadlineMs: params.deadlineMs,
932
+ maxFiles: params.maxFiles,
933
+ maxSymbolsPerFile: params.maxSymbolsPerFile,
934
+ autoPopulate: params.autoPopulate,
935
+ });
936
+ const text = [
937
+ ...result.introduced.map(
938
+ (diagnostic) => `introduced ${diagnostic.severity} ${diagnostic.range.path}:${diagnostic.range.start.line} -- ${diagnostic.message}`,
939
+ ),
940
+ ...result.resolved.map(
941
+ (diagnostic) => `resolved ${diagnostic.severity} ${diagnostic.range.path}:${diagnostic.range.start.line} -- ${diagnostic.message}`,
942
+ ),
943
+ ...result.changed.map(({ before, after }) => `changed ${after.range.path}:${after.range.start.line} -- ${before.message} -> ${after.message}`),
944
+ ].join("\n");
945
+ return { content: [{ type: "text", text: text || "No diagnostic changes." }], details: result };
946
+ },
947
+ renderCall(args, theme, context) {
948
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
949
+ text.setText(formatDiagnosticDeltaCall(args, theme));
950
+ return text;
951
+ },
952
+ renderResult(result, { expanded, isPartial }, theme, context) {
953
+ if (isPartial) return new Text(theme.fg("warning", "Comparing diagnostics..."), 0, 0);
954
+ if (context.isError) {
955
+ const errorText = result.content
956
+ .filter((block) => block.type === "text")
957
+ .map((block) => block.text)
958
+ .join("\n");
959
+ return new Text(theme.fg("error", errorText || "diagnostic_delta failed"), 0, 0);
960
+ }
961
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
962
+ text.setText(formatDiagnosticDeltaResult(result.details, expanded, theme));
963
+ return text;
964
+ },
965
+ });
966
+
777
967
  registerLectorTool({
778
968
  name: "call_hierarchy",
779
969
  label: "Call Hierarchy",
@@ -840,6 +1030,137 @@ export default function (pi: ExtensionAPI) {
840
1030
  },
841
1031
  });
842
1032
 
1033
+ registerLectorTool({
1034
+ name: "type_hierarchy",
1035
+ label: "Type Hierarchy",
1036
+ description:
1037
+ "Resolve a type at an exact file position, or list its direct supertypes or subtypes. ACTIONS: prepare, supertypes, subtypes. Capability-unavailable is reported distinctly from an empty hierarchy.",
1038
+ promptSnippet: "Resolve a type hierarchy at an exact position",
1039
+ promptGuidelines: ["Use supertypes/subtypes instead of grepping for extends or implements; the language server resolves semantic relationships."],
1040
+ parameters: Type.Object({
1041
+ direction: Type.String({ description: "prepare | supertypes | subtypes" }),
1042
+ ...positionParameters,
1043
+ maxResults: Type.Optional(Type.Number({ description: "Maximum hierarchy items (default 1000)" })),
1044
+ maxBytes: Type.Optional(Type.Number({ description: "Maximum JSON bytes (default 1 MiB)" })),
1045
+ deadlineMs: Type.Optional(Type.Number({ description: "Wall-clock deadline in milliseconds (default 10000, maximum 120000)" })),
1046
+ }),
1047
+ async execute(_toolCallId, params): Promise<AgentToolResult<OperationOutputs["workspace.prepareTypeHierarchy"]>> {
1048
+ const path = resolve(cwd, params.path);
1049
+ const bounds = { maxResults: params.maxResults, maxBytes: params.maxBytes, deadlineMs: params.deadlineMs };
1050
+ const result =
1051
+ params.direction === "prepare"
1052
+ ? await codeIntelligenceOperations.prepareTypeHierarchy(path, params.line, params.character, bounds)
1053
+ : params.direction === "supertypes"
1054
+ ? await codeIntelligenceOperations.supertypes(path, params.line, params.character, bounds)
1055
+ : params.direction === "subtypes"
1056
+ ? await codeIntelligenceOperations.subtypes(path, params.line, params.character, bounds)
1057
+ : undefined;
1058
+ if (!result) throw new Error(`unknown type_hierarchy direction: ${String(params.direction)}`);
1059
+ const text =
1060
+ result.items.length === 0
1061
+ ? "No type-hierarchy items found."
1062
+ : result.items.map((item) => `${item.kind} ${item.name} -- ${item.location.path}:${item.location.line}:${item.location.character}`).join("\n");
1063
+ return { content: [{ type: "text", text: `${describeIntelligenceSource(result.provenance)}\n${text}` }], details: result };
1064
+ },
1065
+ renderCall(args, theme, context) {
1066
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1067
+ text.setText(formatTypeHierarchyCall(args, theme));
1068
+ return text;
1069
+ },
1070
+ renderResult(result, { expanded, isPartial }, theme, context) {
1071
+ if (isPartial) return new Text(theme.fg("warning", "Resolving type hierarchy..."), 0, 0);
1072
+ if (context.isError) {
1073
+ const errorText = result.content
1074
+ .filter((block) => block.type === "text")
1075
+ .map((block) => block.text)
1076
+ .join("\n");
1077
+ return new Text(theme.fg("error", errorText || "type_hierarchy failed"), 0, 0);
1078
+ }
1079
+ const details = result.details;
1080
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1081
+ text.setText(renderIntelligenceSource(formatTypeHierarchyResult(details, expanded, theme), details.provenance, theme));
1082
+ return text;
1083
+ },
1084
+ });
1085
+
1086
+ registerLectorTool({
1087
+ name: "impact_analysis",
1088
+ label: "Impact Analysis",
1089
+ description:
1090
+ "Map a git diff or mutation transaction to changed declarations, semantic callers/references, package boundaries, diagnostics, and related tests. Associations preserve semantic-edge versus filename-heuristic evidence.",
1091
+ promptSnippet: "Find symbols and tests affected by a change",
1092
+ promptGuidelines: [
1093
+ "Use after a mutation or against a git ref to choose targeted verification from explicit evidence rather than filename guesses alone.",
1094
+ ],
1095
+ parameters: Type.Object({
1096
+ path: Type.String({ description: "Project directory or a path inside it" }),
1097
+ source: Type.String({ description: "git | mutation" }),
1098
+ ref: Type.Optional(Type.String({ description: "Git ref for source=git" })),
1099
+ transactionId: Type.Optional(Type.String({ description: "Mutation transaction id for source=mutation" })),
1100
+ maxDepth: Type.Number({ description: "Maximum reverse-graph hops" }),
1101
+ maxNodes: Type.Number({ description: "Maximum graph nodes" }),
1102
+ maxEdges: Type.Number({ description: "Maximum graph edges" }),
1103
+ maxBytes: Type.Number({ description: "Maximum response JSON bytes" }),
1104
+ deadlineMs: Type.Number({ description: "Wall-clock deadline in milliseconds" }),
1105
+ maxFiles: Type.Number({ description: "Maximum source files for graph freshness/population" }),
1106
+ maxSymbolsPerFile: Type.Number({ description: "Maximum declarations per source file" }),
1107
+ autoPopulate: Type.Optional(Type.Boolean({ description: "Populate once when no complete graph exists" })),
1108
+ coverage: Type.Optional(
1109
+ Type.Array(
1110
+ Type.Object({
1111
+ testPath: Type.String(),
1112
+ coveredPaths: Type.Array(Type.String(), { maxItems: 1000 }),
1113
+ }),
1114
+ { maxItems: 1000, description: "Optional test-to-covered-source evidence" },
1115
+ ),
1116
+ ),
1117
+ }),
1118
+ async execute(_toolCallId, params): Promise<AgentToolResult<OperationOutputs["workspace.impactAnalysis"]>> {
1119
+ const source =
1120
+ params.source === "git"
1121
+ ? ({ kind: "git", ...(params.ref !== undefined ? { ref: params.ref } : {}) } as const)
1122
+ : params.source === "mutation" && params.transactionId
1123
+ ? ({ kind: "mutation", transactionId: params.transactionId } as const)
1124
+ : undefined;
1125
+ if (!source) throw new TypeError("impact_analysis requires source=git or source=mutation with transactionId");
1126
+ const result = await codeIntelligenceOperations.impactAnalysis(resolve(cwd, params.path), source, {
1127
+ maxDepth: params.maxDepth,
1128
+ maxNodes: params.maxNodes,
1129
+ maxEdges: params.maxEdges,
1130
+ maxBytes: params.maxBytes,
1131
+ deadlineMs: params.deadlineMs,
1132
+ maxFiles: params.maxFiles,
1133
+ maxSymbolsPerFile: params.maxSymbolsPerFile,
1134
+ ...(params.autoPopulate !== undefined ? { autoPopulate: params.autoPopulate } : {}),
1135
+ ...(params.coverage !== undefined ? { coverage: params.coverage } : {}),
1136
+ });
1137
+ const text = [
1138
+ ...result.changedSymbols.map(({ symbol, side }) => `changed ${side} ${symbol.kind} ${symbol.name} -- ${symbol.location.path}`),
1139
+ ...result.impactedSymbols.map(({ symbol, depth }) => `impact depth=${depth} ${symbol.kind} ${symbol.name} -- ${symbol.location.path}`),
1140
+ ...result.relatedTests.map(({ symbol, evidence }) => `test ${evidence.kind} -- ${symbol.location.path}`),
1141
+ ].join("\n");
1142
+ return { content: [{ type: "text", text: text || "No changed symbols resolved." }], details: result };
1143
+ },
1144
+ renderCall(args, theme, context) {
1145
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1146
+ text.setText(formatImpactAnalysisCall(args, theme));
1147
+ return text;
1148
+ },
1149
+ renderResult(result, { expanded, isPartial }, theme, context) {
1150
+ if (isPartial) return new Text(theme.fg("warning", "Analyzing impact..."), 0, 0);
1151
+ if (context.isError) {
1152
+ const errorText = result.content
1153
+ .filter((block) => block.type === "text")
1154
+ .map((block) => block.text)
1155
+ .join("\n");
1156
+ return new Text(theme.fg("error", errorText || "impact_analysis failed"), 0, 0);
1157
+ }
1158
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1159
+ text.setText(formatImpactAnalysisResult(result.details, expanded, theme));
1160
+ return text;
1161
+ },
1162
+ });
1163
+
843
1164
  registerLectorTool({
844
1165
  name: "reference_based_rename",
845
1166
  label: "Reference-Based Rename",
@@ -1012,6 +1333,9 @@ export default function (pi: ExtensionAPI) {
1012
1333
  childId: Type.Optional(Type.String({ description: "The contained annotation's id -- required for contain/uncontain" })),
1013
1334
  rootId: Type.Optional(Type.String({ description: "The subtree's root annotation id -- required for tree" })),
1014
1335
  maxDepth: Type.Optional(Type.Number({ description: "Maximum containment hops from rootId to include -- required for tree" })),
1336
+ autoPopulate: Type.Optional(Type.Boolean({ description: "For create/refresh: populate a genuinely not-cached graph once before resolving anchors" })),
1337
+ maxFiles: Type.Optional(Type.Number({ description: "For create/refresh autoPopulate: explicit maximum files to scan" })),
1338
+ maxSymbolsPerFile: Type.Optional(Type.Number({ description: "For create/refresh autoPopulate: explicit maximum declarations per file" })),
1015
1339
  }),
1016
1340
  async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<SymbolAnnotationToolDetails>> {
1017
1341
  const path = resolve(cwd, params.path);
@@ -1034,6 +1358,11 @@ export default function (pi: ExtensionAPI) {
1034
1358
  params.body,
1035
1359
  resolveAnchorInputs(params.anchors),
1036
1360
  vehicleCall,
1361
+ {
1362
+ ...(params.autoPopulate !== undefined ? { autoPopulate: params.autoPopulate } : {}),
1363
+ ...(params.maxFiles !== undefined ? { maxFiles: params.maxFiles } : {}),
1364
+ ...(params.maxSymbolsPerFile !== undefined ? { maxSymbolsPerFile: params.maxSymbolsPerFile } : {}),
1365
+ },
1037
1366
  );
1038
1367
  details.annotation = annotation;
1039
1368
  text = formatAnnotationDetail(annotation);
@@ -1063,6 +1392,11 @@ export default function (pi: ExtensionAPI) {
1063
1392
  params.body,
1064
1393
  resolveAnchorInputs(params.anchors),
1065
1394
  vehicleCall,
1395
+ {
1396
+ ...(params.autoPopulate !== undefined ? { autoPopulate: params.autoPopulate } : {}),
1397
+ ...(params.maxFiles !== undefined ? { maxFiles: params.maxFiles } : {}),
1398
+ ...(params.maxSymbolsPerFile !== undefined ? { maxSymbolsPerFile: params.maxSymbolsPerFile } : {}),
1399
+ },
1066
1400
  );
1067
1401
  details.annotation = annotation;
1068
1402
  text = annotation ? formatAnnotationDetail(annotation) : `no annotation "${params.id}"`;
@@ -1169,10 +1503,17 @@ export default function (pi: ExtensionAPI) {
1169
1503
  description: "Restrict to one edge kind; omit for any kind",
1170
1504
  }),
1171
1505
  ),
1506
+ autoPopulate: Type.Optional(Type.Boolean({ description: "Populate a genuinely not-cached graph once before traversing" })),
1507
+ maxFiles: Type.Optional(Type.Number({ description: "For autoPopulate: explicit maximum files to scan" })),
1508
+ maxSymbolsPerFile: Type.Optional(Type.Number({ description: "For autoPopulate: explicit maximum declarations per file" })),
1172
1509
  }),
1173
1510
  async execute(_toolCallId, params) {
1174
1511
  const path = resolve(cwd, params.path);
1175
- const symbols = await codeIntelligenceOperations.reachableFrom(path, params.line, params.character, params.maxDepth, params.kind);
1512
+ const symbols = await codeIntelligenceOperations.reachableFrom(path, params.line, params.character, params.maxDepth, params.kind, {
1513
+ ...(params.autoPopulate !== undefined ? { autoPopulate: params.autoPopulate } : {}),
1514
+ ...(params.maxFiles !== undefined ? { maxFiles: params.maxFiles } : {}),
1515
+ ...(params.maxSymbolsPerFile !== undefined ? { maxSymbolsPerFile: params.maxSymbolsPerFile } : {}),
1516
+ });
1176
1517
  const text =
1177
1518
  symbols.length === 0
1178
1519
  ? "Nothing reachable at this position."
@@ -1354,18 +1695,20 @@ export default function (pi: ExtensionAPI) {
1354
1695
  name: "git",
1355
1696
  label: "Git",
1356
1697
  description:
1357
- "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).",
1698
+ "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), grep-history (bounded extended-regex search across commit trees reachable from all refs, with topological paging and commit provenance), 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).",
1358
1699
  promptSnippet:
1359
1700
  "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",
1360
1701
  promptGuidelines: [
1361
- "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.",
1702
+ "maxCount is required for action=log; maxBytes is required for action=diff/compare-symbol/grep-ref/grep-history -- every bounded query needs its bound stated explicitly, never defaulted silently.",
1362
1703
  "path, symbol, and fromRef are required for action=compare-symbol; toRef is optional and means 'the current working tree' when omitted.",
1363
1704
  "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.",
1364
1705
  "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.",
1365
- "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.",
1706
+ "ref and path are required for action=show; ref and pattern (maxMatches, maxBytes) are required for action=grep-ref; pattern, commitOffset, maxCommits, maxMatches, maxBytes, and deadlineMs are required for action=grep-history; ref (maxResults) is required for action=ls-ref; ancestorRef and ref are required for action=is-ancestor. These actions inspect Git objects directly with no checkout.",
1366
1707
  ],
1367
1708
  parameters: Type.Object({
1368
- action: Type.String({ description: "status | log | diff | compare-symbol | show | grep-ref | ls-ref | is-ancestor | worktree-add | worktree-remove" }),
1709
+ action: Type.String({
1710
+ description: "status | log | diff | compare-symbol | show | grep-ref | grep-history | ls-ref | is-ancestor | worktree-add | worktree-remove",
1711
+ }),
1369
1712
  directory: Type.String({ description: "Directory inside the repository to check, absolute or relative to the current working directory" }),
1370
1713
  maxCount: Type.Optional(Type.Number({ description: "Maximum number of commits to return, most recent first -- required for action=log" })),
1371
1714
  ref: Type.Optional(
@@ -1376,7 +1719,9 @@ export default function (pi: ExtensionAPI) {
1376
1719
  ),
1377
1720
  maxBytes: Type.Optional(
1378
1721
  Type.Number({
1379
- description: "Maximum diff/comparison/grep output size in bytes before truncating -- required for action=diff/compare-symbol/grep-ref",
1722
+ minimum: 1,
1723
+ maximum: 8 * 1024 * 1024,
1724
+ description: "Maximum diff/comparison/grep output size in bytes before truncating -- required for action=diff/compare-symbol/grep-ref/grep-history",
1380
1725
  }),
1381
1726
  ),
1382
1727
  path: Type.Optional(
@@ -1392,14 +1737,24 @@ export default function (pi: ExtensionAPI) {
1392
1737
  description: "action=worktree-add only: recreate an already-reused worktree at ref's current tip instead of returning the existing one",
1393
1738
  }),
1394
1739
  ),
1395
- pattern: Type.Optional(Type.String({ description: "Text pattern to search for -- required for action=grep-ref" })),
1740
+ pattern: Type.Optional(
1741
+ Type.String({ maxLength: 4096, description: "Pattern to search for -- required for grep-ref; an extended regular expression for grep-history" }),
1742
+ ),
1396
1743
  pathspecs: Type.Optional(
1397
- Type.Array(Type.String(), {
1744
+ Type.Array(Type.String({ minLength: 1, maxLength: 1024 }), {
1745
+ maxItems: 64,
1398
1746
  description:
1399
- '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',
1747
+ 'Narrows action=grep-ref/grep-history (glob-based, e.g. "*.go") or action=ls-ref (prefix-based, e.g. "pkg/dpll"); omitted searches/lists the whole tree',
1400
1748
  }),
1401
1749
  ),
1402
- maxMatches: Type.Optional(Type.Number({ description: "Maximum grep matches to return -- required for action=grep-ref" })),
1750
+ maxMatches: Type.Optional(
1751
+ Type.Number({ minimum: 1, maximum: 10_000, description: "Maximum grep matches to return -- required for action=grep-ref/grep-history" }),
1752
+ ),
1753
+ commitOffset: Type.Optional(
1754
+ Type.Number({ minimum: 0, maximum: 1_000_000, description: "Number of topologically ordered commits to skip -- required for action=grep-history" }),
1755
+ ),
1756
+ maxCommits: Type.Optional(Type.Number({ minimum: 1, maximum: 512, description: "Maximum commit trees to search -- required for action=grep-history" })),
1757
+ deadlineMs: Type.Optional(Type.Number({ minimum: 1, maximum: 120_000, description: "Wall-clock budget -- required for action=grep-history" })),
1403
1758
  maxResults: Type.Optional(Type.Number({ description: "Maximum file paths to return -- required for action=ls-ref" })),
1404
1759
  ancestorRef: Type.Optional(Type.String({ description: "The candidate ancestor ref -- required for action=is-ancestor" })),
1405
1760
  }),
@@ -1456,6 +1811,32 @@ export default function (pi: ExtensionAPI) {
1456
1811
  const details: GitToolDetails = { action: "grep-ref", grep };
1457
1812
  return { content: [{ type: "text", text: JSON.stringify(grep) }], details };
1458
1813
  }
1814
+ if (params.action === "grep-history") {
1815
+ if (!params.pattern) throw new Error("git action=grep-history requires pattern");
1816
+ if (
1817
+ params.commitOffset === undefined ||
1818
+ params.maxCommits === undefined ||
1819
+ params.maxMatches === undefined ||
1820
+ params.maxBytes === undefined ||
1821
+ params.deadlineMs === undefined
1822
+ )
1823
+ throw new Error("git action=grep-history requires commitOffset, maxCommits, maxMatches, maxBytes, and deadlineMs");
1824
+ const historyGrep = await gitOperations.grepHistory(
1825
+ directory,
1826
+ params.pattern,
1827
+ params.pathspecs,
1828
+ {
1829
+ commitOffset: params.commitOffset,
1830
+ maxCommits: params.maxCommits,
1831
+ maxMatches: params.maxMatches,
1832
+ maxBytes: params.maxBytes,
1833
+ deadlineMs: params.deadlineMs,
1834
+ },
1835
+ vehicleCall,
1836
+ );
1837
+ const details: GitToolDetails = { action: "grep-history", historyGrep };
1838
+ return { content: [{ type: "text", text: JSON.stringify(historyGrep) }], details };
1839
+ }
1459
1840
  if (params.action === "ls-ref") {
1460
1841
  if (!params.ref) throw new Error("git action=ls-ref requires ref");
1461
1842
  if (params.maxResults === undefined) throw new Error("git action=ls-ref requires maxResults");
@@ -1497,7 +1878,7 @@ export default function (pi: ExtensionAPI) {
1497
1878
  name: "search_code",
1498
1879
  label: "Search Code",
1499
1880
  description:
1500
- "Multi-file text/regex search scoped to a real project directory, backed by ripgrep -- respects .gitignore, skips node_modules/.git/build output. Bounded by maxMatches and maxBytes; results are cached.",
1881
+ "Multi-file text/regex search scoped to a real project directory. Uses a bounded resident FFF index when ready and explicit ripgrep loading/stale/degraded fallback otherwise; respects ignore rules and skips generated dependency output. Bounded by maxMatches and maxBytes; results include lexical provenance.",
1501
1882
  promptSnippet: "Search a project's files for a pattern",
1502
1883
  parameters: Type.Object({
1503
1884
  directory: Type.String({ description: "Directory inside the project to search, absolute or relative to the current working directory" }),
@@ -1508,8 +1889,10 @@ export default function (pi: ExtensionAPI) {
1508
1889
  async execute(_toolCallId, params) {
1509
1890
  const directory = resolve(cwd, params.directory);
1510
1891
  const result = await searchOperations.search(params.query, directory, params.maxMatches, params.maxBytes);
1511
- const text =
1892
+ const matches =
1512
1893
  result.matches.length === 0 ? "No matches found." : result.matches.map((m) => `${m.path}:${m.lineNumber}: ${m.line.replace(/\n$/, "")}`).join("\n");
1894
+ const source = result.provenance ? `lexical via ${result.provenance.backend} (${result.provenance.indexState})\n` : "";
1895
+ const text = `${source}${matches}`;
1513
1896
  return { content: [{ type: "text", text }], details: { result } };
1514
1897
  },
1515
1898
  renderCall(args, theme, context) {
@@ -2114,7 +2497,7 @@ export default function (pi: ExtensionAPI) {
2114
2497
  name: "search_code_across_projects",
2115
2498
  label: "Search Code Across Projects",
2116
2499
  description:
2117
- "Fans out a ripgrep-backed text/regex search across several explicitly-named project directories at once and reports one outcome per project. Directories are required and explicit -- never every project this daemon happens to have registered, which can include unrelated projects from other concurrent sessions. Each directory resolves to its OWN nearest project root (package.json/tsconfig.json/go.mod/Cargo.toml/...), not the outer repo's git root -- sibling packages under one monorepo stay distinct scopes rather than collapsing into one. A result's collapsedWith lists any other requested directories that genuinely did resolve to the same workspace; empty means it got its own.",
2500
+ "Fans out bounded indexed lexical text/regex search with explicit ripgrep fallback provenance across several explicitly-named project directories at once and reports one outcome per project. Directories are required and explicit -- never every project this daemon happens to have registered, which can include unrelated projects from other concurrent sessions. Each directory resolves to its OWN nearest project root (package.json/tsconfig.json/go.mod/Cargo.toml/...), not the outer repo's git root -- sibling packages under one monorepo stay distinct scopes rather than collapsing into one. A result's collapsedWith lists any other requested directories that genuinely did resolve to the same workspace; empty means it got its own.",
2118
2501
  promptSnippet: "Search for a pattern across several projects at once",
2119
2502
  parameters: Type.Object({
2120
2503
  directories: Type.Array(Type.String(), { description: "Project directories to search, each absolute or relative to the current working directory" }),
@@ -12,7 +12,9 @@ export function formatSearchCall(args: { directory?: unknown; query?: unknown },
12
12
  }
13
13
 
14
14
  export function formatSearchResult(result: TextSearchResult | undefined, expanded: boolean, theme: LectorTheme): string {
15
- if (!result || result.matches.length === 0) return theme.fg("dim", "No matches found.");
15
+ if (!result) return theme.fg("dim", "No matches found.");
16
+ const provenance = result.provenance ? theme.fg("dim", `lexical via ${result.provenance.backend} (${result.provenance.indexState})`) : undefined;
17
+ if (result.matches.length === 0) return [provenance, theme.fg("dim", "No matches found.")].filter((line) => line !== undefined).join("\n");
16
18
  const lines = renderTruncatedList({
17
19
  items: result.matches,
18
20
  expanded,
@@ -22,5 +24,5 @@ export function formatSearchResult(result: TextSearchResult | undefined, expande
22
24
  moreLine: (hidden) => theme.fg("dim", `... ${hidden} more (${keyHint("app.tools.expand", "to expand")})`),
23
25
  truncationWarning: result.truncated ? theme.fg("warning", "(search itself was truncated by maxMatches/maxBytes -- results are incomplete)") : undefined,
24
26
  });
25
- return lines.join("\n");
27
+ return [provenance, ...lines].filter((line) => line !== undefined).join("\n");
26
28
  }
@@ -13,6 +13,8 @@ export interface AnnotationAnchorInput {
13
13
  readonly character: number;
14
14
  }
15
15
 
16
+ export type AnnotationAutoPopulationOptions = Pick<OperationInputs["workspace.createAnnotation"], "autoPopulate" | "maxFiles" | "maxSymbolsPerFile">;
17
+
16
18
  /**
17
19
  * Thin wrappers over Lector's annotation operations. Every operation resolves its workspace from
18
20
  * its own `path` parameter via workspaceForAnnotationPath -- a real project directory or an
@@ -29,6 +31,7 @@ export interface SymbolAnnotationOperations {
29
31
  body: string,
30
32
  anchors: readonly AnnotationAnchorInput[],
31
33
  call: LectorVehicleCall,
34
+ autoPopulation?: AnnotationAutoPopulationOptions,
32
35
  ): Promise<OperationOutputs["workspace.createAnnotation"]>;
33
36
  get(path: string, id: string, call: LectorVehicleCall): Promise<OperationOutputs["workspace.getAnnotation"]>;
34
37
  list(
@@ -44,6 +47,7 @@ export interface SymbolAnnotationOperations {
44
47
  body: string,
45
48
  anchors: readonly AnnotationAnchorInput[],
46
49
  call: LectorVehicleCall,
50
+ autoPopulation?: AnnotationAutoPopulationOptions,
47
51
  ): Promise<OperationOutputs["workspace.refreshAnnotation"]>;
48
52
  scrub(path: string, id: string, call: LectorVehicleCall): Promise<OperationOutputs["workspace.scrubAnnotation"]>;
49
53
  restore(path: string, id: string, call: LectorVehicleCall): Promise<OperationOutputs["workspace.restoreAnnotation"]>;
@@ -54,13 +58,13 @@ export interface SymbolAnnotationOperations {
54
58
 
55
59
  export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperations {
56
60
  return {
57
- async create(path, subtype, title, body, anchors, call) {
61
+ async create(path, subtype, title, body, anchors, call, autoPopulation) {
58
62
  return withWorkspace(
59
63
  () => workspaceForAnnotationPath(path),
60
64
  ({ workspaceId }) =>
61
65
  invokeLectorVehicleOperation<OperationOutputs["workspace.createAnnotation"]>(
62
66
  "workspace.createAnnotation",
63
- { workspaceId, subtype, title, body, anchors },
67
+ { workspaceId, subtype, title, body, anchors, ...autoPopulation },
64
68
  ANNOTATION_WRITE_PERMISSIONS,
65
69
  call,
66
70
  ),
@@ -90,13 +94,13 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
90
94
  ),
91
95
  );
92
96
  },
93
- async refresh(path, id, subtype, title, body, anchors, call) {
97
+ async refresh(path, id, subtype, title, body, anchors, call, autoPopulation) {
94
98
  return withWorkspace(
95
99
  () => workspaceForAnnotationPath(path),
96
100
  ({ workspaceId }) =>
97
101
  invokeLectorVehicleOperation<OperationOutputs["workspace.refreshAnnotation"]>(
98
102
  "workspace.refreshAnnotation",
99
- { workspaceId, id, subtype, title, body, anchors },
103
+ { workspaceId, id, subtype, title, body, anchors, ...autoPopulation },
100
104
  ANNOTATION_WRITE_PERMISSIONS,
101
105
  call,
102
106
  ),
@@ -29,9 +29,11 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
29
29
 
30
30
  type VehicleClientConnector = () => Promise<VehicleClient>;
31
31
 
32
- function connectLectorVehicleClient(): Promise<VehicleClient> {
32
+ async function connectLectorVehicleClient(): Promise<VehicleClient> {
33
33
  const { host, port, token } = resolveLectorDaemonConnection();
34
- return Promise.resolve(new RemoteVehicleClient({ baseUrl: `http://${host}:${port}`, token }));
34
+ const client = new RemoteVehicleClient({ baseUrl: `http://${host}:${port}`, token });
35
+ await client.negotiate({ minimumVersion: 1, maximumVersion: 1, requiredCapabilities: [], optionalCapabilities: [] });
36
+ return client;
35
37
  }
36
38
 
37
39
  function resolveLectorVehicleIdentity() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.13.9",
3
+ "version": "0.13.11",
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,9 +22,9 @@
22
22
  "@danypops/vehicle-client-pi": "^0.45.0"
23
23
  },
24
24
  "dependencies": {
25
- "@danypops/lector": "^0.20.0",
26
- "@danypops/vehicle-client": "^0.10.3",
27
- "@danypops/vehicle-core": "^0.17.1",
25
+ "@danypops/lector": "^0.20.4",
26
+ "@danypops/vehicle-client": "^0.10.8",
27
+ "@danypops/vehicle-core": "^0.19.1",
28
28
  "malevich-tui-components": "^0.32.1",
29
29
  "picomatch": "^4.0.5"
30
30
  },