mini-coder 0.7.4 → 0.8.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 (47) hide show
  1. package/AGENTS.md +114 -0
  2. package/README.md +53 -66
  3. package/bin/mini-coder.ts +2 -0
  4. package/demo.gif +0 -0
  5. package/package.json +17 -20
  6. package/src/agent.ts +181 -274
  7. package/src/cli.ts +101 -0
  8. package/src/config.ts +150 -0
  9. package/src/prompt.ts +54 -207
  10. package/src/session.ts +124 -69
  11. package/src/tools/bash.ts +89 -0
  12. package/src/tools/common.ts +32 -0
  13. package/src/tools/edit.ts +41 -0
  14. package/src/tools/index.ts +47 -0
  15. package/src/tools/read.ts +64 -0
  16. package/src/tui/commands.ts +63 -0
  17. package/src/tui/editor.ts +291 -0
  18. package/src/tui/highlight.ts +189 -0
  19. package/src/tui/stream.ts +142 -0
  20. package/src/tui/styles.ts +42 -0
  21. package/src/tui/term.ts +436 -0
  22. package/src/tui/theme.ts +121 -0
  23. package/src/tui/tui.ts +595 -0
  24. package/src/tui/usage.ts +67 -0
  25. package/tsconfig.json +8 -8
  26. package/bin/mc.ts +0 -11
  27. package/bun.lock +0 -350
  28. package/nono-mini-coder.json +0 -42
  29. package/src/args.ts +0 -252
  30. package/src/error-handling.test.ts +0 -163
  31. package/src/git.ts +0 -23
  32. package/src/headless.ts +0 -66
  33. package/src/index.ts +0 -43
  34. package/src/models.ts +0 -191
  35. package/src/oauth.ts +0 -147
  36. package/src/shared.ts +0 -119
  37. package/src/themes.ts +0 -234
  38. package/src/tool-bash.ts +0 -77
  39. package/src/tool-edit.ts +0 -121
  40. package/src/tool-read.ts +0 -100
  41. package/src/tui-components.ts +0 -127
  42. package/src/tui-conversation.ts +0 -218
  43. package/src/tui-editor.ts +0 -29
  44. package/src/tui-overlay.ts +0 -604
  45. package/src/tui.ts +0 -314
  46. package/src/types.ts +0 -194
  47. package/src/update.ts +0 -171
