@danypops/pi-lector 0.13.9 → 0.14.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.
@@ -1,6 +1,7 @@
1
1
  import type { EditOutcome } from "@danypops/lector";
2
2
  import { renderDiffLines, renderTruncatedList, type TextMeasure } from "malevich-tui-components";
3
3
  import type { LectorTheme } from "../lector-tui-theme.ts";
4
+ import { presentationTitle } from "../presentation/tool-presentation.ts";
4
5
 
5
6
  /**
6
7
  * The applied result (EditOutcome) only carries a hash transition, not the
@@ -15,7 +16,7 @@ const DEFAULT_VISIBLE_PATCH_LINES = 12;
15
16
 
16
17
  export function formatApplyPatchCall(args: { path?: unknown; patchText?: unknown }, theme: LectorTheme, measure?: TextMeasure): string {
17
18
  const path = typeof args.path === "string" ? args.path : "";
18
- const header = `${theme.fg("toolTitle", theme.bold("apply_patch"))} ${theme.fg("accent", path)}`;
19
+ const header = `${theme.fg("toolTitle", theme.bold(presentationTitle("apply_patch")))} ${theme.fg("accent", path)}`;
19
20
  if (typeof args.patchText !== "string" || args.patchText.length === 0) return header;
20
21
 
21
22
  const styledLines = renderDiffLines(
@@ -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,
@@ -13,6 +14,7 @@ import type {
13
14
  import { keyHint, type ThemeColor } from "@earendil-works/pi-coding-agent";
14
15
  import { renderTruncatedList } from "malevich-tui-components";
15
16
  import { colorForKind, formatLocation, type LectorTheme } from "../lector-tui-theme.ts";
17
+ import { presentationTitle } from "../presentation/tool-presentation.ts";
16
18
 
17
19
  /**
18
20
  * Custom TUI rendering for the code-intelligence tools: go_to_definition,
@@ -27,6 +29,7 @@ const DEFAULT_VISIBLE_LOCATIONS = 8;
27
29
  const DEFAULT_VISIBLE_SYMBOLS = 12;
28
30
  const DEFAULT_VISIBLE_DIAGNOSTICS = 12;
29
31
  const DEFAULT_VISIBLE_CALLS = 12;
32
+ const DEFAULT_VISIBLE_CHANGES = 12;
30
33
 
31
34
  const DIAGNOSTIC_SEVERITY_COLOR: Record<Diagnostic["severity"], ThemeColor> = {
32
35
  error: "error",
@@ -39,7 +42,7 @@ function formatPositionalCall(toolName: string, args: { path?: unknown; line?: u
39
42
  const path = typeof args.path === "string" ? args.path : "";
40
43
  const line = typeof args.line === "number" ? args.line : "?";
41
44
  const character = typeof args.character === "number" ? args.character : "?";
42
- return `${theme.fg("toolTitle", theme.bold(toolName))} ${theme.fg("accent", `${path}:${line}:${character}`)}`;
45
+ return `${theme.fg("toolTitle", theme.bold(presentationTitle(toolName)))} ${theme.fg("accent", `${path}:${line}:${character}`)}`;
43
46
  }
44
47
 
45
48
  function moreLine(theme: LectorTheme): (hidden: number) => string {
@@ -108,7 +111,7 @@ export function formatHoverResult(hover: Hover | undefined, expanded: boolean, t
108
111
 
109
112
  export function formatDocumentSymbolsCall(args: { path?: unknown }, theme: LectorTheme): string {
110
113
  const path = typeof args.path === "string" ? args.path : "";
111
- return `${theme.fg("toolTitle", theme.bold("document_symbols"))} ${theme.fg("accent", path)}`;
114
+ return `${theme.fg("toolTitle", theme.bold(presentationTitle("document_symbols")))} ${theme.fg("accent", path)}`;
112
115
  }
113
116
 
114
117
  /** Flattens a hierarchical DocumentSymbolEntry[] into (depth, entry) pairs, depth-first, for bounded rendering. */
