@danypops/pi-lector 0.12.7 → 0.12.9

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.
@@ -7,7 +7,7 @@ export type EditorAction = { kind: "save" } | { kind: "save-and-quit" } | { kind
7
7
  const BACKSPACE_KEYS = new Set(["\x7f", "\b"]);
8
8
 
9
9
  /**
10
- * Pure neovim-style modal editing state machine: mode transitions, cursor motion, and buffer
10
+ * Pure modal editing state machine: mode transitions, cursor motion, and buffer
11
11
  * edits, with no terminal/ANSI rendering and no I/O -- save/quit/hover requests surface as
12
12
  * `pendingAction` for the hosting Component to actually perform (reading/writing through
13
13
  * Lector's hash-guarded workspace operations lives outside this class entirely). Kept
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The real, host-agnostic editor surface -- deliberately its own subpath export
3
+ * (`@danypops/pi-lector/editor`), not part of this package's own Pi extension entry point
4
+ * (`extension/src/index.ts`, which is loaded only via Pi's own extension-discovery mechanism and
5
+ * has no package export of its own).
6
+ *
7
+ * Everything re-exported here has zero structural dependency on Pi's extension machinery --
8
+ * confirmed directly: ModalEditorComponent's own constructor only ever calls
9
+ * `tui.requestRender()`/`tui.terminal.rows` and `theme.fg()`/`theme.bg()` (EditorTheme is its own
10
+ * interface, deliberately narrowed away from pi-coding-agent's full Theme shape), and
11
+ * ModalEditorHost is a plain `{filePath, save, hover}` port with no Pi types anywhere in it.
12
+ * EditorState's own only dependency is `@danypops/lector`'s LiveBuffer -- no I/O, no rendering.
13
+ *
14
+ * This lets any real host (not just a Pi session that has loaded this package as an extension)
15
+ * construct and mount a real Lector editor Component directly, against its own tui/theme
16
+ * implementation and its own ModalEditorHost backed by whatever it wants (a Lector daemon
17
+ * client, a Vehicle operation, a plain filesystem call -- this package doesn't care).
18
+ */
19
+
20
+ export { type EditorAction, type EditorMode, EditorState } from "./editor-state.ts";
21
+ export type { EditorTheme } from "./editor-theme.ts";
22
+ export { ModalEditorComponent, type ModalEditorHost } from "./modal-editor-component.ts";
@@ -10,7 +10,7 @@ import type { EditorTheme } from "./editor-theme.ts";
10
10
 
11
11
  export type { EditorTheme } from "./editor-theme.ts";
12
12
 
13
- export interface NeovimEditorHost {
13
+ export interface ModalEditorHost {
14
14
  filePath: string;
15
15
  /** 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. */
16
16
  save(text: string): Promise<void>;
@@ -28,16 +28,16 @@ const CAPTURE_COLOR: Record<string, ThemeColor> = {
28
28
  };
29
29
 
30
30
  /**
31
- * A real, full-file, neovim-style modal code editor Component -- not a CustomEditor subclass
31
+ * A real, full-file, modal code editor Component -- not a CustomEditor subclass
32
32
  * (that API replaces Pi's own chat input, not a full-file view; confirmed against
33
33
  * docs/tui.md's Pattern 7 and examples/extensions/modal-editor.ts). Renders as a `ctx.ui.custom`
34
34
  * overlay. Owns no authoritative state of its own past the open edit session: `EditorState`'s
35
35
  * LiveBuffer is the only in-memory copy, and every save round-trips through the host's
36
36
  * hash-guarded write -- never a second source of truth for the file's real disk content.
37
37
  */
38
- export class NeovimEditorComponent implements Component {
38
+ export class ModalEditorComponent implements Component {
39
39
  private readonly state: EditorState;
40
- private readonly host: NeovimEditorHost;
40
+ private readonly host: ModalEditorHost;
41
41
  private readonly tui: TUI;
42
42
  private readonly theme: EditorTheme;
43
43
  private readonly done: () => void;
@@ -47,7 +47,7 @@ export class NeovimEditorComponent implements Component {
47
47
  private statusMessage = "";
48
48
  private highlightCache: { text: string; spans: readonly HighlightSpan[] } | undefined;
49
49
 
50
- constructor(tui: TUI, theme: EditorTheme, host: NeovimEditorHost, content: string, done: () => void) {
50
+ constructor(tui: TUI, theme: EditorTheme, host: ModalEditorHost, content: string, done: () => void) {
51
51
  this.tui = tui;
52
52
  this.theme = theme;
53
53
  this.host = host;
@@ -77,7 +77,7 @@ import { createLectorEditOperations } from "./edit/operations.ts";
77
77
  import { openDirectoryExplorer } from "./editor/directory-explorer-operations.ts";
78
78
  import { ExplorerComponent, type ExplorerResult } from "./editor/explorer-component.ts";
79
79
  import { runExplorerFlow } from "./editor/explorer-flow.ts";
80
- import { NeovimEditorComponent, type NeovimEditorHost } from "./editor/neovim-editor-component.ts";
80
+ import { ModalEditorComponent, type ModalEditorHost } from "./editor/modal-editor-component.ts";
81
81
  import { openEditorFile } from "./editor/operations.ts";
82
82
  import { createExternalSearchOperations } from "./external-search/operations.ts";
83
83
  import {
@@ -387,7 +387,7 @@ export default function (pi: ExtensionAPI) {
387
387
  }
388
388
 
389
389
  await commandCtx.ui.custom<void>((tui, theme, _keybindings, done) => {
390
- const host: NeovimEditorHost = {
390
+ const host: ModalEditorHost = {
391
391
  filePath: absolutePath,
392
392
  save: (text) => session.save(text),
393
393
  hover: async (line, character) => {
@@ -395,7 +395,7 @@ export default function (pi: ExtensionAPI) {
395
395
  return result.hover;
396
396
  },
397
397
  };
398
- return new NeovimEditorComponent(tui, theme, host, session.content, () => done(undefined));
398
+ return new ModalEditorComponent(tui, theme, host, session.content, () => done(undefined));
399
399
  }, editorOverlayOptions);
400
400
  }
401
401
 
@@ -420,7 +420,7 @@ export default function (pi: ExtensionAPI) {
420
420
  }
421
421
 
422
422
  pi.registerCommand("editor", {
423
- description: "Open a file in a neovim-style modal code editor, or a filesystem explorer with no path",
423
+ description: "Open a file in a modal code editor, or a filesystem explorer with no path",
424
424
  handler: async (args, commandCtx) => {
425
425
  const target = args.trim();
426
426
  if (!target) {
@@ -1141,7 +1141,7 @@ export default function (pi: ExtensionAPI) {
1141
1141
  name: "workspace_cache",
1142
1142
  label: "Workspace Cache",
1143
1143
  description:
1144
- "Checks or drives population of the workspace's persisted symbol graph -- the store reachable_from, symbol_annotations (anchor resolution), reference_based_rename, and workspace_map all read from, separate from the live language-server index find_symbols/hover/go_to_definition use. action=status reports not-cached/caching/partial/cached for the given bounds, without starting work. action=populate requests a scan and briefly waits for fast completion. action=wait subscribes to daemon job completion, with bounded status polling only when push delivery is unavailable. action=job_status is a point-in-time diagnostic read.",
1144
+ "Checks or drives population of the workspace's persisted symbol graph -- the store reachable_from, symbol_annotations (anchor resolution), reference_based_rename, and workspace_map all read from, separate from the live language-server index find_symbols/hover/go_to_definition use. action=status reports not-cached/caching/partial/cached for the given bounds, without starting work. action=populate requests a scan and briefly waits for fast completion; a source file changing mid-scan (e.g. a concurrent edit or rename) is retried automatically in the background for up to a minute before surfacing as a real failure, no manual re-run needed. action=wait subscribes to daemon job completion, with bounded status polling only when push delivery is unavailable. action=job_status is a point-in-time diagnostic read.",
1145
1145
  promptSnippet: "Check or force-populate the workspace's persisted symbol graph",
1146
1146
  promptGuidelines: [
1147
1147
  "Use action=populate with a larger maxFiles/maxSymbolsPerFile before relying on reachable_from/symbol_annotations/reference_based_rename against a workspace bigger than the default 500-file auto-scan -- their own errors (empty results, UnknownAnnotationAnchor, ReferenceBasedRenameRequiresFreshGraph) usually mean the graph never reached the files you need, not that population is simply still catching up.",
@@ -13,6 +13,16 @@ import { lectorClient, type ResolvedWorkspace, withWorkspace, workspaceForCodeIn
13
13
  * the whole declared monorepo once (workspace_cache pointed at the repo root) and still rename a
14
14
  * file inside one of its member packages, without collapsing genuinely unrelated sibling projects
15
15
  * that were never declared together into one workspace identity.
16
+ *
17
+ * autoPopulate (Lector's own opt-in recovery from a "not-cached" graph) is applied carefully, not
18
+ * blanket-enabled: only once the correct FINAL scope is already known, never on a speculative
19
+ * first attempt that might still need widening. If a declared monorepo ancestor exists, the
20
+ * narrow project's own first attempt runs WITHOUT autoPopulate (so a genuinely never-populated
21
+ * narrow project still throws and triggers the widen-to-declared-root fallback below, exactly as
22
+ * before -- auto-populating the too-narrow scope here could make a rename "succeed" while
23
+ * silently missing a real cross-package reference the wider scope would have caught). Once widened
24
+ * to the declared root (or when no wider ancestor exists at all, meaning the narrow project IS the
25
+ * correct final scope), autoPopulate is safe and turned on -- one call converges instead of two.
16
26
  */
17
27
  export interface ReferenceBasedRenameOperations {
18
28
  rename(fromPath: string, toPath: string, maxFiles: number, maxSymbolsPerFile: number): Promise<OperationOutputs["workspace.referenceBasedRename"]>;
@@ -21,19 +31,32 @@ export interface ReferenceBasedRenameOperations {
21
31
  export function createReferenceBasedRenameOperations(): ReferenceBasedRenameOperations {
22
32
  return {
23
33
  async rename(fromPath, toPath, maxFiles, maxSymbolsPerFile) {
24
- const performRename = async ({ workspaceId }: ResolvedWorkspace) => {
34
+ const performRename = async ({ workspaceId }: ResolvedWorkspace, autoPopulate: boolean) => {
25
35
  const client = await lectorClient();
26
- return client.callOnce("workspace.referenceBasedRename", { workspaceId, fromPath, toPath, maxFiles, maxSymbolsPerFile });
36
+ return client.callOnce("workspace.referenceBasedRename", { workspaceId, fromPath, toPath, maxFiles, maxSymbolsPerFile, autoPopulate });
27
37
  };
28
38
 
39
+ const narrow = await workspaceForCodeIntelligencePath(fromPath);
40
+ const declared = await workspaceForDeclaredMonorepoRoot(narrow.root);
41
+ if (!declared) {
42
+ // No wider declared ancestor -- the narrow project IS the correct, final scope, so
43
+ // auto-populating it directly is exactly as safe as the already-widened case below.
44
+ return withWorkspace(
45
+ () => Promise.resolve(narrow),
46
+ (resolved) => performRename(resolved, true),
47
+ );
48
+ }
29
49
  try {
30
- return await withWorkspace(() => workspaceForCodeIntelligencePath(fromPath), performRename);
50
+ return await withWorkspace(
51
+ () => Promise.resolve(narrow),
52
+ (resolved) => performRename(resolved, false),
53
+ );
31
54
  } catch (error) {
32
55
  if (!remoteErrorIs(error, "ReferenceBasedRenameRequiresFreshGraph")) throw error;
33
- const narrow = await workspaceForCodeIntelligencePath(fromPath);
34
- const declared = await workspaceForDeclaredMonorepoRoot(narrow.root);
35
- if (!declared) throw error;
36
- return withWorkspace(() => Promise.resolve(declared), performRename);
56
+ return withWorkspace(
57
+ () => Promise.resolve(declared),
58
+ (resolved) => performRename(resolved, true),
59
+ );
37
60
  }
38
61
  },
39
62
  };
@@ -15,9 +15,26 @@ export interface JobWatchHandle {
15
15
 
16
16
  export type JobWatchOutcome = { readonly status: "subscribed"; readonly handle: JobWatchHandle } | { readonly status: "unavailable" };
17
17
 
18
+ /**
19
+ * The daemon's own default (0/omitted -- fail fast on a WorkspaceChangedDuringPopulation race)
20
+ * would surface a live-editing/rename race straight to this tool's own caller as an opaque
21
+ * error. This tool's whole point is "make this converge for me" -- a bounded background retry
22
+ * costs the caller nothing extra (it runs inside the job the caller is already waiting on or
23
+ * polling, not as additional synchronous tool-call latency), so it defaults on here specifically,
24
+ * unlike the raw daemon operation which stays fail-fast by default for programmatic callers that
25
+ * want today's exact contract.
26
+ */
27
+ const DEFAULT_POPULATE_RETRY_TIME_BUDGET_MS = 60_000;
28
+
18
29
  export interface WorkspaceCacheOperations {
19
30
  status(directory: string, maxFiles: number, maxSymbolsPerFile: number): Promise<WorkspaceCacheStatus>;
20
- submit(directory: string, maxFiles: number, maxSymbolsPerFile: number, waitMs?: number): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
31
+ submit(
32
+ directory: string,
33
+ maxFiles: number,
34
+ maxSymbolsPerFile: number,
35
+ waitMs?: number,
36
+ retryTimeBudgetMs?: number,
37
+ ): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
21
38
  jobStatus(jobId: string): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
22
39
  watchJob?(jobId: string, onJob: (job: JobSnapshot<PopulateSymbolGraphResult>) => void): Promise<JobWatchOutcome>;
23
40
  }
@@ -33,14 +50,14 @@ export function createWorkspaceCacheOperations(): WorkspaceCacheOperations {
33
50
  },
34
51
  );
35
52
  },
36
- submit(directory, maxFiles, maxSymbolsPerFile, waitMs = 0) {
53
+ submit(directory, maxFiles, maxSymbolsPerFile, waitMs = 0, retryTimeBudgetMs = DEFAULT_POPULATE_RETRY_TIME_BUDGET_MS) {
37
54
  return withWorkspace(
38
55
  () => workspaceForProjectDirectory(directory),
39
56
  async ({ workspaceId }) => {
40
57
  const client = await lectorClient();
41
58
  const { job } = await client.callOnce("job.submit", {
42
59
  operation: "workspace.populateSymbolGraph",
43
- input: { workspaceId, maxFiles, maxSymbolsPerFile },
60
+ input: { workspaceId, maxFiles, maxSymbolsPerFile, retryTimeBudgetMs },
44
61
  waitMs,
45
62
  });
46
63
  return job;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.12.7",
3
+ "version": "0.12.9",
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",
@@ -12,6 +12,9 @@
12
12
  "pi": {
13
13
  "extensions": ["extension/src/index.ts"]
14
14
  },
15
+ "exports": {
16
+ "./editor": "./extension/src/editor/index.ts"
17
+ },
15
18
  "peerDependencies": {
16
19
  "@earendil-works/pi-coding-agent": "*",
17
20
  "@earendil-works/pi-tui": "*",