@@ -0,0 +1,89 @@
1
+ import { spawn } from "node:child_process";
2
+ import { Type, type Static } from "@earendil-works/pi-ai";
3
+ import type { ToolContext, ToolResult } from "./common.ts";
4
+
5
+ export const BASH_PARAMS = Type.Object(
6
+ { command: Type.String({ description: "Command to run" }) },
7
+ { additionalProperties: false },
8
+ );
9
+ type BashArgs = Static<typeof BASH_PARAMS>;
10
+
11
+ const MAX_HEAD = 10_000;
12
+ const MAX_TAIL = 6_000;
13
+ const TRUNCATED = "\n\n... output truncated ...\n\n";
14
+
15
+ export function bash(args: BashArgs, ctx: ToolContext): Promise<ToolResult> {
16
+ return new Promise((resolve) => {
17
+ const child = spawn("bash", ["-c", args.command], {
18
+ cwd: process.cwd(),
19
+ detached: true,
20
+ stdio: ["ignore", "pipe", "pipe"],
21
+ });
22
+
23
+ let head = "";
24
+ let tail = "";
25
+ let omitted = 0;
26
+ const append = (chunk: Buffer) => {
27
+ let text = chunk.toString();
28
+ ctx.onOutput?.(text);
29
+ if (head.length < MAX_HEAD) {
30
+ const take = Math.min(MAX_HEAD - head.length, text.length);
31
+ head += text.slice(0, take);
32
+ text = text.slice(take);
33
+ }
34
+ if (text === "") return;
35
+ tail += text;
36
+ if (tail.length > MAX_TAIL) {
37
+ omitted += tail.length - MAX_TAIL;
38
+ tail = tail.slice(tail.length - MAX_TAIL);
39
+ }
40
+ };
41
+ child.stdout?.on("data", append);
42
+ child.stderr?.on("data", append);
43
+
44
+ let settled = false;
45
+ const finish = (code: number | null) => {
46
+ if (settled) return;
47
+ settled = true;
48
+ ctx.signal.removeEventListener("abort", onAbort);
49
+ clearTimeout(killTimer);
50
+ const exit = code ?? (ctx.signal.aborted ? "aborted" : "unknown");
51
+ const truncated = omitted > 0;
52
+ const body = truncated ? head + TRUNCATED + tail : head + tail;
53
+ const text = body + (body.endsWith("\n") || body === "" ? "" : "\n") + `exit code: ${exit}`;
54
+ resolve({
55
+ text,
56
+ isError: code !== 0,
57
+ details: truncated
58
+ ? { truncated: true, omittedChars: omitted, totalChars: head.length + tail.length + omitted }
59
+ : undefined,
60
+ });
61
+ };
62
+
63
+ let killTimer: NodeJS.Timeout | undefined;
64
+ const onAbort = () => {
65
+ if (!child.pid) return;
66
+ try {
67
+ process.kill(-child.pid, "SIGTERM");
68
+ } catch {
69
+ /* already gone */
70
+ }
71
+ killTimer = setTimeout(() => {
72
+ try {
73
+ process.kill(-child.pid!, "SIGKILL");
74
+ } catch {
75
+ /* already gone */
76
+ }
77
+ }, 300);
78
+ };
79
+
80
+ if (ctx.signal.aborted) onAbort();
81
+ else ctx.signal.addEventListener("abort", onAbort, { once: true });
82
+
83
+ child.on("error", (error) => {
84
+ append(Buffer.from(`${head || tail ? "\n" : ""}bash failed: ${error.message}\n`));
85
+ finish(null);
86
+ });
87
+ child.on("close", (code) => finish(code));
88
+ });
89
+ }
@@ -0,0 +1,32 @@
1
+ import type { ImageContent, Static, TSchema } from "@earendil-works/pi-ai";
2
+ import { Value } from "typebox/value";
3
+
4
+ export interface ToolContext {
5
+ signal: AbortSignal;
6
+ /** Whether the current model accepts image input. */
7
+ supportsImages: boolean;
8
+ onOutput?: (chunk: string) => void;
9
+ }
10
+
11
+ export interface ToolDetails {
12
+ truncated: boolean;
13
+ omittedChars: number;
14
+ totalChars: number;
15
+ }
16
+
17
+ export interface ToolResult {
18
+ text: string;
19
+ isError: boolean;
20
+ details?: ToolDetails;
21
+ /** Image blocks appended to the tool result, after `text`. */
22
+ images?: ImageContent[];
23
+ }
24
+
25
+ export function parseArgs<T extends TSchema>(schema: T, value: unknown): Static<T> {
26
+ try {
27
+ return Value.Parse(schema, value);
28
+ } catch {
29
+ const first = [...Value.Errors(schema, value)][0];
30
+ throw new Error(first ? `${first.instancePath || "/"} ${first.message}` : "invalid arguments");
31
+ }
32
+ }
@@ -0,0 +1,41 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { Type, type Static } from "@earendil-works/pi-ai";
3
+ import { createTwoFilesPatch } from "diff";
4
+ import type { ToolResult } from "./common.ts";
5
+
6
+ export const EDIT_PARAMS = Type.Object(
7
+ {
8
+ path: Type.String({ description: "File path" }),
9
+ oldText: Type.String({ description: "Exact text to replace; empty to create a file" }),
10
+ newText: Type.String({ description: "Replacement text" }),
11
+ },
12
+ { additionalProperties: false },
13
+ );
14
+ type EditArgs = Static<typeof EDIT_PARAMS>;
15
+
16
+ export function edit(args: EditArgs, signal: AbortSignal): ToolResult {
17
+ const { path, oldText, newText } = args;
18
+ const exists = existsSync(path);
19
+
20
+ if (oldText === "") {
21
+ if (exists) return { text: `edit failed: ${path} already exists`, isError: true };
22
+ if (signal.aborted) return { text: "edit cancelled", isError: true };
23
+ writeFileSync(path, newText);
24
+ return { text: `created ${path}\n${unified(path, "", newText)}`, isError: false };
25
+ }
26
+
27
+ if (!exists) return { text: `edit failed: ${path} does not exist`, isError: true };
28
+ const before = readFileSync(path, "utf8");
29
+ const count = before.split(oldText).length - 1;
30
+ if (count === 0) return { text: `edit failed: oldText not found in ${path}`, isError: true };
31
+ if (count > 1) return { text: `edit failed: oldText matches ${count} times in ${path}`, isError: true };
32
+
33
+ const after = before.replace(oldText, newText);
34
+ if (signal.aborted) return { text: "edit cancelled", isError: true };
35
+ writeFileSync(path, after);
36
+ return { text: `edited ${path}\n${unified(path, before, after)}`, isError: false };
37
+ }
38
+
39
+ function unified(path: string, before: string, after: string): string {
40
+ return createTwoFilesPatch(path, path, before, after, "", "", { context: 3 });
41
+ }
@@ -0,0 +1,47 @@
1
+ import type { Api, Model, Tool, ToolCall } from "@earendil-works/pi-ai";
2
+ import type { ToolName } from "../config.ts";
3
+ import { parseArgs, type ToolContext, type ToolResult } from "./common.ts";
4
+ import { BASH_PARAMS, bash } from "./bash.ts";
5
+ import { EDIT_PARAMS, edit } from "./edit.ts";
6
+ import { READ_PARAMS, read } from "./read.ts";
7
+
8
+ export type { ToolDetails, ToolResult } from "./common.ts";
9
+
10
+ /** Whether a model accepts image input. Drives `read`'s description and results. */
11
+ export function acceptsImages(model: Model<Api>): boolean {
12
+ return model.input.includes("image");
13
+ }
14
+
15
+ const READ_DESCRIPTION = "Read a file. Returns its text. Prefer bash for search, ranges, or binary files.";
16
+ const READ_IMAGE_DESCRIPTION =
17
+ "Read a file. Returns its text, or the image itself when the file is a png, jpg, or webp. " +
18
+ "Prefer bash for search, ranges, or binary files.";
19
+
20
+ export function toolSchemas(names: ToolName[], withImages: boolean): Tool[] {
21
+ const tools: Record<ToolName, Tool> = {
22
+ edit: {
23
+ name: "edit",
24
+ description:
25
+ "Edit a file by exact text replacement. oldText must occur exactly once. " +
26
+ "With empty oldText, create a new file (fails if it exists).",
27
+ parameters: EDIT_PARAMS,
28
+ },
29
+ read: {
30
+ name: "read",
31
+ description: withImages ? READ_IMAGE_DESCRIPTION : READ_DESCRIPTION,
32
+ parameters: READ_PARAMS,
33
+ },
34
+ bash: {
35
+ name: "bash",
36
+ description: "Run a bash command in the current working directory.",
37
+ parameters: BASH_PARAMS,
38
+ },
39
+ };
40
+ return names.map((name) => tools[name]);
41
+ }
42
+
43
+ export async function executeTool(call: ToolCall, ctx: ToolContext): Promise<ToolResult> {
44
+ if (call.name === "edit") return edit(parseArgs(EDIT_PARAMS, call.arguments), ctx.signal);
45
+ if (call.name === "read") return read(parseArgs(READ_PARAMS, call.arguments), ctx);
46
+ return bash(parseArgs(BASH_PARAMS, call.arguments), ctx);
47
+ }
@@ -0,0 +1,64 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { extname } from "node:path";
3
+ import { Type, type Static } from "@earendil-works/pi-ai";
4
+ import type { ToolContext, ToolResult } from "./common.ts";
5
+
6
+ export const READ_PARAMS = Type.Object(
7
+ { path: Type.String({ description: "File path" }) },
8
+ { additionalProperties: false },
9
+ );
10
+ type ReadArgs = Static<typeof READ_PARAMS>;
11
+
12
+ const IMAGE_MIME: Record<string, string> = {
13
+ png: "image/png",
14
+ jpg: "image/jpeg",
15
+ jpeg: "image/jpeg",
16
+ webp: "image/webp",
17
+ };
18
+
19
+ const MAX_TEXT_CHARS = 100_000;
20
+ /** Cap on the base64 payload sent to the provider, where the file inflates by 4/3. */
21
+ const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024;
22
+
23
+ export function read(args: ReadArgs, ctx: ToolContext): ToolResult {
24
+ const { path } = args;
25
+
26
+ let data: Buffer;
27
+ try {
28
+ data = readFileSync(path);
29
+ } catch (error) {
30
+ return { text: `read failed: ${(error as Error).message}`, isError: true };
31
+ }
32
+
33
+ const mime = IMAGE_MIME[extname(path).slice(1).toLowerCase()];
34
+ if (mime !== undefined) {
35
+ if (!ctx.supportsImages) {
36
+ return { text: `read failed: ${path} is an image and the current model does not accept image input`, isError: true };
37
+ }
38
+ const base64 = data.toString("base64");
39
+ if (base64.length > MAX_IMAGE_BASE64_BYTES) {
40
+ return {
41
+ text: `read failed: ${path} encodes to ${base64.length} bytes, over the ${MAX_IMAGE_BASE64_BYTES} byte image limit`,
42
+ isError: true,
43
+ };
44
+ }
45
+ return {
46
+ text: `read ${path} (${mime}, ${data.length} bytes)`,
47
+ isError: false,
48
+ images: [{ type: "image", data: base64, mimeType: mime }],
49
+ };
50
+ }
51
+
52
+ if (data.subarray(0, 8000).includes(0)) {
53
+ return { text: `read failed: ${path} is binary; use bash (file, xxd)`, isError: true };
54
+ }
55
+
56
+ const text = data.toString("utf8");
57
+ if (text.length > MAX_TEXT_CHARS) {
58
+ return {
59
+ text: text.slice(0, MAX_TEXT_CHARS) + `\n\n... truncated ... ${path} is ${text.length} characters\n`,
60
+ isError: false,
61
+ };
62
+ }
63
+ return { text, isError: false };
64
+ }
@@ -0,0 +1,63 @@
1
+ import { dim } from "./styles.ts";
2
+
3
+ export interface CommandContext {
4
+ /** Appends already-styled lines to scrollback. */
5
+ write(lines: string[]): void;
6
+ }
7
+
8
+ interface Command {
9
+ name: string; // no leading slash, lowercase
10
+ description: string; // one line, shown by /help
11
+ run(ctx: CommandContext, args: string): void;
12
+ }
13
+
14
+ /** The keybindings the TUI accepts, in the order `/help` prints them. */
15
+ const KEYBINDINGS: [string, string][] = [
16
+ ["Enter", "submit"],
17
+ ["Shift+Enter", "newline (Ctrl+J also works)"],
18
+ ["Esc", "pause the turn at the next step boundary"],
19
+ ["Ctrl+C", "cancel the turn"],
20
+ ["Ctrl+D", "exit on an empty draft"],
21
+ ];
22
+
23
+ /** One aligned `key description` block; the key column is dimmed. */
24
+ function keyed(rows: [string, string][], width: number): string[] {
25
+ return rows.map(([key, description]) => ` ${dim(key.padEnd(width))} ${description}`);
26
+ }
27
+
28
+ const help: Command = {
29
+ name: "help",
30
+ description: "list commands and keybindings",
31
+ run(ctx): void {
32
+ const names = COMMANDS.map((command): [string, string] => [`/${command.name}`, command.description]);
33
+ const width = Math.max(...names.map(([key]) => key.length), ...KEYBINDINGS.map(([key]) => key.length));
34
+ ctx.write(["commands", ...keyed(names, width), "", "keybindings", ...keyed(KEYBINDINGS, width)]);
35
+ },
36
+ };
37
+
38
+ const COMMANDS: Command[] = [help];
39
+
40
+ /** `/name args` for a known `name`, else null; unknown slash text stays a message. */
41
+ export function findCommand(text: string): { command: Command; args: string } | null {
42
+ const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(text);
43
+ if (match === null) return null;
44
+ const command = COMMANDS.find((candidate) => candidate.name === match[1]);
45
+ return command === undefined ? null : { command, args: match[2] ?? "" };
46
+ }
47
+
48
+ /** Tab completion for a half-typed command name; null leaves the draft alone. */
49
+ export function completeCommand(draft: string): string | null {
50
+ if (!draft.startsWith("/") || /\s/.test(draft)) return null;
51
+ const typed = draft.slice(1);
52
+ const matches = COMMANDS.filter((command) => command.name.startsWith(typed));
53
+ if (matches.length === 0) return null;
54
+ if (matches.length === 1) return `/${matches[0].name}`;
55
+ const shared = matches.map((command) => command.name).reduce(commonPrefix);
56
+ return shared === typed ? null : `/${shared}`;
57
+ }
58
+
59
+ function commonPrefix(a: string, b: string): string {
60
+ let i = 0;
61
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
62
+ return a.slice(0, i);
63
+ }
@@ -0,0 +1,291 @@
1
+ import { displayWidth, expandTabs, wrapLine, type Key } from "./term.ts";
2
+
3
+ const TAB = 4;
4
+ const DEFAULT_WIDTH = 80;
5
+
6
+ function codePoints(line: string): string[] {
7
+ return Array.from(line);
8
+ }
9
+
10
+ function isSpace(ch: string): boolean {
11
+ return /\s/u.test(ch);
12
+ }
13
+
14
+ /** First code point index of the word before `col`; shared by all word motion. */
15
+ function wordStart(cps: string[], col: number): number {
16
+ let i = col;
17
+ while (i > 0 && isSpace(cps[i - 1])) i--;
18
+ while (i > 0 && !isSpace(cps[i - 1])) i--;
19
+ return i;
20
+ }
21
+
22
+ /** Code point index one past the word at or after `col`. */
23
+ function wordEnd(cps: string[], col: number): number {
24
+ let i = col;
25
+ while (i < cps.length && isSpace(cps[i])) i++;
26
+ while (i < cps.length && !isSpace(cps[i])) i++;
27
+ return i;
28
+ }
29
+
30
+ interface EditorRender {
31
+ rows: string[];
32
+ cursorRow: number;
33
+ cursorCol: number;
34
+ }
35
+
36
+ export class Editor {
37
+ private lines: string[] = [""];
38
+ private row = 0;
39
+ private col = 0;
40
+ private scroll = 0;
41
+ private width = DEFAULT_WIDTH;
42
+
43
+ text(): string {
44
+ return this.lines.join("\n");
45
+ }
46
+
47
+ clear(): void {
48
+ this.lines = [""];
49
+ this.row = 0;
50
+ this.col = 0;
51
+ this.scroll = 0;
52
+ }
53
+
54
+ setText(text: string): void {
55
+ this.lines = text.split("\n");
56
+ this.row = this.lines.length - 1;
57
+ this.col = codePoints(this.lines[this.row]).length;
58
+ this.scroll = 0;
59
+ }
60
+
61
+ handle(key: Key): "submit" | "changed" | "none" {
62
+ switch (key.type) {
63
+ case "submit":
64
+ return "submit";
65
+ case "newline":
66
+ this.insert("\n");
67
+ return "changed";
68
+ case "text":
69
+ this.insert(key.text);
70
+ return "changed";
71
+ case "backspace":
72
+ this.backspace();
73
+ return "changed";
74
+ case "delete":
75
+ this.deleteForward();
76
+ return "changed";
77
+ case "wordBack":
78
+ this.wordBack();
79
+ return "changed";
80
+ case "left":
81
+ this.left();
82
+ return "changed";
83
+ case "right":
84
+ this.right();
85
+ return "changed";
86
+ case "wordLeft":
87
+ this.wordLeft();
88
+ return "changed";
89
+ case "wordRight":
90
+ this.wordRight();
91
+ return "changed";
92
+ case "up":
93
+ this.up();
94
+ return "changed";
95
+ case "down":
96
+ this.down();
97
+ return "changed";
98
+ case "home":
99
+ this.col = 0;
100
+ return "changed";
101
+ case "end":
102
+ this.col = codePoints(this.lines[this.row]).length;
103
+ return "changed";
104
+ case "docStart":
105
+ this.row = 0;
106
+ this.col = 0;
107
+ return "changed";
108
+ case "docEnd":
109
+ this.row = this.lines.length - 1;
110
+ this.col = codePoints(this.lines[this.row]).length;
111
+ return "changed";
112
+ default:
113
+ return "none";
114
+ }
115
+ }
116
+
117
+ /** Clipped to `maxRows`, scrolled only as far as the caret requires. */
118
+ render(width: number, maxRows: number): EditorRender {
119
+ this.width = Math.max(1, width);
120
+ const rows: string[] = [];
121
+ let cursorRow = 0;
122
+ let cursorCol = 0;
123
+ for (let line = 0; line < this.lines.length; line++) {
124
+ const chunks = wrapLine(expandTabs(this.lines[line], TAB), this.width);
125
+ if (line === this.row) {
126
+ const caret = this.caret();
127
+ cursorRow = rows.length + caret.row;
128
+ cursorCol = caret.col;
129
+ }
130
+ rows.push(...chunks);
131
+ }
132
+ const view = Math.max(1, maxRows);
133
+ if (cursorRow < this.scroll) this.scroll = cursorRow;
134
+ else if (cursorRow >= this.scroll + view) this.scroll = cursorRow - view + 1;
135
+ this.scroll = Math.min(Math.max(this.scroll, 0), Math.max(0, rows.length - view));
136
+ return {
137
+ rows: rows.slice(this.scroll, this.scroll + view),
138
+ cursorRow: cursorRow - this.scroll,
139
+ cursorCol,
140
+ };
141
+ }
142
+
143
+ /** The caret's display row within its logical line, and its cell column. */
144
+ private caret(): { row: number; col: number } {
145
+ const line = this.lines[this.row];
146
+ const chunks = wrapLine(expandTabs(line, TAB), this.width);
147
+ const cell = this.cells(line)[this.col];
148
+ const row = Math.floor(cell / this.width);
149
+ if (row >= chunks.length) return { row: chunks.length - 1, col: displayWidth(chunks[chunks.length - 1]) };
150
+ return { row, col: cell - row * this.width };
151
+ }
152
+
153
+ /** Display column of every code point boundary in `line`, tabs expanded. */
154
+ private cells(line: string): number[] {
155
+ const out = [0];
156
+ let col = 0;
157
+ for (const ch of codePoints(line)) {
158
+ col += ch === "\t" ? TAB - (col % TAB) : displayWidth(ch);
159
+ out.push(col);
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /** The caret column nearest `cell`, never past the end of the line. */
165
+ private colAtCell(line: string, cell: number): number {
166
+ const cells = this.cells(line);
167
+ let i = cells.length - 1;
168
+ while (i > 0 && cells[i] > cell) i--;
169
+ return i;
170
+ }
171
+
172
+ private insert(text: string): void {
173
+ const parts = text.split("\n");
174
+ const current = codePoints(this.lines[this.row]);
175
+ const before = current.slice(0, this.col).join("");
176
+ const after = current.slice(this.col).join("");
177
+ if (parts.length === 1) {
178
+ this.lines[this.row] = before + parts[0] + after;
179
+ this.col += codePoints(parts[0]).length;
180
+ return;
181
+ }
182
+ const head = before + parts[0];
183
+ const tail = parts[parts.length - 1] + after;
184
+ this.lines.splice(this.row, 1, head, ...parts.slice(1, -1), tail);
185
+ this.row += parts.length - 1;
186
+ this.col = codePoints(parts[parts.length - 1]).length;
187
+ }
188
+
189
+ private backspace(): void {
190
+ if (this.col > 0) {
191
+ const current = codePoints(this.lines[this.row]);
192
+ this.lines[this.row] = current.slice(0, this.col - 1).join("") + current.slice(this.col).join("");
193
+ this.col--;
194
+ return;
195
+ }
196
+ if (this.row > 0) {
197
+ const previous = codePoints(this.lines[this.row - 1]).length;
198
+ this.lines[this.row - 1] += this.lines[this.row];
199
+ this.lines.splice(this.row, 1);
200
+ this.row--;
201
+ this.col = previous;
202
+ }
203
+ }
204
+
205
+ private deleteForward(): void {
206
+ const current = codePoints(this.lines[this.row]);
207
+ if (this.col < current.length) {
208
+ this.lines[this.row] = current.slice(0, this.col).join("") + current.slice(this.col + 1).join("");
209
+ return;
210
+ }
211
+ if (this.row < this.lines.length - 1) {
212
+ this.lines[this.row] += this.lines[this.row + 1];
213
+ this.lines.splice(this.row + 1, 1);
214
+ }
215
+ }
216
+
217
+ private left(): void {
218
+ if (this.col > 0) this.col--;
219
+ else if (this.row > 0) {
220
+ this.row--;
221
+ this.col = codePoints(this.lines[this.row]).length;
222
+ }
223
+ }
224
+
225
+ private right(): void {
226
+ if (this.col < codePoints(this.lines[this.row]).length) this.col++;
227
+ else if (this.row < this.lines.length - 1) {
228
+ this.row++;
229
+ this.col = 0;
230
+ }
231
+ }
232
+
233
+ /** One display row, so the caret crosses the wrapped rows of a long line. */
234
+ private up(): void {
235
+ const line = this.lines[this.row];
236
+ const caret = this.caret();
237
+ if (caret.row > 0) {
238
+ this.col = this.colAtCell(line, this.cells(line)[this.col] - this.width);
239
+ return;
240
+ }
241
+ if (this.row === 0) return;
242
+ this.row--;
243
+ const previous = this.lines[this.row];
244
+ const lastRow = wrapLine(expandTabs(previous, TAB), this.width).length - 1;
245
+ this.col = this.colAtCell(previous, lastRow * this.width + caret.col);
246
+ }
247
+
248
+ private down(): void {
249
+ const line = this.lines[this.row];
250
+ const caret = this.caret();
251
+ if (caret.row < wrapLine(expandTabs(line, TAB), this.width).length - 1) {
252
+ this.col = this.colAtCell(line, this.cells(line)[this.col] + this.width);
253
+ return;
254
+ }
255
+ if (this.row === this.lines.length - 1) return;
256
+ this.row++;
257
+ this.col = this.colAtCell(this.lines[this.row], caret.col);
258
+ }
259
+
260
+ private wordLeft(): void {
261
+ const cps = codePoints(this.lines[this.row]);
262
+ const start = wordStart(cps, this.col);
263
+ if (start !== this.col) {
264
+ this.col = start;
265
+ return;
266
+ }
267
+ if (this.row === 0) return;
268
+ this.row--;
269
+ const previous = codePoints(this.lines[this.row]);
270
+ this.col = wordStart(previous, previous.length);
271
+ }
272
+
273
+ private wordRight(): void {
274
+ const cps = codePoints(this.lines[this.row]);
275
+ const end = wordEnd(cps, this.col);
276
+ if (end !== this.col) {
277
+ this.col = end;
278
+ return;
279
+ }
280
+ if (this.row === this.lines.length - 1) return;
281
+ this.row++;
282
+ this.col = wordEnd(codePoints(this.lines[this.row]), 0);
283
+ }
284
+
285
+ private wordBack(): void {
286
+ const current = codePoints(this.lines[this.row]);
287
+ const start = wordStart(current, this.col);
288
+ this.lines[this.row] = current.slice(0, start).join("") + current.slice(this.col).join("");
289
+ this.col = start;
290
+ }
291
+ }