@valyrianjs/terminal 0.1.0 → 0.1.2

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 (107) hide show
  1. package/README.md +105 -55
  2. package/dist/ansi.d.ts +20 -4
  3. package/dist/ansi.d.ts.map +1 -1
  4. package/dist/ansi.js +171 -47
  5. package/dist/ansi.js.map +1 -1
  6. package/dist/editor-state.d.ts +22 -0
  7. package/dist/editor-state.d.ts.map +1 -0
  8. package/dist/editor-state.js +110 -0
  9. package/dist/editor-state.js.map +1 -0
  10. package/dist/events.d.ts +1 -4
  11. package/dist/events.d.ts.map +1 -1
  12. package/dist/events.js +15 -38
  13. package/dist/events.js.map +1 -1
  14. package/dist/index.d.ts +4 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/keymap.d.ts +7 -0
  19. package/dist/keymap.d.ts.map +1 -0
  20. package/dist/keymap.js +133 -0
  21. package/dist/keymap.js.map +1 -0
  22. package/dist/layout.d.ts +10 -1
  23. package/dist/layout.d.ts.map +1 -1
  24. package/dist/layout.js +97 -7
  25. package/dist/layout.js.map +1 -1
  26. package/dist/mouse.d.ts +1 -0
  27. package/dist/mouse.d.ts.map +1 -1
  28. package/dist/mouse.js +24 -1
  29. package/dist/mouse.js.map +1 -1
  30. package/dist/output-writer.d.ts +9 -0
  31. package/dist/output-writer.d.ts.map +1 -0
  32. package/dist/output-writer.js +79 -0
  33. package/dist/output-writer.js.map +1 -0
  34. package/dist/paste.d.ts +7 -0
  35. package/dist/paste.d.ts.map +1 -0
  36. package/dist/paste.js +18 -0
  37. package/dist/paste.js.map +1 -0
  38. package/dist/primitives.d.ts +8 -1
  39. package/dist/primitives.d.ts.map +1 -1
  40. package/dist/primitives.js +9 -1
  41. package/dist/primitives.js.map +1 -1
  42. package/dist/render.d.ts +8 -3
  43. package/dist/render.d.ts.map +1 -1
  44. package/dist/render.js +840 -67
  45. package/dist/render.js.map +1 -1
  46. package/dist/runtime.d.ts +29 -0
  47. package/dist/runtime.d.ts.map +1 -0
  48. package/dist/runtime.js +215 -0
  49. package/dist/runtime.js.map +1 -0
  50. package/dist/scheduler.d.ts +8 -0
  51. package/dist/scheduler.d.ts.map +1 -0
  52. package/dist/scheduler.js +24 -0
  53. package/dist/scheduler.js.map +1 -0
  54. package/dist/session.d.ts.map +1 -1
  55. package/dist/session.js +729 -199
  56. package/dist/session.js.map +1 -1
  57. package/dist/stream-log.d.ts +40 -0
  58. package/dist/stream-log.d.ts.map +1 -0
  59. package/dist/stream-log.js +73 -0
  60. package/dist/stream-log.js.map +1 -0
  61. package/dist/text.d.ts +3 -0
  62. package/dist/text.d.ts.map +1 -0
  63. package/dist/text.js +19 -0
  64. package/dist/text.js.map +1 -0
  65. package/dist/theme.d.ts +7 -0
  66. package/dist/theme.d.ts.map +1 -0
  67. package/dist/theme.js +254 -0
  68. package/dist/theme.js.map +1 -0
  69. package/dist/tree.d.ts +2 -0
  70. package/dist/tree.d.ts.map +1 -1
  71. package/dist/tree.js +42 -1
  72. package/dist/tree.js.map +1 -1
  73. package/dist/types.d.ts +183 -18
  74. package/dist/types.d.ts.map +1 -1
  75. package/docs/api-reference.md +302 -136
  76. package/docs/assets/quick-note.svg +13 -0
  77. package/docs/cookbook.md +297 -202
  78. package/docs/core-concepts.md +143 -55
  79. package/docs/getting-started.md +209 -90
  80. package/docs/interaction-model.md +95 -61
  81. package/docs/primitive-gallery.md +365 -0
  82. package/docs/session-runtime.md +132 -363
  83. package/docs/valyrian-modules.md +3196 -0
  84. package/llms-full.txt +5357 -0
  85. package/package.json +21 -8
  86. package/src/ansi.ts +269 -0
  87. package/src/clipboard.ts +76 -0
  88. package/src/editor-state.ts +162 -0
  89. package/src/events.ts +163 -0
  90. package/src/index.ts +92 -0
  91. package/src/keymap.ts +151 -0
  92. package/src/layout.ts +282 -0
  93. package/src/mouse.ts +68 -0
  94. package/src/output-writer.ts +93 -0
  95. package/src/paste.ts +23 -0
  96. package/src/primitives.ts +52 -0
  97. package/src/render.ts +1107 -0
  98. package/src/runtime.ts +273 -0
  99. package/src/scheduler.ts +33 -0
  100. package/src/session.ts +1260 -0
  101. package/src/stream-log.ts +96 -0
  102. package/src/text.ts +20 -0
  103. package/src/theme.ts +263 -0
  104. package/src/tree.ts +169 -0
  105. package/src/types.ts +523 -0
  106. package/tsconfig.json +4 -7
  107. package/docs/local-demo.md +0 -28
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valyrianjs/terminal",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Terminal adapter for valyrian.js",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -23,22 +23,35 @@
23
23
  "files": [
24
24
  "dist",
25
25
  "docs",
26
+ "src",
27
+ "!docs/local-demo.md",
28
+ "!docs/plans",
26
29
  "README.md",
27
30
  "tsconfig.json",
28
- "tsconfig.build.json"
31
+ "tsconfig.build.json",
32
+ "llms-full.txt"
29
33
  ],
