@danypops/pi-lector 0.9.6 → 0.11.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 (46) 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/apply-explorer-diffs.ts +45 -0
  9. package/extension/src/editor/directory-explorer-operations.ts +61 -0
  10. package/extension/src/editor/editor-state.ts +317 -0
  11. package/extension/src/editor/editor-theme.ts +7 -0
  12. package/extension/src/editor/explorer-component.ts +241 -0
  13. package/extension/src/editor/explorer-diff.ts +85 -0
  14. package/extension/src/editor/explorer-flow.ts +27 -0
  15. package/extension/src/editor/neovim-editor-component.ts +196 -0
  16. package/extension/src/editor/operations.ts +36 -0
  17. package/extension/src/{external-search-operations.ts → external-search/operations.ts} +1 -1
  18. package/extension/src/{external-search-rendering.ts → external-search/rendering.ts} +1 -1
  19. package/extension/src/{find-files-operations.ts → find-files/operations.ts} +1 -1
  20. package/extension/src/{find-files-rendering.ts → find-files/rendering.ts} +1 -1
  21. package/extension/src/{find-symbols-operations.ts → find-symbols/operations.ts} +1 -1
  22. package/extension/src/{find-symbols-rendering.ts → find-symbols/rendering.ts} +1 -1
  23. package/extension/src/{git-operations.ts → git/operations.ts} +1 -1
  24. package/extension/src/{git-rendering.ts → git/rendering.ts} +1 -1
  25. package/extension/src/index.ts +97 -34
  26. package/extension/src/{line-edit-operations.ts → line-edit/operations.ts} +4 -4
  27. package/extension/src/{line-edit-rendering.ts → line-edit/rendering.ts} +1 -1
  28. package/extension/src/{mutation-history-operations.ts → mutation-history/operations.ts} +2 -2
  29. package/extension/src/{package-source-operations.ts → package-source/operations.ts} +1 -1
  30. package/extension/src/{package-source-rendering.ts → package-source/rendering.ts} +35 -28
  31. package/extension/src/{read-operations.ts → read/operations.ts} +2 -2
  32. package/extension/src/{reference-based-rename-operations.ts → reference-based-rename/operations.ts} +1 -1
  33. package/extension/src/{rename-operations.ts → rename/operations.ts} +1 -1
  34. package/extension/src/{repo-cache-evict-operations.ts → repo-cache/evict-operations.ts} +2 -2
  35. package/extension/src/{repo-cache-list-operations.ts → repo-cache/list-operations.ts} +2 -2
  36. package/extension/src/{repo-cache-rendering.ts → repo-cache/rendering.ts} +1 -1
  37. package/extension/src/{repo-fetch-operations.ts → repo-fetch/operations.ts} +1 -1
  38. package/extension/src/{search-operations.ts → search/operations.ts} +1 -1
  39. package/extension/src/{search-rendering.ts → search/rendering.ts} +1 -1
  40. package/extension/src/{symbol-annotation-operations.ts → symbol-annotation/operations.ts} +1 -1
  41. package/extension/src/{symbol-annotation-rendering.ts → symbol-annotation/rendering.ts} +1 -1
  42. package/extension/src/{workspace-cache-operations.ts → workspace-cache/operations.ts} +1 -1
  43. package/extension/src/{workspace-cache-rendering.ts → workspace-cache/rendering.ts} +1 -1
  44. package/extension/src/{write-operations.ts → write/operations.ts} +2 -2
  45. package/package.json +3 -2
  46. /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,45 @@
