@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
@@ -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,
@@ -0,0 +1,88 @@
1
+ import type { SymbolSearchResult, TextSearchResult, WorkspaceQueryOutcome } from "@danypops/lector";
2
+ import { lectorClient, workspaceForProjectDirectory } from "../lector-client.ts";
3
+
4
+ /**
5
+ * Fans out across explicitly-named directories only -- never the daemon's own "every registered
6
+ * workspace" default. Lector's daemon is a shared, system-wide service: leaving workspaceIds
7
+ * unset would search every project any other concurrent Pi session has ever registered against
8
+ * it, not just this session's own (confirmed live, not assumed -- a real fetched jittor workspace
9
+ * from an unrelated session showed up in an early test of this exact feature). `directories` is
10
+ * required, same "no implicit fallback" convention as find_symbols/search_code.
11
+ */
12
+ export interface CrossWorkspaceSearchOperations {
13
+ findSymbols(query: string, directories: readonly string[], timeoutMs?: number): Promise<readonly CrossWorkspaceOutcome<SymbolSearchResult>[]>;
14
+ searchText(
15
+ query: string,
16
+ directories: readonly string[],
17
+ maxMatches: number,
18
+ maxBytes: number,
19
+ timeoutMs?: number,
20
+ ): Promise<readonly CrossWorkspaceOutcome<TextSearchResult>[]>;
21
+ }
22
+
23
+ /**
24
+ * One caller-supplied directory's own outcome, labeled back by that literal directory (never
25
+ * just a workspaceId hash) so a caller can tell which of their own inputs a result belongs to.
26
+ * `collapsedWith` lists any OTHER requested directories that resolved to this same workspaceId --
27
+ * empty when this directory got its own distinct scope, as it should for a real monorepo
28
+ * subproject. A caller must be able to tell "two of my inputs turned out to be one workspace"
29
+ * apart from "these are genuinely two separate results" -- silently duplicating one payload
30
+ * under two different-looking entries is exactly the bug this exists to prevent.
31
+ */
32
+ export interface CrossWorkspaceOutcome<T> {
33
+ readonly directory: string;
34
+ readonly workspaceId: string;
35
+ readonly collapsedWith: readonly string[];
36
+ readonly outcome: WorkspaceQueryOutcome<T>;
37
+ }
38
+
39
+ async function resolveWorkspaceIds(directories: readonly string[]): Promise<readonly string[]> {
40
+ const resolved = await Promise.all(directories.map((directory) => workspaceForProjectDirectory(directory)));
41
+ return resolved.map((r) => r.workspaceId);
42
+ }
43
+
44
+ /**
45
+ * Zips the daemon's own outcomes back onto the literal directories that produced them, and
46
+ * computes collapsedWith. The daemon's search.symbols/search.text handlers map workspaceIds to
47
+ * results 1:1, in order, with no deduplication of their own (confirmed by reading service.ts's
48
+ * crossFindSymbols/crossSearchText: `targets.map(...)` over the exact `workspaceIds` array
49
+ * given) -- so a length mismatch here means that contract broke, not a normal runtime condition
50
+ * to paper over with an unsafe cast.
51
+ */
52
+ function zipOutcomes<T>(
53
+ directories: readonly string[],
54
+ workspaceIds: readonly string[],
55
+ outcomes: readonly WorkspaceQueryOutcome<T>[],
56
+ ): readonly CrossWorkspaceOutcome<T>[] {
57
+ if (outcomes.length !== directories.length) {
58
+ throw new Error(
59
+ `Lector's search fan-out returned ${outcomes.length} outcome(s) for ${directories.length} requested directories -- expected exactly one outcome per directory, in order`,
60
+ );
61
+ }
62
+ return directories.map((directory, index) => {
63
+ const workspaceId = workspaceIds[index];
64
+ const outcome = outcomes[index];
65
+ if (workspaceId === undefined || outcome === undefined) {
66
+ throw new Error(`Lector's search fan-out is missing a workspaceId/outcome for directory "${directory}"`);
67
+ }
68
+ const collapsedWith = directories.filter((_, otherIndex) => otherIndex !== index && workspaceIds[otherIndex] === workspaceId);
69
+ return { directory, workspaceId, collapsedWith, outcome };
70
+ });
71
+ }
72
+
73
+ export function createLectorCrossWorkspaceSearchOperations(): CrossWorkspaceSearchOperations {
74
+ return {
75
+ async findSymbols(query, directories, timeoutMs) {
76
+ const workspaceIds = await resolveWorkspaceIds(directories);
77
+ const client = await lectorClient();
78
+ const { results } = await client.call("search.symbols", { query, workspaceIds, timeoutMs });
79
+ return zipOutcomes(directories, workspaceIds, results);
80
+ },
81
+ async searchText(query, directories, maxMatches, maxBytes, timeoutMs) {
82
+ const workspaceIds = await resolveWorkspaceIds(directories);
83
+ const client = await lectorClient();
84
+ const { results } = await client.call("search.text", { query, maxMatches, maxBytes, workspaceIds, timeoutMs });
85
+ return zipOutcomes(directories, workspaceIds, results);
86
+ },
87
+ };
88
+ }
@@ -1,8 +1,9 @@
1
- import type { SymbolSearchResult, TextSearchResult, WorkspaceQueryOutcome } from "@danypops/lector";
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 { describeFindSymbolSources } from "./find-symbols-rendering.ts";
5
- 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";
6
7
 
