@danypops/pi-lector 0.9.5 → 0.10.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.
Files changed (42) hide show
  1. package/extension/src/{apply-patch-operations.ts → apply-patch/operations.ts} +3 -3
  2. package/extension/src/{apply-patch-rendering.ts → apply-patch/rendering.ts} +1 -1
  3. package/extension/src/{code-intelligence-operations.ts → code-intelligence/operations.ts} +2 -2
  4. package/extension/src/{code-intelligence-rendering.ts → code-intelligence/rendering.ts} +1 -1
  5. package/extension/src/cross-workspace-search/operations.ts +88 -0
  6. package/extension/src/{cross-workspace-search-rendering.ts → cross-workspace-search/rendering.ts} +37 -13
  7. package/extension/src/{edit-operations.ts → edit/operations.ts} +2 -2
  8. package/extension/src/editor/editor-state.ts +317 -0
  9. package/extension/src/editor/neovim-editor-component.ts +199 -0
  10. package/extension/src/editor/operations.ts +36 -0
  11. package/extension/src/{external-search-operations.ts → external-search/operations.ts} +1 -1
  12. package/extension/src/{external-search-rendering.ts → external-search/rendering.ts} +1 -1
  13. package/extension/src/{find-files-operations.ts → find-files/operations.ts} +1 -1
  14. package/extension/src/{find-files-rendering.ts → find-files/rendering.ts} +1 -1
  15. package/extension/src/{find-symbols-operations.ts → find-symbols/operations.ts} +1 -1
  16. package/extension/src/{find-symbols-rendering.ts → find-symbols/rendering.ts} +1 -1
  17. package/extension/src/{git-operations.ts → git/operations.ts} +1 -1
  18. package/extension/src/{git-rendering.ts → git/rendering.ts} +1 -1
  19. package/extension/src/index.ts +76 -39
  20. package/extension/src/lector-client.ts +25 -0
  21. package/extension/src/{line-edit-operations.ts → line-edit/operations.ts} +4 -4
  22. package/extension/src/{line-edit-rendering.ts → line-edit/rendering.ts} +1 -1
  23. package/extension/src/{mutation-history-operations.ts → mutation-history/operations.ts} +2 -2
  24. package/extension/src/{package-source-operations.ts → package-source/operations.ts} +1 -1
  25. package/extension/src/{package-source-rendering.ts → package-source/rendering.ts} +35 -28
  26. package/extension/src/{read-operations.ts → read/operations.ts} +2 -2
  27. package/extension/src/{reference-based-rename-operations.ts → reference-based-rename/operations.ts} +1 -1
  28. package/extension/src/{rename-operations.ts → rename/operations.ts} +1 -1
  29. package/extension/src/{repo-cache-evict-operations.ts → repo-cache/evict-operations.ts} +2 -2
  30. package/extension/src/{repo-cache-list-operations.ts → repo-cache/list-operations.ts} +2 -2
  31. package/extension/src/{repo-cache-rendering.ts → repo-cache/rendering.ts} +1 -1
  32. package/extension/src/{repo-fetch-operations.ts → repo-fetch/operations.ts} +1 -1
  33. package/extension/src/{search-operations.ts → search/operations.ts} +1 -1
  34. package/extension/src/{search-rendering.ts → search/rendering.ts} +1 -1
  35. package/extension/src/{symbol-annotation-operations.ts → symbol-annotation/operations.ts} +1 -1
  36. package/extension/src/{symbol-annotation-rendering.ts → symbol-annotation/rendering.ts} +1 -1
  37. package/extension/src/{workspace-cache-operations.ts → workspace-cache/operations.ts} +1 -1
  38. package/extension/src/{workspace-cache-rendering.ts → workspace-cache/rendering.ts} +1 -1
  39. package/extension/src/{write-operations.ts → write/operations.ts} +2 -2
  40. package/package.json +2 -2
  41. package/extension/src/cross-workspace-search-operations.ts +0 -43
  42. /package/extension/src/{code-intelligence-hints.ts → code-intelligence/hints.ts} +0 -0
