@danypops/pi-lector 0.9.6 → 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 (40) 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 → cross-workspace-search/operations.ts} +1 -1
  6. package/extension/src/{cross-workspace-search-rendering.ts → cross-workspace-search/rendering.ts} +18 -6
  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 +72 -34
  20. package/extension/src/{line-edit-operations.ts → line-edit/operations.ts} +4 -4
  21. package/extension/src/{line-edit-rendering.ts → line-edit/rendering.ts} +1 -1
  22. package/extension/src/{mutation-history-operations.ts → mutation-history/operations.ts} +2 -2
  23. package/extension/src/{package-source-operations.ts → package-source/operations.ts} +1 -1
  24. package/extension/src/{package-source-rendering.ts → package-source/rendering.ts} +35 -28
  25. package/extension/src/{read-operations.ts → read/operations.ts} +2 -2
  26. package/extension/src/{reference-based-rename-operations.ts → reference-based-rename/operations.ts} +1 -1
  27. package/extension/src/{rename-operations.ts → rename/operations.ts} +1 -1
  28. package/extension/src/{repo-cache-evict-operations.ts → repo-cache/evict-operations.ts} +2 -2
  29. package/extension/src/{repo-cache-list-operations.ts → repo-cache/list-operations.ts} +2 -2
  30. package/extension/src/{repo-cache-rendering.ts → repo-cache/rendering.ts} +1 -1
  31. package/extension/src/{repo-fetch-operations.ts → repo-fetch/operations.ts} +1 -1
  32. package/extension/src/{search-operations.ts → search/operations.ts} +1 -1
  33. package/extension/src/{search-rendering.ts → search/rendering.ts} +1 -1
  34. package/extension/src/{symbol-annotation-operations.ts → symbol-annotation/operations.ts} +1 -1
  35. package/extension/src/{symbol-annotation-rendering.ts → symbol-annotation/rendering.ts} +1 -1
  36. package/extension/src/{workspace-cache-operations.ts → workspace-cache/operations.ts} +1 -1
  37. package/extension/src/{workspace-cache-rendering.ts → workspace-cache/rendering.ts} +1 -1
  38. package/extension/src/{write-operations.ts → write/operations.ts} +2 -2
  39. package/package.json +2 -2
  40. /package/extension/src/{code-intelligence-hints.ts → code-intelligence/hints.ts} +0 -0
@@ -1,13 +1,13 @@
1
1
  import type { ContentHash, EditOutcome } 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.applyPatch -- distinct from both the generic edit tool
7
7
  * (whole-file replace) and line_edit (per-line hash guards): applies a real unified diff's
8
8
  * hunks, guarded by one whole-file expectedHash (a patch inherently describes a
9
9
  * whole-file transformation from a known pre-image). `path` is an absolute file path, the
10
- * same convention edit-operations.ts and line-edit-operations.ts already use.
10
+ * same convention edit/operations.ts and line-edit/operations.ts already use.
11
11
  */
