mini-coder 0.7.4 → 0.8.1
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.
- package/AGENTS.md +114 -0
- package/README.md +53 -66
- package/bin/mini-coder.ts +2 -0
- package/demo.gif +0 -0
- package/package.json +17 -20
- package/src/agent.ts +193 -272
- package/src/auth.ts +84 -0
- package/src/cli.ts +99 -0
- package/src/config.ts +179 -0
- package/src/prompt.ts +54 -207
- package/src/session.ts +124 -69
- package/src/tools/bash.ts +86 -0
- package/src/tools/common.ts +32 -0
- package/src/tools/edit.ts +41 -0
- package/src/tools/index.ts +47 -0
- package/src/tools/read.ts +64 -0
- package/src/tui/commands.ts +199 -0
- package/src/tui/complete.ts +85 -0
- package/src/tui/editor.ts +320 -0
- package/src/tui/highlight.ts +189 -0
- package/src/tui/stream.ts +142 -0
- package/src/tui/styles.ts +20 -0
- package/src/tui/term.ts +436 -0
- package/src/tui/theme.ts +120 -0
- package/src/tui/tui.ts +758 -0
- package/src/tui/usage.ts +67 -0
- package/tsconfig.json +8 -8
- package/bin/mc.ts +0 -11
- package/bun.lock +0 -350
- package/nono-mini-coder.json +0 -42
- package/src/args.ts +0 -252
- package/src/error-handling.test.ts +0 -163
- package/src/git.ts +0 -23
- package/src/headless.ts +0 -66
- package/src/index.ts +0 -43
- package/src/models.ts +0 -191
- package/src/oauth.ts +0 -147
- package/src/shared.ts +0 -119
- package/src/themes.ts +0 -234
- package/src/tool-bash.ts +0 -77
- package/src/tool-edit.ts +0 -121
- package/src/tool-read.ts +0 -100
- package/src/tui-components.ts +0 -127
- package/src/tui-conversation.ts +0 -218
- package/src/tui-editor.ts +0 -29
- package/src/tui-overlay.ts +0 -604
- package/src/tui.ts +0 -314
- package/src/types.ts +0 -194
- package/src/update.ts +0 -171
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { highlightCode, highlightMarkdown } from "./highlight.ts";
|
|
2
|
+
import { dim } from "./styles.ts";
|
|
3
|
+
|
|
4
|
+
/** One logical display line: unwrapped text plus the style its rows inherit. */
|
|
5
|
+
export interface BodyLine {
|
|
6
|
+
text: string;
|
|
7
|
+
style?: (text: string) => string;
|
|
8
|
+
/** Row background, painted across the row's trailing cells by the renderer. */
|
|
9
|
+
bg?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A text stream projected onto display lines. `feed` and `flush` return the
|
|
14
|
+
* lines that became final; `pending` returns what is still in flight.
|
|
15
|
+
*/
|
|
16
|
+
export interface StreamRenderer {
|
|
17
|
+
feed(delta: string): BodyLine[];
|
|
18
|
+
pending(): BodyLine[];
|
|
19
|
+
flush(): BodyLine[];
|
|
20
|
+
reset(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A fence opening line, and the info string that names its language. */
|
|
24
|
+
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})\s*(\S*)/;
|
|
25
|
+
const FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})\s*$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Commits each complete line, colored. A fenced code block is held until its
|
|
29
|
+
* closing marker arrives, because neither the fence's language nor its body can
|
|
30
|
+
* be known before then. A run of prose is held until its block ends, because
|
|
31
|
+
* block context is what separates a setext heading from a paragraph and spans
|
|
32
|
+
* may cross lines; a blank line, a fence or the end of the turn ends the block.
|
|
33
|
+
* The live preview shows the held lines uncolored.
|
|
34
|
+
*/
|
|
35
|
+
export class MarkdownStream implements StreamRenderer {
|
|
36
|
+
private rest = "";
|
|
37
|
+
private fence: { marker: string; info: string; lines: string[] } | null = null;
|
|
38
|
+
private prose: string[] = [];
|
|
39
|
+
|
|
40
|
+
feed(delta: string): BodyLine[] {
|
|
41
|
+
this.rest += delta;
|
|
42
|
+
const lines = this.rest.split("\n");
|
|
43
|
+
this.rest = lines.pop()!;
|
|
44
|
+
return lines.flatMap((line) => this.commit(line));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
pending(): BodyLine[] {
|
|
48
|
+
const held = this.fence === null ? this.prose : this.fence.lines;
|
|
49
|
+
const rest = this.rest === "" ? [] : [this.rest];
|
|
50
|
+
return [...held, ...rest].map((text) => ({ text }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
flush(): BodyLine[] {
|
|
54
|
+
const fence = this.fence;
|
|
55
|
+
const lines = fence === null ? this.releaseProse(this.rest) : this.releaseFence(null);
|
|
56
|
+
if (fence !== null && this.rest !== "") lines.push({ text: highlightMarkdown(this.rest) });
|
|
57
|
+
this.reset();
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
reset(): void {
|
|
62
|
+
this.rest = "";
|
|
63
|
+
this.fence = null;
|
|
64
|
+
this.prose = [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private commit(line: string): BodyLine[] {
|
|
68
|
+
const fence = this.fence;
|
|
69
|
+
if (fence !== null) {
|
|
70
|
+
const close = FENCE_CLOSE.exec(line);
|
|
71
|
+
if (close === null || close[1][0] !== fence.marker[0] || close[1].length < fence.marker.length) {
|
|
72
|
+
fence.lines.push(line);
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
return this.releaseFence(line);
|
|
76
|
+
}
|
|
77
|
+
const open = FENCE_OPEN.exec(line);
|
|
78
|
+
if (open !== null) {
|
|
79
|
+
this.fence = { marker: open[1], info: open[2], lines: [line] };
|
|
80
|
+
return this.releaseProse("");
|
|
81
|
+
}
|
|
82
|
+
if (line.trim() === "") return [...this.releaseProse(""), { text: line }];
|
|
83
|
+
this.prose.push(line);
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Emits the held prose as one text, so the grammar sees whole blocks, then
|
|
89
|
+
* hands back the lines. `tail` is the still incomplete line closing the block.
|
|
90
|
+
*/
|
|
91
|
+
private releaseProse(tail: string): BodyLine[] {
|
|
92
|
+
const prose = this.prose;
|
|
93
|
+
this.prose = [];
|
|
94
|
+
if (tail !== "") prose.push(tail);
|
|
95
|
+
if (prose.length === 0) return [];
|
|
96
|
+
// A block is only a block once its last line ends, so the text needs a
|
|
97
|
+
// terminating newline. It is not a line itself: the closing reset `paint`
|
|
98
|
+
// leaves after it belongs to the last one.
|
|
99
|
+
const lines = highlightMarkdown(`${prose.join("\n")}\n`).split("\n");
|
|
100
|
+
const reset = lines.pop()!;
|
|
101
|
+
lines[lines.length - 1] += reset;
|
|
102
|
+
return lines.map((text) => ({ text }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Emits a held block: the fence lines colored, the body by its language. */
|
|
106
|
+
private releaseFence(closing: string | null): BodyLine[] {
|
|
107
|
+
const fence = this.fence!;
|
|
108
|
+
this.fence = null;
|
|
109
|
+
const lines: BodyLine[] = [{ text: highlightMarkdown(fence.lines.shift()!) }];
|
|
110
|
+
if (fence.lines.length > 0) {
|
|
111
|
+
for (const line of highlightCode(fence.info, fence.lines.join("\n")).split("\n")) {
|
|
112
|
+
lines.push({ text: line });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (closing !== null) lines.push({ text: highlightMarkdown(closing) });
|
|
116
|
+
return lines;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Keeps only the incomplete tail, dimmed; nothing is ever committed. */
|
|
121
|
+
export class TailStream implements StreamRenderer {
|
|
122
|
+
private rest = "";
|
|
123
|
+
|
|
124
|
+
feed(delta: string): BodyLine[] {
|
|
125
|
+
const text = this.rest + delta;
|
|
126
|
+
this.rest = text.slice(text.lastIndexOf("\n") + 1);
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
pending(): BodyLine[] {
|
|
131
|
+
return this.rest === "" ? [] : [{ text: this.rest, style: dim }];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
flush(): BodyLine[] {
|
|
135
|
+
this.reset();
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
reset(): void {
|
|
140
|
+
this.rest = "";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { NORMAL_FG, PALETTE, sgrFg } from "./theme.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inline foreground styling from the palette. The foreground is restored by
|
|
5
|
+
* re-asserting `Normal`'s, never by resetting: the row's background, set by the
|
|
6
|
+
* renderer, must survive, and no cell may fall back to the terminal's colours.
|
|
7
|
+
*/
|
|
8
|
+
const fg = (hex: string) => (text: string): string => `${sgrFg(hex)}${text}${sgrFg(NORMAL_FG)}`;
|
|
9
|
+
|
|
10
|
+
/** `Comment`, the colour diff context lines share. */
|
|
11
|
+
export const dim = fg(PALETTE.comment);
|
|
12
|
+
export const red = fg(PALETTE.red);
|
|
13
|
+
export const green = fg(PALETTE.green);
|
|
14
|
+
export const yellow = fg(PALETTE.yellow);
|
|
15
|
+
/** Diff hunk headers; the palette's `blue` sits closest to the old ANSI cyan. */
|
|
16
|
+
export const cyan = fg(PALETTE.blue);
|
|
17
|
+
/** The user's own words; also `Function`, which user rows never collide with. */
|
|
18
|
+
export const blue = fg(PALETTE.blue);
|
|
19
|
+
/** The tool accent: call-line heads. */
|
|
20
|
+
export const teal = fg(PALETTE.teal);
|
package/src/tui/term.ts
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
const COMBINING = /\p{M}/u;
|
|
2
|
+
|
|
3
|
+
function charWidth(code: number): number {
|
|
4
|
+
if (code < 32 || (code >= 0x7f && code < 0xa0)) return 0;
|
|
5
|
+
if (code === 0x200b || code === 0x200c || code === 0x200d || code === 0xfeff) return 0;
|
|
6
|
+
if (code >= 0xfe00 && code <= 0xfe0f) return 0;
|
|
7
|
+
if (code >= 0xe0100 && code <= 0xe01ef) return 0;
|
|
8
|
+
if (COMBINING.test(String.fromCodePoint(code))) return 0;
|
|
9
|
+
if (
|
|
10
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
11
|
+
(code >= 0x2e80 && code <= 0x303e) ||
|
|
12
|
+
(code >= 0x3041 && code <= 0x33ff) ||
|
|
13
|
+
(code >= 0x3400 && code <= 0x4dbf) ||
|
|
14
|
+
(code >= 0x4e00 && code <= 0x9fff) ||
|
|
15
|
+
(code >= 0xa000 && code <= 0xa4cf) ||
|
|
16
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
17
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
18
|
+
(code >= 0xfe10 && code <= 0xfe19) ||
|
|
19
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
20
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
21
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
22
|
+
(code >= 0x1f300 && code <= 0x1faff) ||
|
|
23
|
+
(code >= 0x20000 && code <= 0x3fffd)
|
|
24
|
+
) {
|
|
25
|
+
return 2;
|
|
26
|
+
}
|
|
27
|
+
return 1;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function displayWidth(text: string): number {
|
|
31
|
+
let width = 0;
|
|
32
|
+
for (const ch of text) width += charWidth(ch.codePointAt(0)!);
|
|
33
|
+
return width;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** An SGR sequence: zero cells wide, so it never affects a row break. */
|
|
37
|
+
const SGR = /^\x1b\[[0-9;]*m/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Everything a tool, a file or a model can emit that the terminal would act on
|
|
41
|
+
* but the TUI did not write itself: control bytes and every escape sequence but
|
|
42
|
+
* SGR. Dropped before the text is measured, so a row's width is its visible
|
|
43
|
+
* width and a result can only add rows — never move the cursor, erase the
|
|
44
|
+
* screen, or break the row model. Tabs survive to `expandTabs`, which owns them.
|
|
45
|
+
*/
|
|
46
|
+
const UNSAFE = /(\x1b\[[0-9;]*m)|\x1b(?:\[[0-9;?]*[ -/]*[@-~]|.)|[\x00-\x08\x0a-\x1f\x7f-\x9f]/g;
|
|
47
|
+
|
|
48
|
+
export function sanitize(text: string): string {
|
|
49
|
+
return text.replace(UNSAFE, (_match, sgr: string | undefined) => sgr ?? "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function expandTabs(text: string, size = 4): string {
|
|
53
|
+
if (!text.includes("\t")) return text;
|
|
54
|
+
let column = 0;
|
|
55
|
+
let out = "";
|
|
56
|
+
for (let i = 0; i < text.length; ) {
|
|
57
|
+
const escape = SGR.exec(text.slice(i));
|
|
58
|
+
if (escape !== null) {
|
|
59
|
+
out += escape[0];
|
|
60
|
+
i += escape[0].length;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const ch = String.fromCodePoint(text.codePointAt(i)!);
|
|
64
|
+
i += ch.length;
|
|
65
|
+
if (ch === "\t") {
|
|
66
|
+
const spaces = size - (column % size);
|
|
67
|
+
out += " ".repeat(spaces);
|
|
68
|
+
column += spaces;
|
|
69
|
+
} else {
|
|
70
|
+
out += ch;
|
|
71
|
+
column += charWidth(ch.codePointAt(0)!);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Splits one logical line into physical rows no wider than `width` cells. SGR
|
|
79
|
+
* sequences count as zero cells and stay attached to the text that follows.
|
|
80
|
+
*/
|
|
81
|
+
export function wrapLine(text: string, width: number): string[] {
|
|
82
|
+
if (width <= 0) return [""];
|
|
83
|
+
if (text === "") return [""];
|
|
84
|
+
const rows: string[] = [];
|
|
85
|
+
let current = "";
|
|
86
|
+
let used = 0;
|
|
87
|
+
for (let i = 0; i < text.length; ) {
|
|
88
|
+
if (text.charCodeAt(i) === 0x1b) {
|
|
89
|
+
const escape = SGR.exec(text.slice(i));
|
|
90
|
+
if (escape !== null) {
|
|
91
|
+
current += escape[0];
|
|
92
|
+
i += escape[0].length;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const ch = String.fromCodePoint(text.codePointAt(i)!);
|
|
97
|
+
const w = charWidth(ch.codePointAt(0)!);
|
|
98
|
+
if (used + w > width && current !== "") {
|
|
99
|
+
rows.push(current);
|
|
100
|
+
current = "";
|
|
101
|
+
used = 0;
|
|
102
|
+
}
|
|
103
|
+
current += ch;
|
|
104
|
+
used += w;
|
|
105
|
+
i += ch.length;
|
|
106
|
+
}
|
|
107
|
+
rows.push(current);
|
|
108
|
+
return rows;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type Key =
|
|
112
|
+
| { type: "text"; text: string }
|
|
113
|
+
| { type: "submit" }
|
|
114
|
+
| { type: "newline" }
|
|
115
|
+
| { type: "backspace" }
|
|
116
|
+
| { type: "delete" }
|
|
117
|
+
| { type: "wordBack" }
|
|
118
|
+
| { type: "left" }
|
|
119
|
+
| { type: "right" }
|
|
120
|
+
| { type: "wordLeft" }
|
|
121
|
+
| { type: "wordRight" }
|
|
122
|
+
| { type: "up" }
|
|
123
|
+
| { type: "down" }
|
|
124
|
+
| { type: "home" }
|
|
125
|
+
| { type: "end" }
|
|
126
|
+
| { type: "docStart" }
|
|
127
|
+
| { type: "docEnd" }
|
|
128
|
+
| { type: "tab" }
|
|
129
|
+
| { type: "escape" }
|
|
130
|
+
| { type: "interrupt" }
|
|
131
|
+
| { type: "eof" };
|
|
132
|
+
|
|
133
|
+
const PASTE_START = "\x1b[200~";
|
|
134
|
+
const PASTE_END = "\x1b[201~";
|
|
135
|
+
|
|
136
|
+
const SHIFT = 1;
|
|
137
|
+
const ALT = 2;
|
|
138
|
+
const CTRL = 4;
|
|
139
|
+
/** Only real modifiers are acted on; lock bits and the rest are ignored. */
|
|
140
|
+
const MODS = SHIFT | ALT | CTRL;
|
|
141
|
+
|
|
142
|
+
/** Codes for keys without a printable form, matching kitty's functional range. */
|
|
143
|
+
const CODE = {
|
|
144
|
+
enter: 13,
|
|
145
|
+
escape: 27,
|
|
146
|
+
tab: 9,
|
|
147
|
+
backspace: 127,
|
|
148
|
+
delete: 57349,
|
|
149
|
+
left: 57350,
|
|
150
|
+
right: 57351,
|
|
151
|
+
up: 57352,
|
|
152
|
+
down: 57353,
|
|
153
|
+
home: 57356,
|
|
154
|
+
end: 57357,
|
|
155
|
+
} as const;
|
|
156
|
+
|
|
157
|
+
/** The one table every input path resolves through: code and modifiers to key. */
|
|
158
|
+
const KEYS: Record<string, Key | undefined> = {
|
|
159
|
+
[`${CODE.enter}:0`]: { type: "submit" },
|
|
160
|
+
[`${CODE.enter}:${ALT}`]: { type: "newline" },
|
|
161
|
+
[`${CODE.enter}:${SHIFT}`]: { type: "newline" },
|
|
162
|
+
[`${CODE.escape}:0`]: { type: "escape" },
|
|
163
|
+
[`${CODE.tab}:0`]: { type: "tab" },
|
|
164
|
+
[`${CODE.backspace}:0`]: { type: "backspace" },
|
|
165
|
+
[`${CODE.backspace}:${CTRL}`]: { type: "wordBack" },
|
|
166
|
+
[`${CODE.delete}:0`]: { type: "delete" },
|
|
167
|
+
[`${CODE.left}:0`]: { type: "left" },
|
|
168
|
+
[`${CODE.left}:${CTRL}`]: { type: "wordLeft" },
|
|
169
|
+
[`${CODE.right}:0`]: { type: "right" },
|
|
170
|
+
[`${CODE.right}:${CTRL}`]: { type: "wordRight" },
|
|
171
|
+
[`${CODE.up}:0`]: { type: "up" },
|
|
172
|
+
[`${CODE.down}:0`]: { type: "down" },
|
|
173
|
+
[`${CODE.home}:0`]: { type: "home" },
|
|
174
|
+
[`${CODE.home}:${CTRL}`]: { type: "docStart" },
|
|
175
|
+
[`${CODE.end}:0`]: { type: "end" },
|
|
176
|
+
[`${CODE.end}:${CTRL}`]: { type: "docEnd" },
|
|
177
|
+
["97:4"]: { type: "home" }, // Ctrl+A
|
|
178
|
+
["98:2"]: { type: "wordBack" }, // Alt+B
|
|
179
|
+
["99:4"]: { type: "interrupt" }, // Ctrl+C
|
|
180
|
+
["100:4"]: { type: "eof" }, // Ctrl+D
|
|
181
|
+
["101:4"]: { type: "end" }, // Ctrl+E
|
|
182
|
+
["106:4"]: { type: "newline" }, // Ctrl+J
|
|
183
|
+
["119:4"]: { type: "wordBack" }, // Ctrl+W
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const CSI_FINALS: Record<string, number | undefined> = {
|
|
187
|
+
A: CODE.up,
|
|
188
|
+
B: CODE.down,
|
|
189
|
+
C: CODE.right,
|
|
190
|
+
D: CODE.left,
|
|
191
|
+
H: CODE.home,
|
|
192
|
+
F: CODE.end,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const CSI_TILDES: Record<number, number | undefined> = {
|
|
196
|
+
1: CODE.home,
|
|
197
|
+
3: CODE.delete,
|
|
198
|
+
4: CODE.end,
|
|
199
|
+
7: CODE.home,
|
|
200
|
+
8: CODE.end,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
interface Chord {
|
|
204
|
+
code: number;
|
|
205
|
+
mods: number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function lookup(code: number, mods: number): Key | null {
|
|
209
|
+
return KEYS[`${code}:${mods}`] ?? null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Control bytes are never text: they resolve through the table or vanish. */
|
|
213
|
+
function mapPoint(code: number): Key | null {
|
|
214
|
+
if (code === 0x7f) return lookup(CODE.backspace, 0);
|
|
215
|
+
if (code < 0x20) {
|
|
216
|
+
// A legacy control byte, as the chord the same key arrives as elsewhere.
|
|
217
|
+
if (code === 0x08) return lookup(CODE.backspace, 0);
|
|
218
|
+
if (code === 0x09) return lookup(CODE.tab, 0);
|
|
219
|
+
if (code === 0x0d) return lookup(CODE.enter, 0);
|
|
220
|
+
return lookup(code + 0x60, CTRL);
|
|
221
|
+
}
|
|
222
|
+
return { type: "text", text: String.fromCodePoint(code) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function modsOf(field: string | undefined): number {
|
|
226
|
+
const raw = Number.parseInt(field ?? "", 10);
|
|
227
|
+
return Number.isFinite(raw) && raw > 0 ? (raw - 1) & MODS : 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** `code[:shifted[:base]];mods[:event];text` */
|
|
231
|
+
function csiUChord(params: string): Chord | null {
|
|
232
|
+
const fields = params.split(";");
|
|
233
|
+
const code = Number.parseInt(fields[0].split(":")[0], 10);
|
|
234
|
+
if (!Number.isFinite(code)) return null; // probe replies and private forms
|
|
235
|
+
if (code >= 57344) return null; // functional keys never arrive as CSI-u
|
|
236
|
+
return { code, mods: modsOf(fields[1]?.split(":")[0]) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** `params final`: the legacy `CSI 1;5D` form and SS3's bare `A`. */
|
|
240
|
+
function legacyChord(final: string, params: string): Chord | null {
|
|
241
|
+
const fields = params.split(";");
|
|
242
|
+
const code = final === "~" ? CSI_TILDES[Number.parseInt(fields[0], 10)] : CSI_FINALS[final];
|
|
243
|
+
return code === undefined ? null : { code, mods: modsOf(fields[1]) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function normalizePaste(text: string): string {
|
|
247
|
+
return text.replace(/\r\n?/g, "\n");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
class KeyParser {
|
|
251
|
+
private buffer = "";
|
|
252
|
+
private pasting = false;
|
|
253
|
+
|
|
254
|
+
/** What the buffer may still complete: a lone ESC, a partial sequence, or nothing. */
|
|
255
|
+
pending(): "escape" | "sequence" | null {
|
|
256
|
+
if (this.pasting) return null;
|
|
257
|
+
if (this.buffer === "\x1b") return "escape";
|
|
258
|
+
return this.buffer.length > 1 ? "sequence" : null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
flushEscape(): Key[] {
|
|
262
|
+
if (this.buffer !== "\x1b") return [];
|
|
263
|
+
this.buffer = "";
|
|
264
|
+
return [{ type: "escape" }];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
flushSequence(): void {
|
|
268
|
+
this.buffer = "";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
feed(chunk: string): Key[] {
|
|
272
|
+
this.buffer += chunk;
|
|
273
|
+
const keys: Key[] = [];
|
|
274
|
+
for (;;) {
|
|
275
|
+
if (this.pasting) {
|
|
276
|
+
const end = this.buffer.indexOf(PASTE_END);
|
|
277
|
+
if (end >= 0) {
|
|
278
|
+
if (end > 0) keys.push({ type: "text", text: normalizePaste(this.buffer.slice(0, end)) });
|
|
279
|
+
this.buffer = this.buffer.slice(end + PASTE_END.length);
|
|
280
|
+
this.pasting = false;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (this.buffer.length > PASTE_END.length) {
|
|
284
|
+
const keep = PASTE_END.length - 1;
|
|
285
|
+
const emit = this.buffer.slice(0, this.buffer.length - keep);
|
|
286
|
+
this.buffer = this.buffer.slice(this.buffer.length - keep);
|
|
287
|
+
if (emit) keys.push({ type: "text", text: normalizePaste(emit) });
|
|
288
|
+
}
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (this.buffer === "") break;
|
|
293
|
+
if (this.buffer.startsWith(PASTE_START)) {
|
|
294
|
+
this.buffer = this.buffer.slice(PASTE_START.length);
|
|
295
|
+
this.pasting = true;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (this.buffer[0] === "\x1b") {
|
|
299
|
+
if (!this.parseEscape(keys)) break;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const code = this.buffer.codePointAt(0)!;
|
|
304
|
+
const ch = String.fromCodePoint(code);
|
|
305
|
+
this.buffer = this.buffer.slice(ch.length);
|
|
306
|
+
const key = mapPoint(code);
|
|
307
|
+
if (key) keys.push(key);
|
|
308
|
+
}
|
|
309
|
+
return keys;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private parseEscape(keys: Key[]): boolean {
|
|
313
|
+
const buffer = this.buffer;
|
|
314
|
+
if (buffer.length < 2) return false;
|
|
315
|
+
const next = buffer[1];
|
|
316
|
+
if (next === "[" || next === "O") return this.parseCsi(keys);
|
|
317
|
+
if (next === "]") return this.skipString(true);
|
|
318
|
+
if (next === "P" || next === "X" || next === "^" || next === "_") return this.skipString(false);
|
|
319
|
+
this.buffer = buffer.slice(2);
|
|
320
|
+
const key = lookup(next.codePointAt(0)!, ALT);
|
|
321
|
+
if (key) keys.push(key);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private parseCsi(keys: Key[]): boolean {
|
|
326
|
+
const buffer = this.buffer;
|
|
327
|
+
let i = 2;
|
|
328
|
+
while (i < buffer.length) {
|
|
329
|
+
const code = buffer.charCodeAt(i);
|
|
330
|
+
if (code >= 0x40 && code <= 0x7e) break;
|
|
331
|
+
i++;
|
|
332
|
+
}
|
|
333
|
+
if (i >= buffer.length) return false;
|
|
334
|
+
const final = buffer[i];
|
|
335
|
+
const params = buffer.slice(2, i);
|
|
336
|
+
this.buffer = buffer.slice(i + 1);
|
|
337
|
+
const chord = final === "u" ? csiUChord(params) : legacyChord(final, params);
|
|
338
|
+
if (chord !== null) {
|
|
339
|
+
const key = lookup(chord.code, chord.mods);
|
|
340
|
+
if (key) keys.push(key);
|
|
341
|
+
}
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** OSC ends at BEL or ST; DCS/APC/PM/SOS end at ST. Skipped whole. */
|
|
346
|
+
private skipString(bel: boolean): boolean {
|
|
347
|
+
const st = this.buffer.indexOf("\x1b\\", 2);
|
|
348
|
+
const bell = bel ? this.buffer.indexOf("\x07", 2) : -1;
|
|
349
|
+
let end = -1;
|
|
350
|
+
if (bell >= 0 && (st < 0 || bell < st)) end = bell + 1;
|
|
351
|
+
else if (st >= 0) end = st + 2;
|
|
352
|
+
if (end < 0) return false;
|
|
353
|
+
this.buffer = this.buffer.slice(end);
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
interface TerminalHandlers {
|
|
359
|
+
onKey: (key: Key) => void;
|
|
360
|
+
onResize: () => void;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export class Terminal {
|
|
364
|
+
private readonly handlers: TerminalHandlers;
|
|
365
|
+
private parser = new KeyParser();
|
|
366
|
+
private escapeTimer: NodeJS.Timeout | undefined;
|
|
367
|
+
private sequenceTimer: NodeJS.Timeout | undefined;
|
|
368
|
+
private started = false;
|
|
369
|
+
|
|
370
|
+
constructor(handlers: TerminalHandlers) {
|
|
371
|
+
this.handlers = handlers;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
get width(): number {
|
|
375
|
+
return process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 80;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
get height(): number {
|
|
379
|
+
return process.stdout.rows && process.stdout.rows > 0 ? process.stdout.rows : 24;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
start(): void {
|
|
383
|
+
if (this.started) return;
|
|
384
|
+
this.started = true;
|
|
385
|
+
process.stdin.setEncoding("utf8");
|
|
386
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
387
|
+
process.stdin.resume();
|
|
388
|
+
process.stdin.on("data", this.onData);
|
|
389
|
+
process.stdout.on("resize", this.onResize);
|
|
390
|
+
// Bracketed paste, then kitty flag 1 so `Shift+Enter` is distinguishable.
|
|
391
|
+
if (process.stdout.isTTY) process.stdout.write("\x1b[?2004h\x1b[>1u\x1b[?u");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
stop(): void {
|
|
395
|
+
if (!this.started) return;
|
|
396
|
+
this.started = false;
|
|
397
|
+
this.clearTimers();
|
|
398
|
+
process.stdin.off("data", this.onData);
|
|
399
|
+
process.stdout.off("resize", this.onResize);
|
|
400
|
+
if (process.stdout.isTTY) process.stdout.write("\x1b[<u\x1b[?2004l");
|
|
401
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
402
|
+
process.stdin.pause();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
write(text: string): void {
|
|
406
|
+
process.stdout.write(text);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private clearTimers(): void {
|
|
410
|
+
if (this.escapeTimer) clearTimeout(this.escapeTimer);
|
|
411
|
+
if (this.sequenceTimer) clearTimeout(this.sequenceTimer);
|
|
412
|
+
this.escapeTimer = undefined;
|
|
413
|
+
this.sequenceTimer = undefined;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private onData = (chunk: string): void => {
|
|
417
|
+
// Both timers reset on every chunk, so `Alt`+key stays snappy and an
|
|
418
|
+
// incomplete sequence never wedges the buffer.
|
|
419
|
+
this.clearTimers();
|
|
420
|
+
for (const key of this.parser.feed(chunk)) this.handlers.onKey(key);
|
|
421
|
+
const pending = this.parser.pending();
|
|
422
|
+
if (pending === "escape") {
|
|
423
|
+
this.escapeTimer = setTimeout(() => {
|
|
424
|
+
this.escapeTimer = undefined;
|
|
425
|
+
for (const key of this.parser.flushEscape()) this.handlers.onKey(key);
|
|
426
|
+
}, 30);
|
|
427
|
+
} else if (pending === "sequence") {
|
|
428
|
+
this.sequenceTimer = setTimeout(() => {
|
|
429
|
+
this.sequenceTimer = undefined;
|
|
430
|
+
this.parser.flushSequence();
|
|
431
|
+
}, 150);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
private onResize = (): void => this.handlers.onResize();
|
|
436
|
+
}
|