@@ -0,0 +1,199 @@
1
+ import { extname } from "node:path";
2
+ import type { HighlightSpan } from "@danypops/lector";
3
+ import { highlightSpans } from "@danypops/lector";
4
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
5
+ import type { Component, TUI } from "@earendil-works/pi-tui";
6
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
+ import type { EditorAction } from "./editor-state.ts";
8
+ import { EditorState } from "./editor-state.ts";
9
+
10
+ /** Real theme, narrowed to exactly what this component needs -- avoids depending on pi-coding-agent's full internal Theme shape. */
11
+ export interface EditorTheme {
12
+ fg(color: ThemeColor, text: string): string;
13
+ bg(color: "selectedBg", text: string): string;
14
+ }
15
+
16
+ export interface NeovimEditorHost {
17
+ filePath: string;
18
+ /** 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. */
19
+ save(text: string): Promise<void>;
20
+ /** Real hover info from Lector's existing code-intelligence operation, or undefined when there is none at this position. */
21
+ hover(line: number, character: number): Promise<{ contents: string } | undefined>;
22
+ }
23
+
24
+ const CAPTURE_COLOR: Record<string, ThemeColor> = {
25
+ keyword: "syntaxKeyword",
26
+ comment: "syntaxComment",
27
+ string: "syntaxString",
28
+ number: "syntaxNumber",
29
+ function: "syntaxFunction",
30
+ type: "syntaxType",
31
+ };
32
+
33
+ /**
34
+ * A real, full-file, neovim-style modal code editor Component -- not a CustomEditor subclass
35
+ * (that API replaces Pi's own chat input, not a full-file view; confirmed against
36
+ * docs/tui.md's Pattern 7 and examples/extensions/modal-editor.ts). Renders as a `ctx.ui.custom`
37
+ * overlay. Owns no authoritative state of its own past the open edit session: `EditorState`'s
38
+ * LiveBuffer is the only in-memory copy, and every save round-trips through the host's
39
+ * hash-guarded write -- never a second source of truth for the file's real disk content.
40
+ */
41
+ export class NeovimEditorComponent implements Component {
42
+ private readonly state: EditorState;
43
+ private readonly host: NeovimEditorHost;
44
+ private readonly tui: TUI;
45
+ private readonly theme: EditorTheme;
46
+ private readonly done: () => void;
47
+ private readonly extension: string;
48
+
49
+ private scrollTop = 1;
50
+ private statusMessage = "";
51
+ private highlightCache: { text: string; spans: readonly HighlightSpan[] } | undefined;
52
+
53
+ constructor(tui: TUI, theme: EditorTheme, host: NeovimEditorHost, content: string, done: () => void) {
54
+ this.tui = tui;
55
+ this.theme = theme;
56
+ this.host = host;
57
+ this.done = done;
58
+ this.extension = extname(host.filePath);
59
+ this.state = new EditorState(content);
60
+ void this.refreshHighlights();
61
+ }
62
+
63
+ invalidate(): void {
64
+ this.highlightCache = undefined;
65
+ }
66
+
67
+ handleInput(data: string): void {
68
+ this.state.handleKey(data);
69
+ this.scrollToKeepCursorVisible();
70
+ const action = this.state.pendingAction;
71
+ if (action) void this.performAction(action);
72
+ this.tui.requestRender();
73
+ }
74
+
75
+ private async performAction(action: EditorAction): Promise<void> {
76
+ switch (action.kind) {
77
+ case "save":
78
+ case "save-and-quit": {
79
+ try {
80
+ await this.host.save(this.state.buffer.text);
81
+ this.state.dirty = false;
82
+ this.statusMessage = `"${this.host.filePath}" written`;
83
+ } catch (error) {
84
+ this.statusMessage = `save failed: ${error instanceof Error ? error.message : String(error)}`;
85
+ this.tui.requestRender();
86
+ return;
87
+ }
88
+ if (action.kind === "save-and-quit") {
89
+ this.done();
90
+ return;
91
+ }
92
+ break;
93
+ }
94
+ case "quit":
95
+ this.done();
96
+ return;
97
+ case "hover": {
98
+ const hover = await this.host.hover(this.state.cursorLine, this.state.cursorCharacter);
99
+ const firstLine = hover?.contents.split("\n")[0];
100
+ this.statusMessage = firstLine ?? "no hover info at this position";
101
+ break;
102
+ }
103
+ default: {
104
+ const exhaustive: never = action;
105
+ throw new Error(`Unhandled editor action: ${JSON.stringify(exhaustive)}`);
106
+ }
107
+ }
108
+ this.tui.requestRender();
109
+ }
110
+
111
+ private async refreshHighlights(): Promise<void> {
112
+ const text = this.state.buffer.text;
113
+ const spans = await highlightSpans(text, this.extension);
114
+ this.highlightCache = { text, spans };
115
+ this.tui.requestRender();
116
+ }
117
+
118
+ private scrollToKeepCursorVisible(): void {
119
+ const viewportHeight = Math.max(1, this.tui.terminal.rows - 2);
120
+ if (this.state.cursorLine < this.scrollTop) this.scrollTop = this.state.cursorLine;
121
+ else if (this.state.cursorLine >= this.scrollTop + viewportHeight) this.scrollTop = this.state.cursorLine - viewportHeight + 1;
122
+ }
123
+
124
+ render(width: number): string[] {
125
+ if (this.highlightCache?.text !== this.state.buffer.text) void this.refreshHighlights();
126
+
127
+ const viewportHeight = Math.max(1, this.tui.terminal.rows - 2);
128
+ const gutterWidth = Math.max(3, String(this.state.buffer.lineCount).length) + 1;
129
+ const lastLine = Math.min(this.state.buffer.lineCount, this.scrollTop + viewportHeight - 1);
130
+
131
+ const lines: string[] = [];
132
+ for (let line = this.scrollTop; line <= lastLine; line++) {
133
+ lines.push(this.renderLine(line, gutterWidth, width - gutterWidth));
134
+ }
135
+ while (lines.length < viewportHeight) lines.push("");
136
+
137
+ lines.push(this.renderStatusLine(width));
138
+ return lines;
139
+ }
140
+
141
+ private renderLine(line: number, gutterWidth: number, contentWidth: number): string {
142
+ const isCursorLine = line === this.state.cursorLine;
143
+ const gutterNumber = isCursorLine ? String(line) : String(Math.abs(line - this.state.cursorLine));
144
+ const gutter = `${this.theme.fg(isCursorLine ? "text" : "muted", gutterNumber.padStart(gutterWidth - 1))} `;
145
+
146
+ const lineText = this.state.buffer.lineText(line);
147
+ const rendered = isCursorLine ? this.renderLineWithCursor(line, lineText) : this.renderHighlightedLine(line, lineText);
148
+ return gutter + truncateToWidth(rendered, contentWidth, "");
149
+ }
150
+
151
+ private spansForLine(line: number): readonly HighlightSpan[] {
152
+ if (!this.highlightCache) return [];
153
+ const from = this.state.buffer.offsetAt({ line, character: 1 });
154
+ const to = from + this.state.buffer.lineText(line).length;
155
+ return this.highlightCache.spans.filter((span) => span.startIndex < to && span.endIndex > from);
156
+ }
157
+
158
+ private renderHighlightedLine(line: number, lineText: string): string {
159
+ const from = this.state.buffer.offsetAt({ line, character: 1 });
160
+ const spans = this.spansForLine(line);
161
+ let result = "";
162
+ let cursor = 0;
163
+ for (const span of spans) {
164
+ const start = Math.max(span.startIndex - from, 0);
165
+ const end = Math.min(span.endIndex - from, lineText.length);
166
+ if (start < cursor) continue; // overlapping captures -- keep the earliest, simplest for v1
167
+ result += lineText.slice(cursor, start);
168
+ const color = CAPTURE_COLOR[span.capture];
169
+ result += color ? this.theme.fg(color, lineText.slice(start, end)) : lineText.slice(start, end);
170
+ cursor = end;
171
+ }
172
+ result += lineText.slice(cursor);
173
+ return result;
174
+ }
175
+
176
+ /** The cursor's own line: highlighted text plus an inverse-video cursor cell, matching pi-tui's own Editor cursor convention. */
177
+ private renderLineWithCursor(line: number, lineText: string): string {
178
+ const highlighted = this.renderHighlightedLine(line, lineText);
179
+ // Re-slicing a pre-highlighted (ANSI-embedded) string by character index would misplace the
180
+ // cursor inside escape codes -- render the cursor against the plain text, forgoing this
181
+ // line's syntax highlighting. Acceptable for v1: only one line is ever affected at a time.
182
+ void highlighted;
183
+ const col = this.state.cursorCharacter - 1;
184
+ const before = lineText.slice(0, col);
185
+ const atCursor = col < lineText.length ? lineText[col] : " ";
186
+ const after = col < lineText.length ? lineText.slice(col + 1) : "";
187
+ return `${before}\x1b[7m${atCursor}\x1b[0m${after}`;
188
+ }
189
+
190
+ private renderStatusLine(width: number): string {
191
+ const modeLabel = { normal: " NORMAL ", insert: " INSERT ", command: " COMMAND " }[this.state.mode];
192
+ const dirtyMarker = this.state.dirty ? " [+]" : "";
193
+ const position = `${this.state.cursorLine}:${this.state.cursorCharacter}`;
194
+ const left = this.state.mode === "command" ? `:${this.state.commandText}` : `${this.theme.fg("accent", modeLabel)} ${this.host.filePath}${dirtyMarker}`;
195
+ const right = this.statusMessage || position;
196
+ const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
197
+ return truncateToWidth(`${left}${" ".repeat(gap)}${right}`, width, "");
198
+ }
199
+ }
@@ -0,0 +1,36 @@
1
+ import type { ContentHash } from "@danypops/lector";
2
+ import { lectorClient, withWorkspace, workspaceForPath } from "../lector-client.ts";
3
+ import { toWorkspaceRelativePath } from "../workspace-relative-path.ts";
4
+
5
+ export interface EditorFileSession {
6
+ readonly content: string;
7
+ /**
8
+ * Saves through Lector's hash-guarded `workspace.exactEdit` -- deliberately NOT the
9
+ * transparent stale-hash-retry behavior `createLectorWriteOperations` uses for pi's
10
+ * unconditional-overwrite write tool. `/editor`'s `:w` is a human editing a file they can see
11
+ * on screen; a genuinely concurrent external change must surface as a real, visible error
12
+ * (StaleExpectedHash), never be silently overwritten just because the model's write-tool
13
+ * contract happens to prefer that elsewhere.
14
+ */
15
+ save(text: string): Promise<void>;
16
+ }
17
+
18
+ /** Opens `absolutePath` for `/editor`: one hash-guarded read, then a save() closure that tracks the hash across saves within the same session. */
19
+ export function openEditorFile(absolutePath: string): Promise<EditorFileSession> {
20
+ return withWorkspace(
21
+ () => workspaceForPath(absolutePath),
22
+ async ({ workspaceId, root }) => {
23
+ const client = await lectorClient();
24
+ const relativePath = toWorkspaceRelativePath(root, absolutePath);
25
+ const { content, hash } = await client.call("workspace.rawRead", { workspaceId, path: relativePath });
26
+ let expectedHash: ContentHash | null = hash;
27
+ return {
28
+ content,
29
+ async save(text: string): Promise<void> {
30
+ const outcome = await client.callOnce("workspace.exactEdit", { workspaceId, path: relativePath, expectedHash, content: text });
31
+ expectedHash = outcome.newHash;
32
+ },
33
+ };
34
+ },
35
+ );
36
+ }
@@ -1,5 +1,5 @@
1
1
  import type { GithubRepoSearchResult, NpmPackageCandidate, SourcegraphCodeCandidate } from "@danypops/lector";