12
12
  export interface ApplyPatchOperations {
13
13
  applyPatch(path: string, expectedHash: ContentHash, patchText: string): Promise<EditOutcome>;
@@ -1,6 +1,6 @@
1
1
  import type { EditOutcome } from "@danypops/lector";
2
2
  import { renderDiffLines, renderTruncatedList, type TextMeasure } from "malevich-tui-components";
3
- import type { LectorTheme } from "./lector-tui-theme.ts";
3
+ import type { LectorTheme } from "../lector-tui-theme.ts";
4
4
 
5
5
  /**
6
6
  * The applied result (EditOutcome) only carries a hash transition, not the
@@ -1,5 +1,5 @@
1
1
  import type { JobSnapshot, OperationInputs, OperationOutputs, PopulateSymbolGraphResult, SymbolEdgeKind, SymbolNode } from "@danypops/lector";
2
- import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath, workspaceForPathOrDirectory } from "./lector-client.ts";
2
+ import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath, workspaceForPathOrDirectory } from "../lector-client.ts";
3
3
 
4
4
  /**
5
5
  * Thin wrappers over Lector's code-intelligence operations: goToDefinition,
@@ -39,7 +39,7 @@ export interface CodeIntelligenceOperations {
39
39
  outgoingCalls(path: string, line: number, character: number): Promise<OperationOutputs["workspace.outgoingCalls"]>;
40
40
  /**
41
41
  * Not exposed as a standalone Pi tool -- every workspace auto-populates on first touch via
42
- * monitorWorkspaceCache (workspace-cache-operations.ts). Kept here as an internal capability
42
+ * monitorWorkspaceCache (workspace-cache/operations.ts). Kept here as an internal capability
43
43
  * for tests that need a populated graph. For an explicit/custom-bound population outside
44
44
  * Pi entirely, `lector workspace populate-symbol-graph` calls the daemon directly.
45
45
  */
@@ -12,7 +12,7 @@ import type {
12
12
  } from "@danypops/lector";
13
13
  import { keyHint, type ThemeColor } from "@earendil-works/pi-coding-agent";
14
14
  import { renderTruncatedList } from "malevich-tui-components";
15
- import { colorForKind, formatLocation, type LectorTheme } from "./lector-tui-theme.ts";
15
+ import { colorForKind, formatLocation, type LectorTheme } from "../lector-tui-theme.ts";
16
16
 
17
17
  /**
18
18
  * Custom TUI rendering for the code-intelligence tools: go_to_definition,
@@ -1,5 +1,5 @@
1
1
  import type { SymbolSearchResult, TextSearchResult, WorkspaceQueryOutcome } from "@danypops/lector";
2
- import { lectorClient, workspaceForProjectDirectory } from "./lector-client.ts";
2
+ import { lectorClient, workspaceForProjectDirectory } from "../lector-client.ts";
3
3
 
4
4
  /**
5
5
  * Fans out across explicitly-named directories only -- never the daemon's own "every registered
@@ -1,9 +1,9 @@
1
1
  import type { SymbolSearchResult, TextSearchResult } from "@danypops/lector";
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
3
  import { renderTruncatedList } from "malevich-tui-components";
4
- import type { CrossWorkspaceOutcome } from "./cross-workspace-search-operations.ts";
5
- import { describeFindSymbolSources } from "./find-symbols-rendering.ts";
6
- import type { LectorTheme } from "./lector-tui-theme.ts";
4
+ import { describeFindSymbolSources } from "../find-symbols/rendering.ts";
5
+ import type { LectorTheme } from "../lector-tui-theme.ts";
6
+ import type { CrossWorkspaceOutcome } from "./operations.ts";
7
7
 
8
8
  const DEFAULT_VISIBLE_PER_WORKSPACE = 10;
9
9
 
@@ -17,9 +17,21 @@ function formatOutcomeHeader(entry: CrossWorkspaceOutcome<unknown>, theme: Lecto
17
17
  const { outcome } = entry;
18
18
  const label = theme.fg("accent", entry.directory);
19
19
  const lines: string[] = [];
20
- if (outcome.status === "ready") lines.push(label);
21
- else if (outcome.status === "loading") lines.push(`${label} ${theme.fg("warning", `-- still loading: ${outcome.message}`)}`);
22
- else lines.push(`${label} ${theme.fg("error", `-- ${outcome.message}`)}`);
20
+ switch (outcome.status) {
21
+ case "ready":
22
+ lines.push(label);
23
+ break;
24
+ case "loading":
25
+ lines.push(`${label} ${theme.fg("warning", `-- still loading: ${outcome.message}`)}`);
26
+ break;
27
+ case "error":
28
+ lines.push(`${label} ${theme.fg("error", `-- ${outcome.message}`)}`);
29
+ break;
30
+ default: {
31
+ const exhaustive: never = outcome;
32
+ throw new Error(`unhandled workspace query outcome status: ${JSON.stringify(exhaustive)}`);
33
+ }
34
+ }
23
35
  // Surfaced explicitly, never silently -- two distinct inputs resolving to one workspace means
24
36
  // one of their own result payloads below is a real duplicate of the other's, not two independent answers.
25
37
  if (entry.collapsedWith.length > 0) {
@@ -1,7 +1,7 @@
1
1
  import { type ContentHash, remoteErrorIs } from "@danypops/lector";
2
2
  import type { EditOperations } from "@earendil-works/pi-coding-agent";
3
- import { lectorClient, withWorkspace, workspaceForPath } from "./lector-client.ts";
4
- import { toWorkspaceRelativePath } from "./workspace-relative-path.ts";
3
+ import { lectorClient, withWorkspace, workspaceForPath } from "../lector-client.ts";
4
+ import { toWorkspaceRelativePath } from "../workspace-relative-path.ts";
5
5
 
6
6
  /**
7
7
  * EditOperations backed by Lector's hash-guarded exactEdit. The workspace
@@ -0,0 +1,317 @@
1
+ import { LiveBuffer } from "@danypops/lector";
2
+
3
+ export type EditorMode = "normal" | "insert" | "command";
4
+
5
+ export type EditorAction = { kind: "save" } | { kind: "save-and-quit" } | { kind: "quit" } | { kind: "hover" };
6
+
7
+ const BACKSPACE_KEYS = new Set(["\x7f", "\b"]);
8
+
9
+ /**
10
+ * Pure neovim-style modal editing state machine: mode transitions, cursor motion, and buffer
11
+ * edits, with no terminal/ANSI rendering and no I/O -- save/quit/hover requests surface as
12
+ * `pendingAction` for the hosting Component to actually perform (reading/writing through
13
+ * Lector's hash-guarded workspace operations lives outside this class entirely). Kept
14
+ * side-effect-free like this specifically so mode/motion/edit logic is unit-testable without a
15
+ * real terminal, per this environment's own "test behavior, not pixels" TUI-testing standard.
16
+ */
17
+ export class EditorState {
18
+ buffer: LiveBuffer;
19
+ mode: EditorMode = "normal";
20
+ cursorLine = 1;
21
+ cursorCharacter = 1;
22
+ commandText = "";
23
+ pendingAction: EditorAction | undefined;
24
+ dirty = false;
25
+
26
+ private normalPrefix = "";
27
+ private insertPendingJ = false;
28
+ private yankedLine: string | undefined;
29
+
30
+ constructor(content: string) {
31
+ this.buffer = new LiveBuffer(content);
32
+ }
33
+
34
+ get currentLineText(): string {
35
+ return this.buffer.lineText(this.cursorLine);
36
+ }
37
+
38
+ handleKey(data: string): void {
39
+ this.pendingAction = undefined;
40
+ if (this.mode === "insert") {
41
+ this.handleInsertKey(data);
42
+ } else if (this.mode === "command") {
43
+ this.handleCommandKey(data);
44
+ } else {
45
+ this.handleNormalKey(data);
46
+ }
47
+ this.clampCursor();
48
+ }
49
+
50
+ private clampCursor(): void {
51
+ this.cursorLine = Math.min(Math.max(this.cursorLine, 1), this.buffer.lineCount);
52
+ const lineLength = this.currentLineText.length;
53
+ const maxCharacter = this.mode === "insert" ? lineLength + 1 : Math.max(lineLength, 1);
54
+ this.cursorCharacter = Math.min(Math.max(this.cursorCharacter, 1), maxCharacter);
55
+ }
56
+
57
+ private enterInsert(): void {
58
+ this.mode = "insert";
59
+ this.insertPendingJ = false;
60
+ }
61
+
62
+ // ── Normal mode ──────────────────────────────────────────────────────────
63
+
64
+ private handleNormalKey(data: string): void {
65
+ // Two-key sequences: dd, gg, yy.
66
+ if (this.normalPrefix) {
67
+ const prefix = this.normalPrefix;
68
+ this.normalPrefix = "";
69
+ if (prefix === "d" && data === "d") {
70
+ this.deleteCurrentLine();
71
+ return;
72
+ }
73
+ if (prefix === "g" && data === "g") {
74
+ this.cursorLine = 1;
75
+ return;
76
+ }
77
+ if (prefix === "y" && data === "y") {
78
+ this.yankedLine = this.currentLineText;
79
+ return;
80
+ }
81
+ // Prefix didn't complete into a known sequence -- fall through and handle `data` fresh.
82
+ }
83
+
84
+ switch (data) {
85
+ case "h":
86
+ this.cursorCharacter -= 1;
87
+ return;
88
+ case "l":
89
+ this.cursorCharacter += 1;
90
+ return;
91
+ case "k":
92
+ this.cursorLine -= 1;
93
+ return;
94
+ case "j":
95
+ this.cursorLine += 1;
96
+ return;
97
+ case "0":
98
+ this.cursorCharacter = 1;
99
+ return;
100
+ case "$":
101
+ this.cursorCharacter = Math.max(this.currentLineText.length, 1);
102
+ return;
103
+ case "G":
104
+ this.cursorLine = this.buffer.lineCount;
105
+ return;
106
+ case "d":
107
+ case "g":
108
+ case "y":
109
+ this.normalPrefix = data;
110
+ return;
111
+ case "i":
112
+ this.enterInsert();
113
+ return;
114
+ case "a":
115
+ this.cursorCharacter += 1;
116
+ this.enterInsert();
117
+ return;
118
+ case "I":
119
+ this.cursorCharacter = 1;
120
+ this.enterInsert();
121
+ return;
122
+ case "A":
123
+ this.cursorCharacter = this.currentLineText.length + 1;
124
+ this.enterInsert();
125
+ return;
126
+ case "o":
127
+ this.openLine(this.cursorLine);
128
+ return;
129
+ case "O":
130
+ this.openLine(this.cursorLine - 1);
131
+ return;
132
+ case "x":
133
+ this.deleteCharUnderCursor();
134
+ return;
135
+ case "p":
136
+ this.pasteYankedLine();
137
+ return;
138
+ case "u":
139
+ this.buffer.undo();
140
+ this.dirty = true;
141
+ return;
142
+ case "\x12": // Ctrl+R
143
+ this.buffer.redo();
144
+ this.dirty = true;
145
+ return;
146
+ case ":":
147
+ this.mode = "command";
148
+ this.commandText = "";
149
+ return;
150
+ case "\x13": // Ctrl+S
151
+ this.pendingAction = { kind: "save" };
152
+ return;
153
+ case "K":
154
+ this.pendingAction = { kind: "hover" };
155
+ return;
156
+ default:
157
+ return;
158
+ }
159
+ }
160
+
161
+ private openLine(afterLine: number): void {
162
+ const offset = this.buffer.offsetAt({ line: Math.max(afterLine, 1), character: this.buffer.lineText(Math.max(afterLine, 1)).length + 1 });
163
+ this.buffer.insert(offset, "\n");
164
+ this.cursorLine = Math.max(afterLine, 1) + 1;
165
+ this.cursorCharacter = 1;
166
+ this.enterInsert();
167
+ this.dirty = true;
168
+ }
169
+
170
+ private deleteCharUnderCursor(): void {
171
+ const line = this.currentLineText;
172
+ if (this.cursorCharacter > line.length) return;
173
+ const from = this.buffer.offsetAt({ line: this.cursorLine, character: this.cursorCharacter });
174
+ this.buffer.delete(from, from + 1);
175
+ this.dirty = true;
176
+ }
177
+
178
+ private deleteCurrentLine(): void {
179
+ const isLastLine = this.cursorLine === this.buffer.lineCount;
180
+ const from = this.buffer.offsetAt({ line: this.cursorLine, character: 1 });
181
+ const to = isLastLine
182
+ ? this.buffer.offsetAt({ line: this.cursorLine, character: this.currentLineText.length + 1 })
183
+ : this.buffer.offsetAt({ line: this.cursorLine + 1, character: 1 });
184
+ this.yankedLine = this.currentLineText;
185
+ this.buffer.delete(from, to);
186
+ if (isLastLine && this.cursorLine > 1) this.cursorLine -= 1;
187
+ this.cursorCharacter = 1;
188
+ this.dirty = true;
189
+ }
190
+
191
+ private pasteYankedLine(): void {
192
+ if (this.yankedLine === undefined) return;
193
+ const offset = this.buffer.offsetAt({ line: this.cursorLine, character: this.currentLineText.length + 1 });
194
+ this.buffer.insert(offset, `\n${this.yankedLine}`);
195
+ this.cursorLine += 1;
196
+ this.cursorCharacter = 1;
197
+ this.dirty = true;
198
+ }
199
+
200
+ // ── Insert mode ──────────────────────────────────────────────────────────
201
+
202
+ private handleInsertKey(data: string): void {
203
+ if (data === "\x1b") {
204
+ this.insertPendingJ = false;
205
+ this.mode = "normal";
206
+ this.cursorCharacter = Math.max(this.cursorCharacter - 1, 1);
207
+ return;
208
+ }
209
+
210
+ // "jk" chord exits insert without inserting either character -- mirrors the user's own
211
+ // real Neovim muscle memory (`map("i", "jk", "<ESC>")`).
212
+ if (this.insertPendingJ) {
213
+ this.insertPendingJ = false;
214
+ if (data === "k") {
215
+ this.mode = "normal";
216
+ return;
217
+ }
218
+ this.insertCharacter("j");
219
+ // Fall through and handle `data` normally (it wasn't the "k" half of the chord).
220
+ }
221
+ if (data === "j" && data.length === 1) {
222
+ this.insertPendingJ = true;
223
+ return;
224
+ }
225
+
226
+ if (BACKSPACE_KEYS.has(data)) {
227
+ this.backspace();
228
+ return;
229
+ }
230
+ if (data === "\r" || data === "\n") {
231
+ this.insertNewline();
232
+ return;
233
+ }
234
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
235
+ this.insertCharacter(data);
236
+ return;
237
+ }
238
+ // Unhandled control sequence (arrow keys, etc.) -- ignored in this v1 walking skeleton.
239
+ }
240
+
241
+ private insertCharacter(char: string): void {
242
+ const offset = this.buffer.offsetAt({ line: this.cursorLine, character: this.cursorCharacter });
243
+ this.buffer.insert(offset, char);
244
+ this.cursorCharacter += char.length;
245
+ this.dirty = true;
246
+ }
247
+
248
+ private insertNewline(): void {
249
+ const offset = this.buffer.offsetAt({ line: this.cursorLine, character: this.cursorCharacter });
250
+ this.buffer.insert(offset, "\n");
251
+ this.cursorLine += 1;
252
+ this.cursorCharacter = 1;
253
+ this.dirty = true;
254
+ }
255
+
256
+ private backspace(): void {
257
+ if (this.cursorCharacter > 1) {
258
+ const offset = this.buffer.offsetAt({ line: this.cursorLine, character: this.cursorCharacter });
259
+ this.buffer.delete(offset - 1, offset);
260
+ this.cursorCharacter -= 1;
261
+ this.dirty = true;
262
+ return;
263
+ }
264
+ if (this.cursorLine > 1) {
265
+ const previousLineLength = this.buffer.lineText(this.cursorLine - 1).length;
266
+ const offset = this.buffer.offsetAt({ line: this.cursorLine, character: 1 });
267
+ this.buffer.delete(offset - 1, offset);
268
+ this.cursorLine -= 1;
269
+ this.cursorCharacter = previousLineLength + 1;
270
+ this.dirty = true;
271
+ }
272
+ }
273
+
274
+ // ── Command mode ─────────────────────────────────────────────────────────
275
+
276
+ private handleCommandKey(data: string): void {
277
+ if (data === "\x1b") {
278
+ this.mode = "normal";
279
+ this.commandText = "";
280
+ return;
281
+ }
282
+ if (data === "\r" || data === "\n") {
283
+ this.executeCommand(this.commandText);
284
+ this.mode = "normal";
285
+ this.commandText = "";
286
+ return;
287
+ }
288
+ if (BACKSPACE_KEYS.has(data)) {
289
+ if (this.commandText.length === 0) {
290
+ this.mode = "normal";
291
+ return;
292
+ }
293
+ this.commandText = this.commandText.slice(0, -1);
294
+ return;
295
+ }
296
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
297
+ this.commandText += data;
298
+ }
299
+ }
300
+
301
+ private executeCommand(command: string): void {
302
+ switch (command) {
303
+ case "w":
304
+ this.pendingAction = { kind: "save" };
305
+ return;
306
+ case "q":
307
+ this.pendingAction = { kind: "quit" };
308
+ return;
309
+ case "wq":
310
+ case "x":
311
+ this.pendingAction = { kind: "save-and-quit" };
312
+ return;
313
+ default:
314
+ return;
315
+ }
316
+ }
317
+ }
@@ -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 };
@@ -43,9 +43,9 @@ import { Type } from "typebox";
43
43
  /** Real ANSI-aware measurement for Table -- Malevich's own default is ASCII-only, unsafe against theme-styled cell/header text. */
