@danypops/pi-lector 0.13.7 → 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 {
@@ -9,7 +9,14 @@ import type { EditorTheme } from "./editor-theme.ts";
9
9
  import type { ExplorerDiff, ExplorerEntry } from "./explorer-diff.ts";
10
10
  import { diffExplorerLines, formatExplorerLine, parseExplorerLine } from "./explorer-diff.ts";
11
11
 
12
- export type ExplorerResult = { kind: "quit" } | { kind: "open-file"; absolutePath: string };
12
+ /** Restores one Workspace's directory and active entry when its Explorer becomes visible again. */
13
+ export interface ExplorerViewState {
14
+ readonly relativePath: string;
15
+ readonly selectedEntryName?: string;
16
+ }
17
+
18
+ /** Reports why the Explorer closed plus its latest restorable location when the host supports view-state persistence. */
19
+ export type ExplorerResult = { kind: "quit"; viewState?: ExplorerViewState } | { kind: "open-file"; absolutePath: string; viewState?: ExplorerViewState };
13
20
 
14
21
  /** Joins a directory-relative name onto `directory` ("" means the resolved root itself). */
15
22
  export function joinExplorerPath(directory: string, name: string): string {
@@ -51,12 +58,19 @@ export class ExplorerComponent implements Component {
51
58
  private statusMessage = "";
52
59
  private confirming: PendingConfirmation | undefined;
53
60
 
54
- constructor(tui: TUI, theme: EditorTheme, session: DirectoryExplorerSession, initialRelativePath: string, done: (result: ExplorerResult) => void) {
61
+ constructor(
62
+ tui: TUI,
63
+ theme: EditorTheme,
64
+ session: DirectoryExplorerSession,
65
+ initialRelativePath: string,
66
+ done: (result: ExplorerResult) => void,
67
+ initialSelectedEntryName?: string,
68
+ ) {
55
69
  this.tui = tui;
56
70
  this.theme = theme;
57
71
  this.session = session;
58
72
  this.done = done;
59
- this.pending = this.loadDirectory(initialRelativePath).catch((error: unknown) => this.reportError(error));
73
+ this.pending = this.loadDirectory(initialRelativePath, initialSelectedEntryName).catch((error: unknown) => this.reportError(error));
60
74
  }
61
75
 
62
76
  private pending: Promise<void> = Promise.resolve();
@@ -111,13 +125,20 @@ export class ExplorerComponent implements Component {
111
125
  this.tui.requestRender();
112
126
  }
113
127
 
114
- private async loadDirectory(relativePath: string): Promise<void> {
128
+ private async loadDirectory(relativePath: string, selectedEntryName?: string): Promise<void> {
115
129
  const listing = await this.session.listDirectory(relativePath);
116
130
  this.currentPath = relativePath;
117
131
  this.nextId = 1;
118
132
  this.entries = listing.entries.map((entry) => ({ id: this.nextId++, name: entry.name, kind: entry.kind }));
119
133
  const text = this.entries.length > 0 ? this.entries.map((entry) => formatExplorerLine(entry)).join("\n") : "";
120
134
  this.state = new EditorState(text);
135
+ const selectedIndex = selectedEntryName === undefined ? -1 : this.entries.findIndex((entry) => entry.name === selectedEntryName);
136
+ const selectedEntry = selectedIndex >= 0 ? this.entries[selectedIndex] : undefined;
137
+ if (selectedEntry) {
138
+ this.state.cursorLine = selectedIndex + 1;
139
+ this.state.cursorCharacter = `${selectedEntry.id} `.length + 1;
140
+ this.scrollToKeepCursorVisible();
141
+ }
121
142
  this.confirming = undefined;
122
143
  this.statusMessage = "";
123
144
  this.tui.requestRender();
@@ -133,12 +154,17 @@ export class ExplorerComponent implements Component {
133
154
  await this.loadDirectory(joinExplorerPath(this.currentPath, entry.name));
134
155
  return;
135
156
  }
136
- this.done({ kind: "open-file", absolutePath: join(this.session.root, joinExplorerPath(this.currentPath, entry.name)) });
157
+ this.done({
158
+ kind: "open-file",
159
+ absolutePath: join(this.session.root, joinExplorerPath(this.currentPath, entry.name)),
160
+ viewState: { relativePath: this.currentPath, selectedEntryName: entry.name },
161
+ });
137
162
  }
138
163
 
139
164
  private async navigateToParent(): Promise<void> {
140
165
  if (this.currentPath === "") return; // already at the resolved root -- v1 never widens scope above it
141
- await this.loadDirectory(parentExplorerPath(this.currentPath));
166
+ const childName = posix.basename(this.currentPath);
167
+ await this.loadDirectory(parentExplorerPath(this.currentPath), childName);
142
168
  }
143
169
 
144
170
  private async performAction(action: EditorAction): Promise<void> {
@@ -149,14 +175,14 @@ export class ExplorerComponent implements Component {
149
175
  this.state.dirty = false;
150
176
  if (diffs.length === 0) {
151
177
  this.statusMessage = "no changes";
152
- if (action.kind === "save-and-quit") this.done({ kind: "quit" });
178
+ if (action.kind === "save-and-quit") this.done({ kind: "quit", viewState: this.viewState() });
153
179
  return;
154
180
  }
155
181
  this.confirming = { diffs, andQuit: action.kind === "save-and-quit" };
156
182
  return;
157
183
  }
158
184
  case "quit":
159
- this.done({ kind: "quit" });
185
+ this.done({ kind: "quit", viewState: this.viewState() });
160
186
  return;
161
187
  case "hover":
162
188
  this.statusMessage = "hover is not applicable in the file explorer";
@@ -186,13 +212,19 @@ export class ExplorerComponent implements Component {
186
212
  return;
187
213
  }
188
214
  if (andQuit) {
189
- this.done({ kind: "quit" });
215
+ this.done({ kind: "quit", viewState: this.viewState() });
190
216
  return;
191
217
  }
192
218
  await this.loadDirectory(this.currentPath);
193
219
  this.statusMessage = "applied";
194
220
  }
195
221
 
222
+ private viewState(): ExplorerViewState {
223
+ const parsed = parseExplorerLine(this.state.currentLineText);
224
+ const entry = !parsed || parsed.id === null ? undefined : this.entries.find((candidate) => candidate.id === parsed.id);
225
+ return entry ? { relativePath: this.currentPath, selectedEntryName: entry.name } : { relativePath: this.currentPath };
226
+ }
227
+
196
228
  private scrollToKeepCursorVisible(): void {
197
229
  const viewportHeight = Math.max(1, this.tui.terminal.rows - 2);
198
230
  if (this.state.cursorLine < this.scrollTop) this.scrollTop = this.state.cursorLine;
@@ -1,10 +1,10 @@
1
- import { dirname, relative } from "node:path";
1
+ import { basename, dirname, relative } from "node:path";
2
2
  import type { DirectoryExplorerSession } from "./directory-explorer-operations.ts";
3
- import type { ExplorerResult } from "./explorer-component.ts";
3
+ import type { ExplorerResult, ExplorerViewState } from "./explorer-component.ts";
4
4
 
5
5
  export interface ExplorerFlowHost {
6
- /** Shows the explorer at `relativePath` (root-relative, "" for the resolved root) and resolves once the user quits or opens a file. */
7
- showExplorer(session: DirectoryExplorerSession, relativePath: string): Promise<ExplorerResult>;
6
+ /** Shows the explorer at `relativePath` (root-relative, "" for the resolved root), optionally revealing one active entry, and resolves once the user quits or opens a file. */
7
+ showExplorer(session: DirectoryExplorerSession, relativePath: string, selectedEntryName?: string): Promise<ExplorerResult>;
8
8
  /** Shows the real file editor for `absolutePath` and resolves once the user quits it. */
9
9
  showEditor(absolutePath: string): Promise<void>;
10
10
  }
@@ -14,14 +14,21 @@ export interface ExplorerFlowHost {
14
14
  * the explorer -- at the directory the opened file lives in, not the resolved root -- once that
15
15
  * editor quits, rather than closing the whole session after the first file opened.
16
16
  */
17
- export async function runExplorerFlow(session: DirectoryExplorerSession, host: ExplorerFlowHost): Promise<void> {
18
- let relativePath = "";
17
+ export async function runExplorerFlow(
18
+ session: DirectoryExplorerSession,
19
+ host: ExplorerFlowHost,
20
+ initialViewState: ExplorerViewState = { relativePath: "" },
21
+ ): Promise<ExplorerViewState> {
22
+ let viewState = initialViewState;
19
23
  for (;;) {
20
- const result = await host.showExplorer(session, relativePath);
21
- if (result.kind === "quit") return;
24
+ const result = await host.showExplorer(session, viewState.relativePath, viewState.selectedEntryName);
25
+ if (result.kind === "quit") return result.viewState ?? viewState;
22
26
 
23
27
  await host.showEditor(result.absolutePath);
24
28
  // path.relative(root, root) is "" directly, matching ExplorerComponent's own root-relative convention -- no "." case to normalize.
25
- relativePath = relative(session.root, dirname(result.absolutePath));
29
+ viewState = result.viewState ?? {
30
+ relativePath: relative(session.root, dirname(result.absolutePath)),
31
+ selectedEntryName: basename(result.absolutePath),
32
+ };
26
33
  }
27
34
  }
@@ -29,9 +29,16 @@
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
- export { ExplorerComponent, type ExplorerResult, joinExplorerPath } from "./explorer-component.ts";
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
36
43
  // the real editor, return to the explorer at that file's own directory once it quits) -- exported
37
44
  // for the same reason ExplorerComponent/DirectoryExplorerSession are: any real host can drive this
@@ -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),