2
- import { lectorClient } from "./lector-client.ts";
2
+ import { lectorClient } from "../lector-client.ts";
3
3
 
4
4
  /** Thin wrapper over search.githubRepos/search.npmPackages/search.sourcegraphCode -- explicit-query discovery inputs shaped for repo_cache/package_source, never open-ended discovery/trending. */
5
5
  export interface ExternalSearchOperations {
@@ -1,5 +1,5 @@
1
1
  import type { GithubRepoSearchResult, NpmPackageCandidate, SourcegraphCodeCandidate } from "@danypops/lector";
2
- import type { LectorTheme } from "./lector-tui-theme.ts";
2
+ import type { LectorTheme } from "../lector-tui-theme.ts";
3
3
 
4
4
  type ExternalSearchAction = "github_repos" | "npm_packages" | "sourcegraph_code";
5
5
 
@@ -1,5 +1,5 @@
1
1
  import type { FindFilesResult } from "@danypops/lector";
2
- import { lectorClient, withWorkspace, workspaceForDirectory } from "./lector-client.ts";
2
+ import { lectorClient, withWorkspace, workspaceForDirectory } from "../lector-client.ts";
3
3
 
4
4
  /**
5
5
  * Thin wrapper over workspace.findFiles -- the `find`-shaped half of the classic grep+find pair,
@@ -1,7 +1,7 @@
1
1
  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
- import type { LectorTheme } from "./lector-tui-theme.ts";
4
+ import type { LectorTheme } from "../lector-tui-theme.ts";
5
5
 
6
6
  const DEFAULT_VISIBLE_PATHS = 40;
7
7
 
@@ -1,5 +1,5 @@
1
1
  import type { OperationInputs, SymbolSearchResult } from "@danypops/lector";
2
- import { lectorClient, withWorkspace, workspaceForDirectory } from "./lector-client.ts";
2
+ import { lectorClient, withWorkspace, workspaceForDirectory } from "../lector-client.ts";
3
3
 
4
4
  /**
5
5
  * Thin wrapper over workspace.findSymbols. No seedFile parameter here or
@@ -1,7 +1,7 @@
1
1
  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
- import { colorForKind, formatLocation, type LectorTheme } from "./lector-tui-theme.ts";
4
+ import { colorForKind, formatLocation, type LectorTheme } from "../lector-tui-theme.ts";
5
5
 
6
6
  /**
7
7
  * Custom TUI rendering for find_symbols -- the one Lector-backed tool with
@@ -1,5 +1,5 @@
1
1
  import type { GitDiffResult, GitLogEntry, GitStatusSummary, OperationOutputs } from "@danypops/lector";
2
- import { lectorClient, withWorkspace, workspaceForDirectory } from "./lector-client.ts";
2
+ import { lectorClient, withWorkspace, workspaceForDirectory } from "../lector-client.ts";
3
3
 
4
4
  type SymbolComparison = OperationOutputs["workspace.compareSymbolAcrossVersions"];
5
5
 
@@ -2,7 +2,7 @@ import type { GitDiffResult, GitLogEntry, GitStatusSummary, OperationOutputs } f
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
3
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
4
  import { renderDiffLines, renderTruncatedList, type TextMeasure } from "malevich-tui-components";
5
- import type { LectorTheme } from "./lector-tui-theme.ts";
5
+ import type { LectorTheme } from "../lector-tui-theme.ts";
6
6
 
7
7
  /** Real ANSI-aware measurement, not Malevich's own ASCII-only default -- every diff line renderDiffLines receives is already theme.fg-styled. */
8
8
  const measure: TextMeasure = { visibleWidth, truncateToWidth };
@@ -27,7 +27,6 @@ import type {
27
27
  WorkspaceCacheStatus,
28
28
  WorkspaceLocation,
29
29
  WorkspaceMapResult,
30
- WorkspaceQueryOutcome,
31
30
  } from "@danypops/lector";
32
31
  import { DEFAULT_EXTERNAL_SEARCH_MAX_RESULTS, PACKAGE_ECOSYSTEMS } from "@danypops/lector";
33
32
  import {
@@ -44,9 +43,9 @@ import { Type } from "typebox";
44
43
  /** Real ANSI-aware measurement for Table -- Malevich's own default is ASCII-only, unsafe against theme-styled cell/header text. */
45
44
  const tableMeasure: TextMeasure = { visibleWidth, truncateToWidth };
46
45
 
47
- import { createLectorApplyPatchOperations } from "./apply-patch-operations.ts";
48
- import { formatApplyPatchCall, formatApplyPatchResult } from "./apply-patch-rendering.ts";
49
- import { createLectorCodeIntelligenceOperations } from "./code-intelligence-operations.ts";
46
+ import { createLectorApplyPatchOperations } from "./apply-patch/operations.ts";
47
+ import { formatApplyPatchCall, formatApplyPatchResult } from "./apply-patch/rendering.ts";
48
+ import { createLectorCodeIntelligenceOperations } from "./code-intelligence/operations.ts";
50
49
  import {
51
50
  type CallHierarchyToolDetails,
52
51
  formatCallHierarchyCall,
@@ -67,29 +66,31 @@ import {
67
66
  formatReachableFromResult,
68
67
  formatWorkspaceMapCall,
69
68
  formatWorkspaceMapResult,
70
- } from "./code-intelligence-rendering.ts";
71
- import { createLectorCrossWorkspaceSearchOperations } from "./cross-workspace-search-operations.ts";
72
- import { formatCrossWorkspaceCall, formatFindSymbolsAcrossProjectsResult, formatSearchTextAcrossProjectsResult } from "./cross-workspace-search-rendering.ts";
73
- import { createLectorEditOperations } from "./edit-operations.ts";
74
- import { createExternalSearchOperations } from "./external-search-operations.ts";
69
+ } from "./code-intelligence/rendering.ts";
70
+ import { type CrossWorkspaceOutcome, createLectorCrossWorkspaceSearchOperations } from "./cross-workspace-search/operations.ts";
71
+ import { formatCrossWorkspaceCall, formatFindSymbolsAcrossProjectsResult, formatSearchTextAcrossProjectsResult } from "./cross-workspace-search/rendering.ts";
72
+ import { createLectorEditOperations } from "./edit/operations.ts";
73
+ import { NeovimEditorComponent, type NeovimEditorHost } from "./editor/neovim-editor-component.ts";
74
+ import { openEditorFile } from "./editor/operations.ts";
75
+ import { createExternalSearchOperations } from "./external-search/operations.ts";
75
76
  import {
76
77
  formatExternalSearchCall,
77
78
  formatGithubRepoSearchResult,
78
79
  formatNpmPackageSearchResult,
79
80
  formatSourcegraphCodeSearchResult,
80
- } from "./external-search-rendering.ts";
81
- import { createLectorFindFilesOperations } from "./find-files-operations.ts";
82
- import { formatFindFilesCall, formatFindFilesResult } from "./find-files-rendering.ts";
83
- import { createLectorFindSymbolsOperations } from "./find-symbols-operations.ts";
84
- import { describeFindSymbolSources, formatFindSymbolsCall, formatFindSymbolsResult } from "./find-symbols-rendering.ts";
85
- import { createLectorGitOperations } from "./git-operations.ts";
86
- import { formatGitCall, formatGitResult, type GitToolDetails } from "./git-rendering.ts";
81
+ } from "./external-search/rendering.ts";
82
+ import { createLectorFindFilesOperations } from "./find-files/operations.ts";
83
+ import { formatFindFilesCall, formatFindFilesResult } from "./find-files/rendering.ts";
84
+ import { createLectorFindSymbolsOperations } from "./find-symbols/operations.ts";
85
+ import { describeFindSymbolSources, formatFindSymbolsCall, formatFindSymbolsResult } from "./find-symbols/rendering.ts";
86
+ import { createLectorGitOperations } from "./git/operations.ts";
87
+ import { formatGitCall, formatGitResult, type GitToolDetails } from "./git/rendering.ts";
87
88
  import { setNewWorkspaceObserver } from "./lector-client.ts";
88
- import { createLectorLineEditOperations } from "./line-edit-operations.ts";
89
- import { formatLineEditCall, formatLineEditResult } from "./line-edit-rendering.ts";
90
- import { createMutationHistoryOperations } from "./mutation-history-operations.ts";
89
+ import { createLectorLineEditOperations } from "./line-edit/operations.ts";
90
+ import { formatLineEditCall, formatLineEditResult } from "./line-edit/rendering.ts";
91
+ import { createMutationHistoryOperations } from "./mutation-history/operations.ts";
91
92
  import { isFilesystemRoot, nearestGitRoot } from "./nearest-workspace-root.ts";
92
- import { createLectorPackageSourceOperations, type PackageSourceListPage } from "./package-source-operations.ts";
93
+ import { createLectorPackageSourceOperations, type PackageSourceListPage } from "./package-source/operations.ts";
93
94
  import {
94
95
  buildPackageSourceListTableRows,
95
96
  formatPackageSourceCall,
@@ -100,12 +101,12 @@ import {
100
101
  PACKAGE_SOURCE_LIST_TABLE_COLUMNS,
101
102
  PACKAGE_SOURCE_LIST_VISIBLE_ROWS,
102
103
  packageSourceListMoreLine,
103
- } from "./package-source-rendering.ts";
104
- import { createLectorReadOperations } from "./read-operations.ts";
105
- import { createReferenceBasedRenameOperations } from "./reference-based-rename-operations.ts";
106
- import { createRenameOperations } from "./rename-operations.ts";
107
- import { createRepoCacheEvictOperations } from "./repo-cache-evict-operations.ts";
108
- import { createRepoCacheListOperations } from "./repo-cache-list-operations.ts";
104
+ } from "./package-source/rendering.ts";
105
+ import { createLectorReadOperations } from "./read/operations.ts";
106
+ import { createReferenceBasedRenameOperations } from "./reference-based-rename/operations.ts";
107
+ import { createRenameOperations } from "./rename/operations.ts";
108
+ import { createRepoCacheEvictOperations } from "./repo-cache/evict-operations.ts";
109
+ import { createRepoCacheListOperations } from "./repo-cache/list-operations.ts";
109
110
  import {
110
111
  buildRepoCacheTableRows,
111
112
  formatRepoCacheCall,
@@ -115,21 +116,21 @@ import {
115
116
  REPO_CACHE_TABLE_COLUMNS,
116
117
  REPO_CACHE_VISIBLE_ROWS,
117
118
  repoCacheMoreLine,
118
- } from "./repo-cache-rendering.ts";
119
- import { createLectorRepoFetchOperations } from "./repo-fetch-operations.ts";
120
- import { createLectorSearchOperations } from "./search-operations.ts";
121
- import { formatSearchCall, formatSearchResult } from "./search-rendering.ts";
122
- import { type AnnotationAnchorInput, createLectorSymbolAnnotationOperations } from "./symbol-annotation-operations.ts";
123
- import { formatAnnotationDetail, formatAnnotationListSummary, formatAnnotationSummary } from "./symbol-annotation-rendering.ts";
119
+ } from "./repo-cache/rendering.ts";
120
+ import { createLectorRepoFetchOperations } from "./repo-fetch/operations.ts";
121
+ import { createLectorSearchOperations } from "./search/operations.ts";
122
+ import { formatSearchCall, formatSearchResult } from "./search/rendering.ts";
123
+ import { type AnnotationAnchorInput, createLectorSymbolAnnotationOperations } from "./symbol-annotation/operations.ts";
124
+ import { formatAnnotationDetail, formatAnnotationListSummary, formatAnnotationSummary } from "./symbol-annotation/rendering.ts";
124
125
  import {
125
126
  type CachePresentationState,
126
127
  cacheContextMessage,
127
128
  createWorkspaceCacheOperations,
128
129
  describeCacheState,
129
130
  monitorWorkspaceCache,
130
- } from "./workspace-cache-operations.ts";
131
- import { formatJobSnapshotResult, formatWorkspaceCacheCall, formatWorkspaceCacheStatusResult } from "./workspace-cache-rendering.ts";
132
- import { createLectorWriteOperations } from "./write-operations.ts";
131
+ } from "./workspace-cache/operations.ts";
132
+ import { formatJobSnapshotResult, formatWorkspaceCacheCall, formatWorkspaceCacheStatusResult } from "./workspace-cache/rendering.ts";
133
+ import { createLectorWriteOperations } from "./write/operations.ts";
133
134
 