44
44
  const tableMeasure: TextMeasure = { visibleWidth, truncateToWidth };
45
45
 
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";
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";
49
49
  import {
50
50
  type CallHierarchyToolDetails,
51
51
  formatCallHierarchyCall,
@@ -66,29 +66,31 @@ import {
66
66
  formatReachableFromResult,
67
67
  formatWorkspaceMapCall,
68
68
  formatWorkspaceMapResult,
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 { 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";
74
76
  import {
75
77
  formatExternalSearchCall,
76
78
  formatGithubRepoSearchResult,
77
79
  formatNpmPackageSearchResult,
78
80
  formatSourcegraphCodeSearchResult,
79
- } from "./external-search-rendering.ts";
80
- import { createLectorFindFilesOperations } from "./find-files-operations.ts";
81
- import { formatFindFilesCall, formatFindFilesResult } from "./find-files-rendering.ts";
82
- import { createLectorFindSymbolsOperations } from "./find-symbols-operations.ts";
83
- import { describeFindSymbolSources, formatFindSymbolsCall, formatFindSymbolsResult } from "./find-symbols-rendering.ts";
84
- import { createLectorGitOperations } from "./git-operations.ts";
85
- 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";
86
88
  import { setNewWorkspaceObserver } from "./lector-client.ts";
87
- import { createLectorLineEditOperations } from "./line-edit-operations.ts";
88
- import { formatLineEditCall, formatLineEditResult } from "./line-edit-rendering.ts";
89
- 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";
90
92
  import { isFilesystemRoot, nearestGitRoot } from "./nearest-workspace-root.ts";
91
- import { createLectorPackageSourceOperations, type PackageSourceListPage } from "./package-source-operations.ts";
93
+ import { createLectorPackageSourceOperations, type PackageSourceListPage } from "./package-source/operations.ts";
92
94
  import {
93
95
  buildPackageSourceListTableRows,
94
96
  formatPackageSourceCall,
@@ -99,12 +101,12 @@ import {
99
101
  PACKAGE_SOURCE_LIST_TABLE_COLUMNS,
100
102
  PACKAGE_SOURCE_LIST_VISIBLE_ROWS,
101
103
  packageSourceListMoreLine,
102
- } from "./package-source-rendering.ts";
103
- import { createLectorReadOperations } from "./read-operations.ts";
104
- import { createReferenceBasedRenameOperations } from "./reference-based-rename-operations.ts";
105
- import { createRenameOperations } from "./rename-operations.ts";
106
- import { createRepoCacheEvictOperations } from "./repo-cache-evict-operations.ts";
107
- 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";
108
110
  import {
109
111
  buildRepoCacheTableRows,
110
112
  formatRepoCacheCall,
@@ -114,21 +116,21 @@ import {
114
116
  REPO_CACHE_TABLE_COLUMNS,
115
117
  REPO_CACHE_VISIBLE_ROWS,
116
118
  repoCacheMoreLine,
117
- } from "./repo-cache-rendering.ts";
118
- import { createLectorRepoFetchOperations } from "./repo-fetch-operations.ts";
119
- import { createLectorSearchOperations } from "./search-operations.ts";
120
- import { formatSearchCall, formatSearchResult } from "./search-rendering.ts";
121
- import { type AnnotationAnchorInput, createLectorSymbolAnnotationOperations } from "./symbol-annotation-operations.ts";
122
- 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";
123
125
  import {
124
126
  type CachePresentationState,
125
127
  cacheContextMessage,
126
128
  createWorkspaceCacheOperations,
127
129
  describeCacheState,
128
130
  monitorWorkspaceCache,
129
- } from "./workspace-cache-operations.ts";
130
- import { formatJobSnapshotResult, formatWorkspaceCacheCall, formatWorkspaceCacheStatusResult } from "./workspace-cache-rendering.ts";
131
- 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";
132
134
 
133
135
  function describeIntelligenceSource(provenance: IntelligenceProvenance): string {
134
136
  return `${provenance.fidelity} via ${provenance.backend}`;
@@ -344,6 +346,42 @@ export default function (pi: ExtensionAPI) {
344
346
  });
345
347
 
346
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
+
347
385
  const referenceBasedRenameOperations = createReferenceBasedRenameOperations();
348
386
  const renameOperations = createRenameOperations();
349
387
  const positionParameters = {
@@ -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[];
@@ -1,7 +1,7 @@
1
1
  import type { PackageSourceListEntry, PackageSourceOperationResult } from "@danypops/lector";
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
3
  import { renderTruncatedList, type TableColumn } 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_CANDIDATES = 5;
7
7
 
@@ -36,33 +36,40 @@ export function formatPackageSourceCall(
36
36
  export function formatPackageSourceResult(result: PackageSourceOperationResult | undefined, expanded: boolean, theme: LectorTheme): string {
37
37
  if (!result) return theme.fg("dim", "No package-source result.");
38
38
  const { outcome } = result;
39
- if (outcome.status === "verified") {
40
- return [
41
- `${theme.fg("accent", result.workspaceId ?? "unregistered")} ${theme.fg("success", `${outcome.coordinate.name}@${outcome.coordinate.resolvedVersion}`)}`,
42
- `${outcome.workspace.cachePath}`,
43
- `${outcome.repository.url ?? "local source"}@${outcome.repository.resolvedRef ?? "local"} ${outcome.repository.commit ?? outcome.verification.integrity}`,
44
- ].join("\n");
39
+ switch (outcome.status) {
40
+ case "verified":
41
+ return [
42
+ `${theme.fg("accent", result.workspaceId ?? "unregistered")} ${theme.fg("success", `${outcome.coordinate.name}@${outcome.coordinate.resolvedVersion}`)}`,
43
+ `${outcome.workspace.cachePath}`,
44
+ `${outcome.repository.url ?? "local source"}@${outcome.repository.resolvedRef ?? "local"} ${outcome.repository.commit ?? outcome.verification.integrity}`,
45
+ ].join("\n");
46
+ case "ambiguous": {
47
+ const lines = [
48
+ theme.fg("warning", `Ambiguous package source (${outcome.code})`),
49
+ ...renderTruncatedList({
50
+ items: outcome.candidates,
51
+ expanded,
52
+ visibleCount: DEFAULT_VISIBLE_CANDIDATES,
53
+ formatItem: (candidate) => `${candidate.version} -- ${candidate.source}`,
54
+ moreLine: (hidden) => theme.fg("dim", `… ${hidden} more`),
55
+ truncationWarning: outcome.truncated ? theme.fg("dim", "More candidates were truncated by the daemon.") : undefined,
56
+ }),
57
+ ];
58
+ return lines.join("\n");
59
+ }
60
+ case "unauthenticated":
61
+ return theme.fg("warning", `Authentication required (${outcome.code}): configure ${outcome.requiredCredentialNames.join(", ")}`);
62
+ case "oversized":
63
+ return theme.fg("warning", `Source resolution exceeded ${outcome.resource} limit ${outcome.limit}.`);
64
+ case "mismatched":
65
+ return theme.fg("error", `Source mismatch (${outcome.code}): expected ${outcome.expected}, got ${outcome.actual}.`);
66
+ case "unavailable":
67
+ return theme.fg("warning", `Source unavailable (${outcome.code}).`);
68
+ default: {
69
+ const exhaustive: never = outcome;
70
+ throw new Error(`unhandled package source outcome status: ${JSON.stringify(exhaustive)}`);
71
+ }
45
72
  }
46
- if (outcome.status === "ambiguous") {
47
- const lines = [
48
- theme.fg("warning", `Ambiguous package source (${outcome.code})`),
49
- ...renderTruncatedList({
50
- items: outcome.candidates,
51
- expanded,
52
- visibleCount: DEFAULT_VISIBLE_CANDIDATES,
53
- formatItem: (candidate) => `${candidate.version} -- ${candidate.source}`,
54
- moreLine: (hidden) => theme.fg("dim", `… ${hidden} more`),
55
- truncationWarning: outcome.truncated ? theme.fg("dim", "More candidates were truncated by the daemon.") : undefined,
56
- }),
57
- ];
58
- return lines.join("\n");
59
- }
60
- if (outcome.status === "unauthenticated") {
61
- return theme.fg("warning", `Authentication required (${outcome.code}): configure ${outcome.requiredCredentialNames.join(", ")}`);
62
- }
63
- if (outcome.status === "oversized") return theme.fg("warning", `Source resolution exceeded ${outcome.resource} limit ${outcome.limit}.`);
64
- if (outcome.status === "mismatched") return theme.fg("error", `Source mismatch (${outcome.code}): expected ${outcome.expected}, got ${outcome.actual}.`);
65
- return theme.fg("warning", `Source unavailable (${outcome.code}).`);
66
73
  }
67
74
 
68
75
  /** Empty-state fallback only -- a non-empty page renders as a real Table (see buildPackageSourceListTableRows) so the human channel actually shows what's resolved, not just a bare count. Mirrors formatRepoCacheListResult. */
@@ -74,7 +81,7 @@ export function formatPackageSourceListResult(
74
81
  return count === 0 ? theme.fg("dim", "no resolved package sources") : theme.fg("success", `${count} resolved package source${count === 1 ? "" : "s"}`);
75
82
  }
76
83
 
77
- /** Powers of 1024, one decimal past the first. Mirrors repo-cache-rendering.ts's own formatCacheSize -- kept as a small local duplicate rather than a shared export, matching that file's own precedent. */
84
+ /** Powers of 1024, one decimal past the first. Mirrors repo-cache/rendering.ts's own formatCacheSize -- kept as a small local duplicate rather than a shared export, matching that file's own precedent. */
78
85
  function formatCacheSize(bytes: number): string {
79
86
  const units = ["B", "KB", "MB", "GB", "TB"] as const;
80
87
  let value = bytes;
@@ -1,8 +1,8 @@
1
1
  import { constants } from "node:fs";
2
2
  import { access as fsAccess, readFile as fsReadFile } from "node:fs/promises";
3
3
  import type { ReadOperations } from "@earendil-works/pi-coding-agent";
4
- import { lectorClient, withWorkspace, workspaceForPath } from "./lector-client.ts";
5
- import { toWorkspaceRelativePath } from "./workspace-relative-path.ts";
4
+ import { lectorClient, withWorkspace, workspaceForPath } from "../lector-client.ts";
5
+ import { toWorkspaceRelativePath } from "../workspace-relative-path.ts";
6
6
 
7
7
  /**
8
8
  * Lector's core domain (RawRead/ExpectedHashEdit) is deliberately text-only
@@ -1,5 +1,5 @@
1
1
  import type { OperationOutputs } from "@danypops/lector";
2
- import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath } from "./lector-client.ts";
2
+ import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath } from "../lector-client.ts";
3
3
 
4
4
  /**
5
5
  * Thin wrapper over Lector's non-LSP reference-based rename: moves a file and rewrites every
@@ -1,5 +1,5 @@
1
1
  import type { OperationOutputs } from "@danypops/lector";
2
- import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath } from "./lector-client.ts";
2
+ import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath } from "../lector-client.ts";
3
3
 
4
4
  /**
5
5
  * Thin wrappers over Lector's LSP-driven prepareRename/rename -- position-based (path + 1-indexed
@@ -1,8 +1,8 @@
1
- import { lectorClient } from "./lector-client.ts";
1
+ import { lectorClient } from "../lector-client.ts";
2
2
 
3
3
  /**
4
4
  * Thin wrapper over repo.evictCache -- no `directory`/workspaceForDirectory resolution (matching
5
- * repo-fetch-operations.ts and repo-cache-list-operations.ts: this targets the daemon-wide fetch
5
+ * repo-fetch/operations.ts and repo-cache/list-operations.ts: this targets the daemon-wide fetch
6
6
  * cache, not a workspace-scoped concept).
7
7
  */
8
8
  export interface RepoCacheEvictOperations {
@@ -1,9 +1,9 @@
1
1
  import type { CachedRepositoryPage } from "@danypops/lector";
2
- import { lectorClient } from "./lector-client.ts";
2
+ import { lectorClient } from "../lector-client.ts";
3
3
 
4
4
  /**
5
5
  * Thin wrapper over repo.listCache -- no network, no cache mutation, no `directory`/
6
- * workspaceForDirectory resolution (matching repo-fetch-operations.ts: this queries the
6
+ * workspaceForDirectory resolution (matching repo-fetch/operations.ts: this queries the
7
7
  * daemon-wide fetch cache, not a workspace-scoped concept).
8
8
  */
9
9
  export interface RepoCacheListOperations {
@@ -1,7 +1,7 @@
1
1
  import type { CachedRepositoryEntry, CachedRepositoryPage, RepoFetchResult } from "@danypops/lector";
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
3
  import type { TableColumn } 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
  /** Table has no row-count bound of its own; a cache can grow arbitrarily large even though repo_cache's own `maxResults` bounds any one page, so the display itself still needs a cap independent of that. */
7
7
  export const REPO_CACHE_VISIBLE_ROWS = 20;
@@ -1,5 +1,5 @@
1
1
  import type { RepoFetchResult } from "@danypops/lector";
2
- import { lectorClient } from "./lector-client.ts";
2
+ import { lectorClient } from "../lector-client.ts";
3
3
 
4
4
  /**
5
5
  * Thin wrapper over repo.fetch. No `directory`/workspaceForDirectory resolution here -- unlike
@@ -1,5 +1,5 @@
1
1
  import type { TextSearchResult } 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.searchText. `directory` is required, same convention as
@@ -1,7 +1,7 @@
1
1
  import type { TextSearchResult } 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_MATCHES = 20;
7
7
 
@@ -1,5 +1,5 @@
1
1
  import type { OperationInputs, OperationOutputs } from "@danypops/lector";
2
- import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath } from "./lector-client.ts";
2
+ import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath } from "../lector-client.ts";
3
3
 
4
4
  /** A bare symbol position an anchor is given as -- symbolNodeId and the anchor's baseline file hash are derived server-side, never supplied by the caller. */
5
5
  export interface AnnotationAnchorInput {
@@ -1,5 +1,5 @@
1
1
  import type { SymbolAnnotation } 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
  const STATUS_COLOR: Record<SymbolAnnotation["status"], "success" | "warning" | "dim"> = {
5
5
  fresh: "success",
@@ -1,5 +1,5 @@
1
1
  import type { JobSnapshot, PopulateSymbolGraphResult, WorkspaceCacheStatus } 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
  export interface WorkspaceCacheOperations {
5
5
  status(directory: string, maxFiles: number, maxSymbolsPerFile: number): Promise<WorkspaceCacheStatus>;
@@ -1,5 +1,5 @@
1
1
  import type { JobSnapshot, PopulateSymbolGraphResult, WorkspaceCacheStatus } 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 WorkspaceCacheAction = "status" | "populate" | "job_status";
5
5
 
@@ -1,7 +1,7 @@
1
1
  import { type ContentHash, remoteErrorIs } from "@danypops/lector";
2
2
  import type { WriteOperations } from "@earendil-works/pi-coding-agent";
3
- import { lectorClient, withWorkspace, workspaceForPath } from "./lector-client.ts";
4
- import { toWorkspaceRelativePath } from "./workspace-relative-path.ts";
3
+ import { lectorClient, withWorkspace, workspaceForPath } from "../lector-client.ts";
4
+ import { toWorkspaceRelativePath } from "../workspace-relative-path.ts";
5
5
 
6
6
  const MAX_STALE_HASH_RETRIES = 3;
7
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.9.6",
3
+ "version": "0.10.0",
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",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@danypops/vehicle-client": "^0.2.0",
22
- "@danypops/lector": "^0.12.0",
22
+ "@danypops/lector": "^0.14.0",
23
23
  "malevich-tui-components": "^0.19.0"
24
24
  },
25
25
  "devDependencies": {