7
8
  const DEFAULT_VISIBLE_PER_WORKSPACE = 10;
8
9
 
@@ -12,21 +13,43 @@ export function formatCrossWorkspaceCall(args: { directories?: unknown; query?:
12
13
  return `${theme.fg("accent", `"${query}"`)} ${theme.fg("dim", `across ${directories.length} project(s)`)}`;
13
14
  }
14
15
 
15
- function formatOutcomeHeader(outcome: WorkspaceQueryOutcome<unknown>, theme: LectorTheme): string {
16
- if (outcome.status === "ready") return theme.fg("accent", outcome.workspaceId);
17
- if (outcome.status === "loading") return `${theme.fg("warning", outcome.workspaceId)} ${theme.fg("warning", `-- still loading: ${outcome.message}`)}`;
18
- return `${theme.fg("error", outcome.workspaceId)} ${theme.fg("error", `-- ${outcome.message}`)}`;
16
+ function formatOutcomeHeader(entry: CrossWorkspaceOutcome<unknown>, theme: LectorTheme): string {
17
+ const { outcome } = entry;
18
+ const label = theme.fg("accent", entry.directory);
19
+ const lines: string[] = [];
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
+ }
35
+ // Surfaced explicitly, never silently -- two distinct inputs resolving to one workspace means
36
+ // one of their own result payloads below is a real duplicate of the other's, not two independent answers.
37
+ if (entry.collapsedWith.length > 0) {
38
+ lines.push(theme.fg("warning", ` resolved to the same workspace as: ${entry.collapsedWith.join(", ")}`));
39
+ }
40
+ return lines.join("\n");
19
41
  }
20
42
 
21
43
  export function formatFindSymbolsAcrossProjectsResult(
22
- results: readonly WorkspaceQueryOutcome<SymbolSearchResult>[] | undefined,
44
+ results: readonly CrossWorkspaceOutcome<SymbolSearchResult>[] | undefined,
23
45
  expanded: boolean,
24
46
  theme: LectorTheme,
25
47
  ): string {
26
48
  if (!results || results.length === 0) return theme.fg("dim", "No projects to search.");
27
49
  const lines: string[] = [];
28
- for (const outcome of results) {
29
- lines.push(formatOutcomeHeader(outcome, theme));
50
+ for (const entry of results) {
51
+ lines.push(formatOutcomeHeader(entry, theme));
52
+ const { outcome } = entry;
30
53
  if (outcome.status !== "ready") continue;
31
54
  lines.push(
32
55
  theme.fg("muted", ` ${outcome.result.provenance.fidelity} via ${outcome.result.provenance.backend}${outcome.result.truncated ? " (truncated)" : ""}`),
@@ -50,14 +73,15 @@ export function formatFindSymbolsAcrossProjectsResult(
50
73
  }
51
74
 
52
75
  export function formatSearchTextAcrossProjectsResult(
53
- results: readonly WorkspaceQueryOutcome<TextSearchResult>[] | undefined,
76
+ results: readonly CrossWorkspaceOutcome<TextSearchResult>[] | undefined,
54
77
  expanded: boolean,
55
78
  theme: LectorTheme,
56
79
  ): string {
57
80
  if (!results || results.length === 0) return theme.fg("dim", "No projects to search.");
58
81
  const lines: string[] = [];
59
- for (const outcome of results) {
60
- lines.push(formatOutcomeHeader(outcome, theme));
82
+ for (const entry of results) {
83
+ lines.push(formatOutcomeHeader(entry, theme));
84
+ const { outcome } = entry;
61
85
  if (outcome.status !== "ready") continue;
62
86
  if (outcome.result.matches.length === 0) {
63
87
  lines.push(theme.fg("dim", " no matches"));
@@ -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
+ }