134
135
  function describeIntelligenceSource(provenance: IntelligenceProvenance): string {
135
136
  return `${provenance.fidelity} via ${provenance.backend}`;
@@ -345,6 +346,42 @@ export default function (pi: ExtensionAPI) {
345
346
  });
346
347
 
347
348
  const codeIntelligenceOperations = createLectorCodeIntelligenceOperations();
349
+
350
+ pi.registerCommand("editor", {
351
+ description: "Open a file in a neovim-style modal code editor",
352
+ handler: async (args, commandCtx) => {
353
+ const target = args.trim();
354
+ if (!target) {
355
+ commandCtx.ui.notify("Usage: /editor <path>", "error");
356
+ return;
357
+ }
358
+ const absolutePath = resolve(commandCtx.cwd, target);
359
+
360
+ let session: Awaited<ReturnType<typeof openEditorFile>>;
361
+ try {
362
+ session = await openEditorFile(absolutePath);
363
+ } catch (error) {
364
+ commandCtx.ui.notify(`Could not open ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`, "error");
365
+ return;
366
+ }
367
+
368
+ await commandCtx.ui.custom<void>(
369
+ (tui, theme, _keybindings, done) => {
370
+ const host: NeovimEditorHost = {
371
+ filePath: absolutePath,
372
+ save: (text) => session.save(text),
373
+ hover: async (line, character) => {
374
+ const result = await codeIntelligenceOperations.hover(absolutePath, line, character);
375
+ return result.hover;
376
+ },
377
+ };
378
+ return new NeovimEditorComponent(tui, theme, host, session.content, () => done(undefined));
379
+ },
380
+ { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "center" } },
381
+ );
382
+ },
383
+ });
384
+
348
385
  const referenceBasedRenameOperations = createReferenceBasedRenameOperations();