1
+ import type { DirectoryExplorerSession } from "./directory-explorer-operations.ts";
2
+ import type { ExplorerDiff } from "./explorer-diff.ts";
3
+
4
+ function joinRelative(directory: string, name: string): string {
5
+ return directory === "" ? name : `${directory}/${name}`;
6
+ }
7
+
8
+ /** Applies a diff batch to `directory`, in order. Never partial-rolls-back a failure partway through -- the caller (the explorer's own :w confirmation flow) is responsible for surfacing which operations landed before an error, via each real thrown error's own message. */
9
+ export async function applyExplorerDiffs(session: DirectoryExplorerSession, directory: string, diffs: readonly ExplorerDiff[]): Promise<void> {
10
+ for (const diff of diffs) {
11
+ switch (diff.kind) {
12
+ case "create":
13
+ if (diff.isDirectory) await session.createDirectory(joinRelative(directory, diff.name));
14
+ else await session.createFile(joinRelative(directory, diff.name));
15
+ break;
16
+ case "rename":
17
+ await session.renamePath(joinRelative(directory, diff.fromName), joinRelative(directory, diff.toName));
18
+ break;
19
+ case "delete":
20
+ if (diff.isDirectory) await session.deleteDirectory(joinRelative(directory, diff.name));
21
+ else await session.deleteFile(joinRelative(directory, diff.name));
22
+ break;
23
+ default: {
24
+ const exhaustive: never = diff;
25
+ throw new Error(`unreachable explorer diff kind: ${JSON.stringify(exhaustive)}`);
26
+ }
27
+ }
28
+ }
29
+ }
30
+
31
+ /** One human-readable confirmation line for a pending diff, shown before :w actually applies anything. */
32
+ export function summarizeExplorerDiff(diff: ExplorerDiff): string {
33
+ switch (diff.kind) {
34
+ case "create":
35
+ return `+ ${diff.name}${diff.isDirectory ? "/" : ""}`;
36
+ case "rename":
37
+ return `${diff.fromName} -> ${diff.toName}`;
38
+ case "delete":
39
+ return `- ${diff.name}${diff.isDirectory ? "/" : ""}`;
40
+ default: {
41
+ const exhaustive: never = diff;
42
+ throw new Error(`unreachable explorer diff kind: ${JSON.stringify(exhaustive)}`);
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,61 @@
1
+ import type { DirectoryListing, WorkspaceId } from "@danypops/lector";
2
+ import { lectorClient, withWorkspace, workspaceForPathOrDirectory } from "../lector-client.ts";
3
+
4
+ /** Oil's own default (view_options.show_hidden = false): dotfiles/dotdirs excluded unless explicitly toggled. Toggling is deferred (v2) -- this always applies for now, matching the tool's own out-of-the-box behavior. */
5
+ function isHidden(name: string): boolean {
6
+ return name.startsWith(".");
7
+ }
8
+
9
+ /**
10
+ * Backs the /editor no-path Oil-style explorer: one resolved workspace for the whole browsing
11
+ * session (navigating between directories never re-resolves a workspace, just changes which
12
+ * relative path is listed), plus the four mutation primitives its :w diff-and-apply step needs.
13
+ *
14
+ * deleteFile is not a thin pass-through: workspace.deleteEntry is hash-guarded and the explorer
15
+ * only ever has a directory *listing* (no content hash) for the line being deleted, so it reads
16
+ * the file's current hash immediately before deleting it -- an extra round trip, acceptable for
17
+ * an infrequent interactive action, not a hot path.
18
+ */
19
+ export interface DirectoryExplorerSession {
20
+ readonly root: string;
21
+ readonly workspaceId: WorkspaceId;
22
+ listDirectory(relativePath: string): Promise<DirectoryListing>;
23
+ createFile(relativePath: string): Promise<void>;
24
+ createDirectory(relativePath: string): Promise<void>;
25
+ renamePath(oldRelativePath: string, newRelativePath: string): Promise<void>;
26
+ deleteFile(relativePath: string): Promise<void>;
27
+ deleteDirectory(relativePath: string): Promise<void>;
28
+ }
29
+
30
+ export function openDirectoryExplorer(absoluteDirectory: string): Promise<DirectoryExplorerSession> {
31
+ return withWorkspace(
32
+ () => workspaceForPathOrDirectory(absoluteDirectory),
33
+ async ({ workspaceId, root }) => {
34
+ const client = await lectorClient();
35
+ return {
36
+ root,
37
+ workspaceId,
38
+ listDirectory: async (relativePath: string): Promise<DirectoryListing> => {
39
+ const listing = await client.call("workspace.listDirectory", { workspaceId, path: relativePath });
40
+ return { ...listing, entries: listing.entries.filter((entry) => !isHidden(entry.name)) };
41
+ },
42
+ createFile: async (relativePath: string) => {
43
+ await client.callOnce("workspace.exactEdit", { workspaceId, path: relativePath, expectedHash: null, content: "" });
44
+ },
45
+ createDirectory: async (relativePath: string) => {
46
+ await client.callOnce("workspace.createDirectory", { workspaceId, path: relativePath });
47
+ },
48
+ renamePath: async (oldRelativePath: string, newRelativePath: string) => {
49
+ await client.callOnce("workspace.renamePath", { workspaceId, oldPath: oldRelativePath, newPath: newRelativePath });
50
+ },
51
+ deleteFile: async (relativePath: string) => {
52
+ const { hash } = await client.call("workspace.rawRead", { workspaceId, path: relativePath });
53
+ await client.callOnce("workspace.deleteEntry", { workspaceId, path: relativePath, expectedHash: hash });
54
+ },
55
+ deleteDirectory: async (relativePath: string) => {
56
+ await client.callOnce("workspace.deleteDirectory", { workspaceId, path: relativePath });
57
+ },
58
+ };
59
+ },
60
+ );
61
+ }
@@ -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,7 @@
1
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** Real theme, narrowed to exactly what the editor/explorer components need -- avoids depending on pi-coding-agent's full internal Theme shape. */
4
+ export interface EditorTheme {
5
+ fg(color: ThemeColor, text: string): string;
6
+ bg(color: "selectedBg", text: string): string;
7
+ }