30
34
  "scripts": {
31
- "test": "bun test",
35
+ "test": "bun test --max-concurrency=1",
36
+ "demo:dogfood": "bun examples/opencode-dogfood.tsx",
37
+ "llms:full": "bun scripts/generate-llms-full.ts",
38
+ "replay:ansi": "bun scripts/replay-ansi.ts",
32
39
  "typecheck": "bunx tsc -p tsconfig.json --noEmit",
33
40
  "build": "rm -rf dist && bunx tsc -p tsconfig.build.json"
34
41
  },
35
42
  "devDependencies": {
36
- "@types/node": "^25.3.3",
37
- "bun-types": "latest",
38
- "typescript": "^5.9.3",
39
- "valyrian.js": "^9.1.3"
43
+ "@types/node": "^25.9.1",
44
+ "@xterm/headless": "^6.0.0",
45
+ "bun-types": "^1.3.14",
46
+ "schema-shield": "^1.0.5",
47
+ "typescript": "^6.0.3",
48
+ "valyrian.js": "^9.1.13"
40
49
  },
41
50
  "peerDependencies": {
42
51
  "valyrian.js": "^9.1.3"
52
+ },
53
+ "overrides": {
54
+ "picomatch": "2.3.2",
55
+ "postcss": "8.5.15"
43
56
  }