349
386
  const renameOperations = createRenameOperations();
350
387
  const positionParameters = {
@@ -1725,7 +1762,7 @@ export default function (pi: ExtensionAPI) {
1725
1762
  name: "find_symbols_across_projects",
1726
1763
  label: "Find Symbols Across Projects",
1727
1764
  description:
1728
- "Fans out a symbol-name search across several explicitly-named project directories at once (e.g. several fetched repos, or a handful of related local projects) and reports one outcome per project -- ready with real results, loading (a project's language server is still cold-starting; retry shortly), or error. Directories are required and explicit -- never every project this daemon happens to have registered, which can include unrelated projects from other concurrent sessions.",
1765
+ "Fans out a symbol-name search across several explicitly-named project directories at once (e.g. several fetched repos, or a handful of related local projects) and reports one outcome per project -- ready with real results, loading (a project's language server is still cold-starting; retry shortly), or error. 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.",
1729
1766
  promptSnippet: "Search for a symbol name across several projects at once",
1730
1767
  parameters: Type.Object({
1731
1768
  directories: Type.Array(Type.String(), { description: "Project directories to search, each absolute or relative to the current working directory" }),
@@ -1751,7 +1788,7 @@ export default function (pi: ExtensionAPI) {
1751
1788
  .join("\n");
1752
1789
  return new Text(theme.fg("error", errorText || "find_symbols_across_projects failed"), 0, 0);
1753
1790
  }
1754
- const details = result.details as { results?: readonly WorkspaceQueryOutcome<SymbolSearchResult>[] } | undefined;
1791
+ const details = result.details as { results?: readonly CrossWorkspaceOutcome<SymbolSearchResult>[] } | undefined;
1755
1792
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1756
1793
  text.setText(formatFindSymbolsAcrossProjectsResult(details?.results, expanded, theme));
1757
1794
  return text;
@@ -1762,7 +1799,7 @@ export default function (pi: ExtensionAPI) {
1762
1799
  name: "search_code_across_projects",
1763
1800
  label: "Search Code Across Projects",
1764
1801
  description:
1765
- "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.",
1802
+ "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.",
1766
1803
  promptSnippet: "Search for a pattern across several projects at once",
1767
1804
  parameters: Type.Object({
1768
1805
  directories: Type.Array(Type.String(), { description: "Project directories to search, each absolute or relative to the current working directory" }),
@@ -1790,7 +1827,7 @@ export default function (pi: ExtensionAPI) {
1790
1827
  .join("\n");
1791
1828
  return new Text(theme.fg("error", errorText || "search_code_across_projects failed"), 0, 0);
1792
1829
  }
1793
- const details = result.details as { results?: readonly WorkspaceQueryOutcome<TextSearchResult>[] } | undefined;
1830
+ const details = result.details as { results?: readonly CrossWorkspaceOutcome<TextSearchResult>[] } | undefined;
1794
1831
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1795
1832
  text.setText(formatSearchTextAcrossProjectsResult(details?.results, expanded, theme));
1796
1833
  return text;
@@ -3,6 +3,7 @@ import { dirname, extname, parse } from "node:path";
3
3
  import {
4
4
  connectLectorClient,
5
5
  descriptorForExtension,
6
+ LANGUAGE_SERVER_DESCRIPTORS,
6
7
  type LectorClient,
7
8
  type OperationInputs,
8
9
  type OperationName,
@@ -142,6 +143,30 @@ export function workspaceForCodeIntelligencePath(absolutePath: string): Promise<
142
143
  return workspaceForRoot(root);
143
144
  }
144
145
 
146
+ /** Every known language's own rootMarkers, deduplicated -- see workspaceForProjectDirectory. */
147
+ const ALL_PROJECT_ROOT_MARKERS: readonly string[] = [...new Set(LANGUAGE_SERVER_DESCRIPTORS.flatMap((descriptor) => descriptor.rootMarkers))];
148
+
149
+ /**
150
+ * Resolves a caller-supplied directory to its OWN nearest project root -- never the outer repo's
151
+ * git root -- so distinct sibling packages under one monorepo stay distinct workspaces. Unlike
152
+ * workspaceForDirectory (used by find_symbols/read/write, where one canonical workspaceId per
153
+ * repo is exactly the point), this is for a tool whose entire premise is comparing *different*
154
+ * scopes (find_symbols_across_projects, search_code_across_projects): collapsing two sibling
155
+ * packages into the same workspaceId there silently duplicates one package's own results under
156
+ * the other's name, with no error at all -- confirmed live against this monorepo
157
+ * (packages/lector and packages/pi-lector both resolved to the same workspaceId).
158
+ *
159
+ * Unlike workspaceForCodeIntelligencePath, there is no single file (and therefore no known
160
+ * extension) to pick one specific language's markers from -- a caller-supplied directory could
161
+ * be any language, so this checks the union of every known language's rootMarkers. Falls back to
162
+ * the nearest git root, then the directory itself, exactly as nearestProjectRoot already does
163
+ * internally (it appends ".git" to whatever marker list it's given).
164
+ */
165
+ export function workspaceForProjectDirectory(directory: string): Promise<ResolvedWorkspace> {
166
+ const root = nearestProjectRoot(directory, ALL_PROJECT_ROOT_MARKERS) ?? directory;
167
+ return workspaceForRoot(root);
168
+ }
169
+
145
170
  /**
146
171
  * For an operation whose `path` genuinely means "the project/workspace itself"
147
172
  * (populateSymbolGraph, workspaceMap, hasWarmIndex) rather than one specific file
@@ -1,12 +1,12 @@
1
1
  import { type LineEdit, type LineEditOutcome, type LineHash, lineHashOf } from "@danypops/lector";
2
- import { lectorClient, withWorkspace, workspaceForPath } from "./lector-client.ts";
3
- import { toWorkspaceRelativePath } from "./workspace-relative-path.ts";
2
+ import { lectorClient, withWorkspace, workspaceForPath } from "../lector-client.ts";
3
+ import { toWorkspaceRelativePath } from "../workspace-relative-path.ts";
4
4
 
5
5
  /**
6
6
  * Thin wrapper over workspace.lineEdit -- distinct from the generic edit tool (backed by
7
- * exactEdit's whole-file hash guard, see edit-operations.ts): every edit here is guarded by
7
+ * exactEdit's whole-file hash guard, see edit/operations.ts): every edit here is guarded by
8
8
  * its own referenced line(s)' hash, so a concurrent change to a line no edit references never
9
- * invalidates this one. `path` is an absolute file path, the same convention edit-operations.ts
9
+ * invalidates this one. `path` is an absolute file path, the same convention edit/operations.ts
10
10
  * already uses -- the workspace is resolved from the file itself, not a separate directory arg.
11
11
  */
12
12
  export interface LineEditOperations {
@@ -1,5 +1,5 @@
1
1
  import type { LineEditOutcome } from "@danypops/lector";
2
- import type { LectorTheme } from "./lector-tui-theme.ts";
2
+ import type { LectorTheme } from "../lector-tui-theme.ts";
3
3
 
4
4
  export function formatLineEditCall(args: { path?: unknown; edits?: unknown }, theme: LectorTheme): string {
5
5
  const path = typeof args.path === "string" ? args.path : "";
@@ -1,6 +1,6 @@
1
1
  import type { MutationHistoryEntry } from "@danypops/lector";
2
- import { lectorClient, withWorkspace, workspaceForPath } from "./lector-client.ts";
3
- import { toWorkspaceRelativePath } from "./workspace-relative-path.ts";
2
+ import { lectorClient, withWorkspace, workspaceForPath } from "../lector-client.ts";
3
+ import { toWorkspaceRelativePath } from "../workspace-relative-path.ts";
4
4
 
5
5
  /** Thin wrapper over Lector's mutation history: every successful edit is recorded, and any entry can be reverted -- guarded the same way every other Lector write is. */
6
6
  export interface MutationHistoryOperations {
@@ -1,5 +1,5 @@
1
1
  import { DEFAULT_PACKAGE_SOURCE_BOUNDS, type PackageEcosystem, type PackageSourceListEntry, type PackageSourceOperationResult } from "@danypops/lector";
2
- import { lectorClient } from "./lector-client.ts";
2
+ import { lectorClient } from "../lector-client.ts";
3
3
 
4
4
  export interface PackageSourceListPage {
5
5
  readonly entries: readonly PackageSourceListEntry[];