@@ -147,7 +150,7 @@ export function formatDocumentSymbolsResult(symbols: readonly DocumentSymbolEntr
147
150
 
148
151
  export function formatDiagnosticsCall(args: { path?: unknown }, theme: LectorTheme): string {
149
152
  const path = typeof args.path === "string" ? args.path : "";
150
- return `${theme.fg("toolTitle", theme.bold("diagnostics"))} ${theme.fg("accent", path)}`;
153
+ return `${theme.fg("toolTitle", theme.bold(presentationTitle("diagnostics")))} ${theme.fg("accent", path)}`;
151
154
  }
152
155
 
153
156
  export function formatDiagnosticsResult(diagnostics: readonly Diagnostic[] | undefined, expanded: boolean, theme: LectorTheme): string {
@@ -171,6 +174,127 @@ export function formatDiagnosticsResult(diagnostics: readonly Diagnostic[] | und
171
174
  return lines.join("\n");
172
175
  }
173
176
 
177
+ function formatPathCall(toolName: string, args: { path?: unknown }, theme: LectorTheme, qualifier = ""): string {
178
+ const path = typeof args.path === "string" ? args.path : "";
179
+ return `${theme.fg("toolTitle", theme.bold(presentationTitle(toolName)))}${qualifier ? ` ${theme.fg("muted", qualifier)}` : ""} ${theme.fg("accent", path)}`;
180
+ }
181
+
182
+ export function formatCodeActionPreviewCall(
183
+ args: { path?: unknown; startLine?: unknown; startCharacter?: unknown; endLine?: unknown; endCharacter?: unknown },
184
+ theme: LectorTheme,
185
+ ): string {
186
+ 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 : "?"}`;
187
+ return formatPathCall("code_action_preview", args, theme, range);
188
+ }
189
+
190
+ export function formatCodeActionPreviewResult(
191
+ result: OperationOutputs["workspace.previewCodeActions"] | undefined,
192
+ expanded: boolean,
193
+ theme: LectorTheme,
194
+ ): string {
195
+ if (!result || result.actions.length === 0) return theme.fg("dim", "No code actions.");
196
+ return [
197
+ theme.fg("muted", `${result.actions.length} code action${result.actions.length === 1 ? "" : "s"}:`),
198
+ ...renderTruncatedList({
199
+ items: result.actions,
200
+ expanded,
201
+ visibleCount: DEFAULT_VISIBLE_CHANGES,
202
+ formatItem: (action) => {
203
+ const state = action.disabledReason
204
+ ? theme.fg("warning", `disabled: ${action.disabledReason}`)
205
+ : action.preferred
206
+ ? theme.fg("success", "preferred")
207
+ : "";
208
+ const paths = action.affectedPaths.length > 0 ? action.affectedPaths.join(", ") : "command only";
209
+ return ` ${theme.bold(action.title)}${action.kind ? ` (${action.kind})` : ""} -- ${paths}${state ? ` -- ${state}` : ""}`;
210
+ },
211
+ moreLine: moreLine(theme),
212
+ truncationWarning: result.truncated || result.deadlineReached ? theme.fg("warning", "results truncated by the requested bounds") : undefined,
213
+ }),
214
+ ].join("\n");
215
+ }
216
+
217
+ export function formatCodeActionApplyCall(args: { path?: unknown }, theme: LectorTheme): string {
218
+ return formatPathCall("code_action_apply", args, theme);
219
+ }
220
+
221
+ export function formatCodeActionApplyResult(result: OperationOutputs["workspace.applyCodeAction"] | undefined, theme: LectorTheme): string {
222
+ if (!result) return theme.fg("dim", "No code-action result.");
223
+ const lines = result.touchedPaths.map((path) => ` ${path}`);
224
+ if (result.transactionId) lines.push(theme.fg("muted", `transaction ${result.transactionId}`));
225
+ if (result.pendingCommand) lines.push(theme.fg("warning", `pending command ${result.pendingCommand.command}`));
226
+ return lines.length > 0 ? lines.join("\n") : theme.fg("dim", "Code action made no file changes.");
227
+ }
228
+
229
+ export function formatDiagnosticDeltaCall(args: { path?: unknown; source?: unknown }, theme: LectorTheme): string {
230
+ return formatPathCall("diagnostic_delta", args, theme, typeof args.source === "string" ? args.source : "");
231
+ }
232
+
233
+ export function formatDiagnosticDeltaResult(result: OperationOutputs["workspace.diagnosticDelta"] | undefined, expanded: boolean, theme: LectorTheme): string {
234
+ if (!result) return theme.fg("dim", "No diagnostic delta.");
235
+ const changes = [
236
+ ...result.introduced.map((diagnostic) => ({ label: "introduced", diagnostic })),
237
+ ...result.resolved.map((diagnostic) => ({ label: "resolved", diagnostic })),
238
+ ...result.changed.map(({ after }) => ({ label: "changed", diagnostic: after })),
239
+ ];
240
+ if (changes.length === 0) return theme.fg("success", "No diagnostic changes.");
241
+ return renderTruncatedList({
242
+ items: changes,
243
+ expanded,
244
+ visibleCount: DEFAULT_VISIBLE_CHANGES,
245
+ formatItem: ({ label, diagnostic }) =>
246
+ `${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}`,
247
+ moreLine: moreLine(theme),
248
+ truncationWarning: result.truncated ? theme.fg("warning", "results truncated by maxResults/maxBytes") : undefined,
249
+ }).join("\n");
250
+ }
251
+
252
+ export function formatTypeHierarchyCall(args: { direction?: unknown; path?: unknown; line?: unknown; character?: unknown }, theme: LectorTheme): string {
253
+ const direction = typeof args.direction === "string" ? args.direction : undefined;
254
+ const path = typeof args.path === "string" ? args.path : "";
255
+ const line = typeof args.line === "number" ? args.line : "?";
256
+ const character = typeof args.character === "number" ? args.character : "?";
257
+ return `${theme.fg("toolTitle", theme.bold(presentationTitle("type_hierarchy", direction)))} ${theme.fg("accent", `${path}:${line}:${character}`)}`;
258
+ }
259
+
260
+ export function formatTypeHierarchyResult(
261
+ result: OperationOutputs["workspace.prepareTypeHierarchy"] | undefined,
262
+ expanded: boolean,
263
+ theme: LectorTheme,
264
+ ): string {
265
+ if (!result || result.items.length === 0) return theme.fg("dim", "No type-hierarchy items found.");
266
+ return renderTruncatedList({
267
+ items: result.items,
268
+ expanded,
269
+ visibleCount: DEFAULT_VISIBLE_CALLS,
270
+ formatItem: (item) => ` ${formatCallHierarchyEntry(item, theme)}${item.detail ? theme.fg("dim", ` -- ${item.detail}`) : ""}`,
271
+ moreLine: moreLine(theme),
272
+ truncationWarning: result.truncated ? theme.fg("warning", "results truncated by the requested bounds") : undefined,
273
+ }).join("\n");
274
+ }
275
+
276
+ export function formatImpactAnalysisCall(args: { path?: unknown; source?: unknown }, theme: LectorTheme): string {
277
+ return formatPathCall("impact_analysis", args, theme, typeof args.source === "string" ? args.source : "");
278
+ }
279
+
280
+ export function formatImpactAnalysisResult(result: OperationOutputs["workspace.impactAnalysis"] | undefined, expanded: boolean, theme: LectorTheme): string {
281
+ if (!result) return theme.fg("dim", "No impact-analysis result.");
282
+ const impacts = [
283
+ ...result.changedSymbols.map(({ symbol, side }) => ({ label: `changed ${side}`, symbol })),
284
+ ...result.impactedSymbols.map(({ symbol, depth }) => ({ label: `impact depth=${depth}`, symbol })),
285
+ ...result.relatedTests.map(({ symbol, evidence }) => ({ label: `test ${evidence.kind}`, symbol })),
286
+ ];
287
+ if (impacts.length === 0) return theme.fg("dim", "No changed symbols resolved.");
288
+ return renderTruncatedList({
289
+ items: impacts,
290
+ expanded,
291
+ visibleCount: DEFAULT_VISIBLE_CHANGES,
292
+ formatItem: ({ label, symbol }) => `${theme.fg("muted", label)} ${formatCallHierarchyEntry(symbol, theme)}`,
293
+ moreLine: moreLine(theme),
294
+ truncationWarning: result.truncated || result.deadlineReached ? theme.fg("warning", "analysis truncated by the requested bounds") : undefined,
295
+ }).join("\n");
296
+ }
297
+
174
298
  function formatCallHierarchyEntry(entry: { kind: string; name: string; location: WorkspaceLocation }, theme: LectorTheme): string {
175
299
  const kind = theme.fg(colorForKind(entry.kind), entry.kind);
176
300
  const name = theme.fg("text", theme.bold(entry.name));
@@ -192,7 +316,7 @@ export function formatCallHierarchyCall(args: { direction?: unknown; path?: unkn
192
316
  const path = typeof args.path === "string" ? args.path : "";
193
317
  const line = typeof args.line === "number" ? args.line : "?";
194
318
  const character = typeof args.character === "number" ? args.character : "?";
195
- return `${theme.fg("toolTitle", theme.bold("call_hierarchy"))} ${theme.fg("muted", direction)} ${theme.fg("accent", `${path}:${line}:${character}`)}`;
319
+ return `${theme.fg("toolTitle", theme.bold(presentationTitle("call_hierarchy", direction)))} ${theme.fg("accent", `${path}:${line}:${character}`)}`;
196
320
  }
197
321
 
198
322
  function formatPrepareCallHierarchyResult(items: readonly CallHierarchyEntry[] | undefined, theme: LectorTheme): string {
@@ -265,7 +389,7 @@ export function formatReachableFromResult(symbols: readonly SymbolNode[] | undef
265
389
  export function formatWorkspaceMapCall(args: { path?: unknown; maxEntries?: unknown }, theme: LectorTheme): string {
266
390
  const path = typeof args.path === "string" ? args.path : "";
267
391
  const maxEntries = typeof args.maxEntries === "number" ? ` (top ${args.maxEntries})` : "";
268
- return `${theme.fg("toolTitle", theme.bold("workspace_map"))} ${theme.fg("dim", path)}${theme.fg("muted", maxEntries)}`;
392
+ return `${theme.fg("toolTitle", theme.bold(presentationTitle("workspace_map")))} ${theme.fg("dim", path)}${theme.fg("muted", maxEntries)}`;
269
393
  }
270
394
 
271
395
  export function formatWorkspaceMapResult(result: WorkspaceMapResult | undefined, expanded: boolean, theme: LectorTheme): string {
@@ -3,14 +3,19 @@ import { keyHint } from "@earendil-works/pi-coding-agent";
3
3
  import { renderTruncatedList } from "malevich-tui-components";
4
4
  import { describeFindSymbolSources } from "../find-symbols/rendering.ts";
5
5
  import type { LectorTheme } from "../lector-tui-theme.ts";
6
+ import { presentationTitle } from "../presentation/tool-presentation.ts";
6
7
  import type { CrossWorkspaceOutcome } from "./operations.ts";
7
8
 
8
9
  const DEFAULT_VISIBLE_PER_WORKSPACE = 10;
9
10
 
10
- export function formatCrossWorkspaceCall(args: { directories?: unknown; query?: unknown }, theme: LectorTheme): string {
11
+ export function formatCrossWorkspaceCall(
12
+ toolName: "find_symbols_across_projects" | "search_code_across_projects",
13
+ args: { directories?: unknown; query?: unknown },
14
+ theme: LectorTheme,
15
+ ): string {
11
16
  const directories = Array.isArray(args.directories) ? args.directories.filter((d): d is string => typeof d === "string") : [];
12
17
  const query = typeof args.query === "string" ? args.query : "";
13
- return `${theme.fg("accent", `"${query}"`)} ${theme.fg("dim", `across ${directories.length} project(s)`)}`;
18
+ return `${theme.fg("toolTitle", theme.bold(presentationTitle(toolName)))} ${theme.fg("accent", `"${query}"`)} ${theme.fg("dim", `across ${directories.length} project(s)`)}`;
14
19
  }
15
20
 
16
21
  function formatOutcomeHeader(entry: CrossWorkspaceOutcome<unknown>, theme: LectorTheme): string {
@@ -83,6 +88,9 @@ export function formatSearchTextAcrossProjectsResult(
83
88
  lines.push(formatOutcomeHeader(entry, theme));
84
89
  const { outcome } = entry;
85
90
  if (outcome.status !== "ready") continue;
91
+ if (outcome.result.provenance) {
92
+ lines.push(theme.fg("dim", ` lexical via ${outcome.result.provenance.backend} (${outcome.result.provenance.indexState})`));
93
+ }
86
94
  if (outcome.result.matches.length === 0) {
87
95
  lines.push(theme.fg("dim", " no matches"));
88
96
  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,27 +1,61 @@
1
1
  import type { GithubRepoSearchResult, NpmPackageCandidate, SourcegraphCodeCandidate } from "@danypops/lector";
2
2
  import type { LectorTheme } from "../lector-tui-theme.ts";
3
+ import { presentationTitle } from "../presentation/tool-presentation.ts";
3
4
 
4
5
  type ExternalSearchAction = "github_repos" | "npm_packages" | "sourcegraph_code";
5
6
 
6
7
  export function formatExternalSearchCall(action: ExternalSearchAction, args: { query?: unknown }, theme: LectorTheme): string {
7
- const label = theme.fg("toolTitle", theme.bold("external_search"));
8
+ const label = theme.fg("toolTitle", theme.bold(presentationTitle("external_search", action)));
8
9
  const query = typeof args.query === "string" ? args.query : "";
9
- return `${label} ${theme.fg("accent", action)} ${theme.fg("dim", query)}`;
10
+ return `${label} ${theme.fg("accent", `"${query}"`)}`;
10
11
  }
11
12
 
12
- export function formatGithubRepoSearchResult(result: GithubRepoSearchResult | undefined, theme: LectorTheme): string {
13
+ const VISIBLE_CANDIDATES = 8;
14
+
15
+ function boundedCandidates<T>(candidates: readonly T[], expanded: boolean): { visible: readonly T[]; more: number } {
16
+ const visible = expanded ? candidates : candidates.slice(0, VISIBLE_CANDIDATES);
17
+ return { visible, more: candidates.length - visible.length };
18
+ }
19
+
20
+ export function formatGithubRepoSearchResult(result: GithubRepoSearchResult | undefined, expanded: boolean, theme: LectorTheme): string {
13
21
  if (!result) return theme.fg("dim", "No result.");
14
- const count = result.candidates.length;
15
- const summary = count === 0 ? theme.fg("dim", "no repositories matched") : theme.fg("success", `${count} repositor${count === 1 ? "y" : "ies"}`);
16
- return result.authenticated ? summary : `${summary} ${theme.fg("warning", "(unauthenticated -- lower rate limit)")}`;
22
+ if (result.candidates.length === 0) return theme.fg("dim", "no repositories matched");
23
+ const { visible, more } = boundedCandidates(result.candidates, expanded);
24
+ const lines = visible.map(
25
+ (candidate) =>
26
+ `${theme.fg("accent", `${candidate.host}/${candidate.owner}/${candidate.repo}`)} · ${candidate.stars} stars${candidate.language ? ` · ${candidate.language}` : ""}${candidate.description ? `\n ${candidate.description}` : ""}`,
27
+ );
28
+ if (more > 0) lines.push(theme.fg("muted", `… ${more} more (expand to show)`));
29
+ if (!result.authenticated) lines.push(theme.fg("warning", "unauthenticated -- lower rate limit"));
30
+ return lines.join("\n");
17
31
  }
18
32
 
19
- export function formatNpmPackageSearchResult(result: { candidates: readonly NpmPackageCandidate[] } | undefined, theme: LectorTheme): string {
20
- const count = result?.candidates.length ?? 0;
21
- return count === 0 ? theme.fg("dim", "no packages matched") : theme.fg("success", `${count} package${count === 1 ? "" : "s"}`);
33
+ export function formatNpmPackageSearchResult(
34
+ result: { candidates: readonly NpmPackageCandidate[] } | undefined,
35
+ expanded: boolean,
36
+ theme: LectorTheme,
37
+ ): string {
38
+ if (!result || result.candidates.length === 0) return theme.fg("dim", "no packages matched");
39
+ const { visible, more } = boundedCandidates(result.candidates, expanded);
40
+ const lines = visible.map(
41
+ (candidate) =>
42
+ `${theme.fg("accent", `${candidate.name}@${candidate.version}`)} · score ${candidate.score.toFixed(3)}${candidate.description ? `\n ${candidate.description}` : ""}`,
43
+ );
44
+ if (more > 0) lines.push(theme.fg("muted", `… ${more} more (expand to show)`));
45
+ return lines.join("\n");
22
46
  }
23
47
 
24
- export function formatSourcegraphCodeSearchResult(result: { candidates: readonly SourcegraphCodeCandidate[] } | undefined, theme: LectorTheme): string {
25
- const count = result?.candidates.length ?? 0;
26
- return count === 0 ? theme.fg("dim", "no code matches") : theme.fg("success", `${count} match${count === 1 ? "" : "es"}`);
48
+ export function formatSourcegraphCodeSearchResult(
49
+ result: { candidates: readonly SourcegraphCodeCandidate[] } | undefined,
50
+ expanded: boolean,
51
+ theme: LectorTheme,
52
+ ): string {
53
+ if (!result || result.candidates.length === 0) return theme.fg("dim", "no code matches");
54
+ const { visible, more } = boundedCandidates(result.candidates, expanded);
55
+ const lines = visible.map((candidate) => {
56
+ const matches = candidate.lineMatches.slice(0, expanded ? candidate.lineMatches.length : 3);
57
+ return `${theme.fg("accent", `${candidate.repository}/${candidate.path}`)}\n${matches.map((match) => ` ${match.line}: ${match.preview}`).join("\n")}`;
58
+ });
59
+ if (more > 0) lines.push(theme.fg("muted", `… ${more} more (expand to show)`));
60
+ return lines.join("\n");
27
61
  }
@@ -2,13 +2,14 @@ import type { FindFilesResult } from "@danypops/lector";
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
3
  import { renderTruncatedList } from "malevich-tui-components";
4
4
  import type { LectorTheme } from "../lector-tui-theme.ts";
5
+ import { presentationTitle } from "../presentation/tool-presentation.ts";
5
6
 
6
7
  const DEFAULT_VISIBLE_PATHS = 40;
7
8
 
8
9
  export function formatFindFilesCall(args: { directory?: unknown; patterns?: unknown }, theme: LectorTheme): string {
9
10
  const directory = typeof args.directory === "string" ? args.directory : "";
10
11
  const patterns = Array.isArray(args.patterns) ? args.patterns.filter((p): p is string => typeof p === "string") : [];
11
- return `${theme.fg("toolTitle", theme.bold("find_files"))} ${theme.fg("accent", patterns.map((p) => `"${p}"`).join(", "))} ${theme.fg("dim", directory)}`;
12
+ return `${theme.fg("toolTitle", theme.bold(presentationTitle("find_files")))} ${theme.fg("accent", patterns.map((p) => `"${p}"`).join(", "))} ${theme.fg("dim", directory)}`;
12
13
  }
13
14
 
14
15
  export function formatFindFilesResult(result: FindFilesResult | undefined, expanded: boolean, theme: LectorTheme): string {
@@ -2,6 +2,7 @@ import type { SymbolSearchResult, WorkspaceSymbol } from "@danypops/lector";
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
3
  import { renderTruncatedList } from "malevich-tui-components";
4
4
  import { colorForKind, formatLocation, type LectorTheme } from "../lector-tui-theme.ts";
5
+ import { presentationTitle } from "../presentation/tool-presentation.ts";
5
6
 
6
7
  /**
7
8
  * Custom TUI rendering for find_symbols -- the one Lector-backed tool with
@@ -18,7 +19,7 @@ const DEFAULT_VISIBLE_RESULTS = 8;
18
19
 
19
20
  export function formatFindSymbolsCall(args: { query?: unknown; directory?: unknown }, theme: FindSymbolsTheme): string {
20
21
  const query = typeof args.query === "string" ? args.query : "";
21
- let content = `${theme.fg("toolTitle", theme.bold("find_symbols"))} ${theme.fg("accent", `"${query}"`)}`;
22
+ let content = `${theme.fg("toolTitle", theme.bold(presentationTitle("find_symbols")))} ${theme.fg("accent", `"${query}"`)}`;
22
23
  if (typeof args.directory === "string" && args.directory.length > 0) {
23
24
  content += ` ${theme.fg("muted", `in ${args.directory}`)}`;
24
25
  }