44
- }
57
+ }
package/src/ansi.ts ADDED
@@ -0,0 +1,269 @@
1
+ import { resolveTerminalStyle, resolveTerminalStyleToken } from "./theme.js";
2
+ import type { CursorPosition, TerminalFrame, TerminalStyleSpan, TerminalTheme } from "./types.js";
3
+
4
+ export const ANSI_ENTER_ALTERNATE_SCREEN = "\u001b[?1049h";
5
+ export const ANSI_EXIT_ALTERNATE_SCREEN = "\u001b[?1049l";
6
+ export const ANSI_HIDE_CURSOR = "\u001b[?25l";
7
+ export const ANSI_SHOW_CURSOR = "\u001b[?25h";
8
+
9
+ type AnsiFrameOptions = {
10
+ showCursor?: boolean;
11
+ showCursorWhenFrameHasCursor?: boolean;
12
+ theme?: TerminalTheme;
13
+ };
14
+
15
+ export type AnsiFrameDiffResult = {
16
+ changedLineIndexes: number[];
17
+ outputChunk: string;
18
+ restoresCursor: boolean;
19
+ };
20
+
21
+ export function stripCursorMarker(value: string) {
22
+ return value.replaceAll("|", "");
23
+ }
24
+
25
+ function spanLayerPriority(span: TerminalStyleSpan) {
26
+ return span.kind === "focus" ? 0 : 1;
27
+ }
28
+
29
+ function lineSpans(spans: TerminalStyleSpan[], y: number) {
30
+ return spans
31
+ .map((span, index) => ({ span, index }))
32
+ .filter(({ span }) => span.y === y)
33
+ .sort((a, b) => a.span.x1 - b.span.x1 || spanLayerPriority(a.span) - spanLayerPriority(b.span) || b.span.x2 - a.span.x2 || a.index - b.index)
34
+ .map(({ span }) => span);
35
+ }
36
+
37
+ function tokenStyle(token: ReturnType<typeof resolveTerminalStyleToken>) {
38
+ if (!token) return undefined;
39
+ return token.style || token.color || token.background
40
+ ? { ...token.style, color: token.color ?? token.style?.color, background: token.background ?? token.style?.background }
41
+ : undefined;
42
+ }
43
+
44
+ function styleAnsiOpen(style: { color?: string; background?: string } | undefined) {
45
+ return `${colorAnsi(style?.color, false)}${colorAnsi(style?.background, true)}`;
46
+ }
47
+
48
+ function styleAnsiClose(style: { color?: string; background?: string } | undefined) {
49
+ return `${style?.background ? "\u001b[49m" : ""}${style?.color ? "\u001b[39m" : ""}`;
50
+ }
51
+
52
+ function spanAnsiOpen(span: TerminalStyleSpan, theme?: TerminalTheme) {
53
+ const { kind, style } = span;
54
+ if (style) return styleAnsiOpen(style);
55
+ const token = resolveTerminalStyleToken(kind, theme);
56
+ const resolvedTokenStyle = tokenStyle(token);
57
+ if (resolvedTokenStyle) return styleAnsiOpen(resolvedTokenStyle);
58
+ if (token?.ansiOpen) return token.ansiOpen;
59
+ try {
60
+ return styleAnsiOpen(resolveTerminalStyle(kind, theme));
61
+ } catch {
62
+ return "";
63
+ }
64
+ }
65
+
66
+ function spanAnsiClose(span: TerminalStyleSpan, theme?: TerminalTheme) {
67
+ const { kind, style } = span;
68
+ if (style) return styleAnsiClose(style);
69
+ const token = resolveTerminalStyleToken(kind, theme);
70
+ const resolvedTokenStyle = tokenStyle(token);
71
+ if (resolvedTokenStyle) return styleAnsiClose(resolvedTokenStyle);
72
+ if (token?.ansiClose) return token.ansiClose;
73
+ try {
74
+ return styleAnsiClose(resolveTerminalStyle(kind, theme));
75
+ } catch {
76
+ return "";
77
+ }
78
+ }
79
+
80
+ function colorAnsi(value: string | undefined, background: boolean) {
81
+ if (!value) return "";
82
+ const hex = value.startsWith("#") ? value.slice(1) : "";
83
+ const full = hex.length === 3
84
+ ? hex.split("").map((part) => part + part).join("")
85
+ : hex;
86
+ if (!/^[0-9a-fA-F]{6}$/.test(full)) {
87
+ throw new RangeError(`Unsupported terminal style color: ${value}`);
88
+ }
89
+ const red = Number.parseInt(full.slice(0, 2), 16);
90
+ const green = Number.parseInt(full.slice(2, 4), 16);
91
+ const blue = Number.parseInt(full.slice(4, 6), 16);
92
+ return `\u001b[${background ? 48 : 38};2;${red};${green};${blue}m`;
93
+ }
94
+
95
+ function renderAnsiLine(line: string, spans: TerminalStyleSpan[], y: number, theme?: TerminalTheme) {
96
+ let output = "";
97
+ let visibleColumn = 1;
98
+ const rowSpans = lineSpans(spans, y);
99
+ let activeSpanIndex = 0;
100
+
101
+ for (let i = 0; i < line.length; i += 1) {
102
+ while (rowSpans[activeSpanIndex]?.x1 === visibleColumn) {
103
+ output += spanAnsiOpen(rowSpans[activeSpanIndex], theme);
104
+ activeSpanIndex += 1;
105
+ }
106
+
107
+ const char = line[i];
108
+ if (char === "|") {
109
+ continue;
110
+ }
111
+
112
+ output += char;
113
+ visibleColumn += 1;
114
+
115
+ for (const span of rowSpans) {
116
+ if (span.x2 === visibleColumn) {
117
+ if (span.kind !== "focus") {
118
+ output += spanAnsiClose(span, theme);
119
+ }
120
+ }
121
+ }
122
+ for (const span of rowSpans) {
123
+ if (span.x2 === visibleColumn && span.kind === "focus") {
124
+ output += spanAnsiClose(span, theme);
125
+ }
126
+ }
127
+ }
128
+
129
+ for (const span of rowSpans) {
130
+ if (span.x1 < visibleColumn && span.x2 > visibleColumn) {
131
+ if (span.kind !== "focus") {
132
+ output += spanAnsiClose(span, theme);
133
+ }
134
+ }
135
+ }
136
+ for (const span of rowSpans) {
137
+ if (span.x1 < visibleColumn && span.x2 > visibleColumn && span.kind === "focus") {
138
+ output += spanAnsiClose(span, theme);
139
+ }
140
+ }
141
+
142
+ return output;
143
+ }
144
+
145
+ function hasPlainAffordance(token: ReturnType<typeof resolveTerminalStyleToken>) {
146
+ return typeof token !== "undefined" && ("plainPrefix" in token || "plainSuffix" in token);
147
+ }
148
+
149
+ function formatPlainLine(line: string, spans: TerminalStyleSpan[], y: number, theme?: TerminalTheme) {
150
+ let output = "";
151
+ let visibleColumn = 1;
152
+ const firstFallbackFocusY = lineSpans(spans, y).some((span) => {
153
+ const token = resolveTerminalStyleToken(span.kind, theme);
154
+ return span.kind === "focus" && !hasPlainAffordance(token) && span.x1 === 1;
155
+ })
156
+ ? Math.min(...spans.filter((span) => span.kind === "focus" && span.x1 === 1).map((span) => span.y))
157
+ : null;
158
+ const rowSpans = lineSpans(spans, y).map((span) => {
159
+ const token = resolveTerminalStyleToken(span.kind, theme);
160
+ const fallbackFocusMarker = span.kind === "focus"
161
+ && !token?.plainPrefix
162
+ && !token?.plainSuffix
163
+ && span.x1 === 1
164
+ && y === firstFallbackFocusY
165
+ && span.x2 <= line.length + 1
166
+ && !line.trimStart().startsWith(">")
167
+ && !line.includes("|")
168
+ && !line.includes("[>");
169
+ return {
170
+ ...span,
171
+ x2: fallbackFocusMarker ? Math.min(span.x2, line.trimEnd().length + 1) : span.x2,
172
+ plainPrefix: token?.plainPrefix ?? span.style?.plainPrefix ?? (fallbackFocusMarker ? ">" : ""),
173
+ plainSuffix: token?.plainSuffix ?? span.style?.plainSuffix ?? (fallbackFocusMarker ? "<" : "")
174
+ };
175
+ }).filter((span) => Boolean(span.plainPrefix || span.plainSuffix));
176
+
177
+ for (let i = 0; i < line.length; i += 1) {
178
+ const char = line[i];
179
+ if (char === "|") {
180
+ output += char;
181
+ continue;
182
+ }
183
+
184
+ for (const span of rowSpans) {
185
+ if (span.x1 === visibleColumn) {
186
+ output += span.plainPrefix;
187
+ }
188
+ }
189
+
190
+ output += char;
191
+ visibleColumn += 1;
192
+
193
+ for (const span of rowSpans) {
194
+ if (span.x2 === visibleColumn) {
195
+ if (span.kind !== "focus") {
196
+ output += span.plainSuffix;
197
+ }
198
+ }
199
+ }
200
+ for (const span of rowSpans) {
201
+ if (span.x2 === visibleColumn && span.kind === "focus") {
202
+ output += span.plainSuffix;
203
+ }
204
+ }
205
+ }
206
+
207
+ for (const span of rowSpans) {
208
+ if (span.x1 === visibleColumn) {
209
+ output += span.plainPrefix + span.plainSuffix;
210
+ }
211
+ }
212
+
213
+ return output;
214
+ }
215
+
216
+ export function formatPlainFrame(frame: TerminalFrame, options: AnsiFrameOptions = {}) {
217
+ return frame.lines.map((line, index) => formatPlainLine(line, frame.spans, index + 1, options.theme)).join("\n");
218
+ }
219
+
220
+ export function toAnsiFrame(lines: string[], cursor: CursorPosition | null, spans: TerminalStyleSpan[] = [], options: AnsiFrameOptions = {}) {
221
+ const ansiLines = lines.map((line, index) => `${renderAnsiLine(line, spans, index + 1, options.theme)}\u001b[K`);
222
+ const showCursor = options.showCursor !== false || (options.showCursorWhenFrameHasCursor === true && cursor);
223
+ const cursorCode = cursor ? `\u001b[${cursor.y};${cursor.x}H${showCursor ? ANSI_SHOW_CURSOR : ""}` : showCursor ? ANSI_SHOW_CURSOR : "";
224
+ return `\u001b[?25l\u001b[H${ansiLines.join("\n")}${cursorCode}`;
225
+ }
226
+
227
+ export function createAnsiFrameDiff(
228
+ previousAnsiLines: string[],
229
+ nextAnsiLines: string[],
230
+ cursor: CursorPosition | null,
231
+ options: AnsiFrameOptions = {}
232
+ ): AnsiFrameDiffResult {
233
+ const changedLineIndexes: number[] = [];
234
+ const writes: string[] = ["\u001b[?25l"];
235
+ const maxLines = Math.max(previousAnsiLines.length, nextAnsiLines.length);
236
+
237
+ for (let i = 0; i < maxLines; i += 1) {
238
+ const nextLine = nextAnsiLines[i] || "";
239
+ const prevLine = previousAnsiLines[i] || "";
240
+ if (nextLine !== prevLine) {
241
+ changedLineIndexes.push(i);
242
+ writes.push(`\u001b[${i + 1};1H${nextLine}\u001b[0m\u001b[K`);
243
+ }
244
+ }
245
+
246
+ if (cursor) {
247
+ writes.push(`\u001b[${cursor.y};${cursor.x}H`);
248
+ }
249
+ if (options.showCursor !== false || (options.showCursorWhenFrameHasCursor === true && cursor)) {
250
+ writes.push(ANSI_SHOW_CURSOR);
251
+ }
252
+
253
+ return {
254
+ changedLineIndexes,
255
+ outputChunk: writes.join(""),
256
+ restoresCursor: Boolean(cursor)
257
+ };
258
+ }
259
+
260
+ export function createAnsiDiffWriter(options: AnsiFrameOptions = {}) {
261
+ let previousAnsiLines: string[] = [];
262
+
263
+ return function toAnsiDiff(lines: string[], cursor: CursorPosition | null, spans: TerminalStyleSpan[] = []) {
264
+ const ansiLines = lines.map((line, index) => renderAnsiLine(line, spans, index + 1, options.theme));
265
+ const diff = createAnsiFrameDiff(previousAnsiLines, ansiLines, cursor, options);
266
+ previousAnsiLines = ansiLines.slice();
267
+ return diff.outputChunk;
268
+ };
269
+ }
@@ -0,0 +1,76 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ import type { TerminalClipboardAdapter } from "./types.js";
4
+
5
+ function tryRead(command: string, args: string[]) {
6
+ try {
7
+ const result = spawnSync(command, args, { encoding: "utf8" });
8
+ if (result.status === 0) {
9
+ return String(result.stdout || "").replace(/\r\n/g, "\n").replace(/\n$/, "");
10
+ }
11
+ } catch {
12
+ return undefined;
13
+ }
14
+ return undefined;
15
+ }
16
+
17
+ function tryWrite(command: string, args: string[], value: string) {
18
+ try {
19
+ const result = spawnSync(command, args, { input: value, encoding: "utf8" });
20
+ return result.status === 0;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ function createDarwinClipboard(): TerminalClipboardAdapter {
27
+ return {
28
+ readText() {
29
+ return tryRead("pbpaste", []);
30
+ },
31
+ writeText(value: string) {
32
+ tryWrite("pbcopy", [], value);
33
+ }
34
+ };
35
+ }
36
+
37
+ function createWindowsClipboard(): TerminalClipboardAdapter {
38
+ return {
39
+ readText() {
40
+ return tryRead("powershell.exe", ["-NoProfile", "-Command", "Get-Clipboard"]);
41
+ },
42
+ writeText(value: string) {
43
+ if (!tryWrite("clip.exe", [], value)) {
44
+ tryWrite("powershell.exe", ["-NoProfile", "-Command", "Set-Clipboard"], value);
45
+ }
46
+ }
47
+ };
48
+ }
49
+
50
+ function createLinuxClipboard(): TerminalClipboardAdapter {
51
+ return {
52
+ readText() {
53
+ return tryRead("wl-paste", ["-n"]) || tryRead("xclip", ["-selection", "clipboard", "-out"]) || tryRead("xsel", ["--clipboard", "--output"]);
54
+ },
55
+ writeText(value: string) {
56
+ if (tryWrite("wl-copy", [], value)) {
57
+ return;
58
+ }
59
+ if (tryWrite("xclip", ["-selection", "clipboard", "-in"], value)) {
60
+ return;
61
+ }
62
+ tryWrite("xsel", ["--clipboard", "--input"], value);
63
+ }
64
+ };
65
+ }
66
+
67
+ export function createSystemClipboardAdapter(): TerminalClipboardAdapter {
68
+ switch (process.platform) {
69
+ case "darwin":
70
+ return createDarwinClipboard();
71
+ case "win32":
72
+ return createWindowsClipboard();
73
+ default:
74
+ return createLinuxClipboard();
75
+ }
76
+ }
@@ -0,0 +1,162 @@
1
+ export interface EditorCursor {
2
+ line: number;
3
+ column: number;
4
+ }
5
+
6
+ export interface EditorState {
7
+ value: string;
8
+ lines: string[];
9
+ cursor: EditorCursor;
10
+ desiredColumn?: number;
11
+ }
12
+
13
+ export type EditorCursorDirection = "left" | "right" | "up" | "down" | "home" | "end";
14
+
15
+ export interface EditorOperationResult {
16
+ value: string;
17
+ state: EditorState;
18
+ }
19
+
20
+ export function createEditorState(value: string, cursor?: EditorCursor, desiredColumn?: number): EditorState {
21
+ return normalizeEditorState(value, cursor, desiredColumn);
22
+ }
23
+
24
+ export function normalizeEditorState(value: string, cursor?: EditorCursor, desiredColumn?: number): EditorState {
25
+ const normalizedValue = normalizeLineEndings(value);
26
+ const lines = normalizedValue.split("\n");
27
+ const line = clamp(cursor?.line ?? lines.length - 1, 0, lines.length - 1);
28
+ const column = clamp(cursor?.column ?? lines[line].length, 0, lines[line].length);
29
+ const state: EditorState = { value: normalizedValue, lines, cursor: { line, column } };
30
+
31
+ if (desiredColumn !== undefined) {
32
+ state.desiredColumn = Math.max(0, Math.trunc(desiredColumn));
33
+ }
34
+
35
+ return state;
36
+ }
37
+
38
+ export function insertEditorText(value: string, state: EditorState, text: string): EditorOperationResult {
39
+ const normalizedText = normalizeLineEndings(text);
40
+ const normalizedState = normalizeEditorState(value, state.cursor);
41
+ const index = cursorToIndex(normalizedState.lines, normalizedState.cursor);
42
+ const nextValue = normalizedState.value.slice(0, index) + normalizedText + normalizedState.value.slice(index);
43
+ const insertedLines = normalizedText.split("\n");
44
+ const cursor =
45
+ insertedLines.length === 1
46
+ ? { line: normalizedState.cursor.line, column: normalizedState.cursor.column + insertedLines[0].length }
47
+ : {
48
+ line: normalizedState.cursor.line + insertedLines.length - 1,
49
+ column: insertedLines[insertedLines.length - 1].length
50
+ };
51
+ const nextState = createEditorState(nextValue, cursor);
52
+
53
+ return { value: nextState.value, state: nextState };
54
+ }
55
+
56
+ export function removeEditorBackward(value: string, state: EditorState): EditorOperationResult {
57
+ const normalizedState = normalizeEditorState(value, state.cursor);
58
+ const index = cursorToIndex(normalizedState.lines, normalizedState.cursor);
59
+
60
+ if (index === 0) {
61
+ return { value: normalizedState.value, state: normalizedState };
62
+ }
63
+
64
+ const cursor =
65
+ normalizedState.cursor.column > 0
66
+ ? { line: normalizedState.cursor.line, column: normalizedState.cursor.column - 1 }
67
+ : {
68
+ line: normalizedState.cursor.line - 1,
69
+ column: normalizedState.lines[normalizedState.cursor.line - 1].length
70
+ };
71
+ const nextValue = normalizedState.value.slice(0, index - 1) + normalizedState.value.slice(index);
72
+ const nextState = createEditorState(nextValue, cursor);
73
+
74
+ return { value: nextState.value, state: nextState };
75
+ }
76
+
77
+ export function removeEditorForward(value: string, state: EditorState): EditorOperationResult {
78
+ const normalizedState = normalizeEditorState(value, state.cursor);
79
+ const index = cursorToIndex(normalizedState.lines, normalizedState.cursor);
80
+
81
+ if (index >= normalizedState.value.length) {
82
+ return { value: normalizedState.value, state: normalizedState };
83
+ }
84
+
85
+ const nextValue = normalizedState.value.slice(0, index) + normalizedState.value.slice(index + 1);
86
+ const nextState = createEditorState(nextValue, normalizedState.cursor);
87
+
88
+ return { value: nextState.value, state: nextState };
89
+ }
90
+
91
+ export function moveEditorCursor(value: string, state: EditorState, direction: EditorCursorDirection): EditorState {
92
+ const normalizedState = normalizeEditorState(value, state.cursor);
93
+ const { cursor, lines } = normalizedState;
94
+
95
+ if (direction === "left") {
96
+ if (cursor.column > 0) {
97
+ return createEditorState(normalizedState.value, { line: cursor.line, column: cursor.column - 1 });
98
+ }
99
+
100
+ if (cursor.line > 0) {
101
+ return createEditorState(normalizedState.value, { line: cursor.line - 1, column: lines[cursor.line - 1].length });
102
+ }
103
+
104
+ return normalizedState;
105
+ }
106
+
107
+ if (direction === "right") {
108
+ if (cursor.column < lines[cursor.line].length) {
109
+ return createEditorState(normalizedState.value, { line: cursor.line, column: cursor.column + 1 });
110
+ }
111
+
112
+ if (cursor.line < lines.length - 1) {
113
+ return createEditorState(normalizedState.value, { line: cursor.line + 1, column: 0 });
114
+ }
115
+
116
+ return normalizedState;
117
+ }
118
+
119
+ if (direction === "home") {
120
+ return createEditorState(normalizedState.value, { line: cursor.line, column: 0 });
121
+ }
122
+
123
+ if (direction === "end") {
124
+ return createEditorState(normalizedState.value, { line: cursor.line, column: lines[cursor.line].length });
125
+ }
126
+
127
+ const desiredColumn = state.desiredColumn ?? cursor.column;
128
+ const nextLine = direction === "up" ? cursor.line - 1 : cursor.line + 1;
129
+
130
+ if (nextLine < 0 || nextLine >= lines.length) {
131
+ return withDesiredColumn(normalizedState, desiredColumn);
132
+ }
133
+
134
+ const nextState = createEditorState(normalizedState.value, {
135
+ line: nextLine,
136
+ column: Math.min(desiredColumn, lines[nextLine].length)
137
+ });
138
+
139
+ return withDesiredColumn(nextState, desiredColumn);
140
+ }
141
+
142
+ function normalizeLineEndings(value: string): string {
143
+ return value.replace(/\r\n?/g, "\n");
144
+ }
145
+
146
+ function cursorToIndex(lines: string[], cursor: EditorCursor): number {
147
+ let index = 0;
148
+
149
+ for (let line = 0; line < cursor.line; line++) {
150
+ index += lines[line].length + 1;
151
+ }
152
+
153
+ return index + cursor.column;
154
+ }
155
+
156
+ function clamp(value: number, min: number, max: number): number {
157
+ return Math.min(Math.max(Math.trunc(value), min), max);
158
+ }
159
+
160
+ function withDesiredColumn(state: EditorState, desiredColumn: number): EditorState {
161
+ return { value: state.value, lines: [...state.lines], cursor: { ...state.cursor }, desiredColumn };
162
+ }