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,85 @@
|
|
|
1
|
+
import { readdirSync, statSync, type Dirent } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Deliberately bash-unfaithful: no `cdable_vars`, no `~user` expansion (`~`
|
|
7
|
+
* always means `homedir()`), no `$VAR`, backtick, or history expansion (word
|
|
8
|
+
* text is always literal), and no shell options — dotfile hiding, byte-exact
|
|
9
|
+
* matching, and dir-slash behavior below hold unconditionally.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A word must already carry path syntax to be completed: a leading `~`, a
|
|
14
|
+
* leading `./` or `../` (or bare `.` / `..`), a leading `/`, or any segment
|
|
15
|
+
* followed by a slash somewhere in the word. Bare words (`re`, `hello`) return
|
|
16
|
+
* null without a syscall, so mid-sentence prose never gets hijacked.
|
|
17
|
+
*/
|
|
18
|
+
const PATH_LIKE = /^(?:~|\/|\.{1,2}(?:\/|$)|\w+\/)/;
|
|
19
|
+
|
|
20
|
+
/** A directory, or a symlink that resolves to one (`statSync` follows). */
|
|
21
|
+
function isDir(path: string, entry: Dirent): boolean {
|
|
22
|
+
if (entry.isDirectory()) return true;
|
|
23
|
+
if (!entry.isSymbolicLink()) return false;
|
|
24
|
+
try {
|
|
25
|
+
return statSync(join(path, entry.name)).isDirectory();
|
|
26
|
+
} catch {
|
|
27
|
+
return false; // broken link completes file-shaped, like bash.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Completes `word` as a file path, bash-readline-style: the returned word is
|
|
33
|
+
* the whole feedback, a strict extension of what was typed. Unreadable
|
|
34
|
+
* locations, paths through files, and nothing longer to add all give null and
|
|
35
|
+
* the caller leaves the draft alone.
|
|
36
|
+
*/
|
|
37
|
+
export function completePath(word: string, cwd: string): string | null {
|
|
38
|
+
if (!PATH_LIKE.test(word)) return null;
|
|
39
|
+
|
|
40
|
+
// Word = dirPart + prefix, split at the last slash, slash kept with dirPart.
|
|
41
|
+
const slash = word.lastIndexOf("/");
|
|
42
|
+
const prefix = word.slice(slash + 1);
|
|
43
|
+
|
|
44
|
+
// Resolve dirPart to the directory to list. `~` means homedir() here and
|
|
45
|
+
// nowhere else: the returned word below reuses the user's shorthand verbatim
|
|
46
|
+
// (`~/Doc⇥` stays `~/Documents/`), so this resolve only locates the listing.
|
|
47
|
+
// Concatenate before resolving — `resolve(home, "/x")` would reset to root.
|
|
48
|
+
let dir: string;
|
|
49
|
+
if (word.startsWith("~")) dir = resolve(homedir() + word.slice(1, slash));
|
|
50
|
+
else if (slash === 0) dir = "/";
|
|
51
|
+
else dir = resolve(cwd, word.slice(0, slash));
|
|
52
|
+
|
|
53
|
+
let entries;
|
|
54
|
+
try {
|
|
55
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
56
|
+
} catch {
|
|
57
|
+
return null; // vanished, permission, or a file in the path: nothing better.
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Bash hides dotfiles unless the prefix itself starts with one (`~/.⇥`
|
|
61
|
+
// includes hidden entries). A directory completion grows a trailing `/` so
|
|
62
|
+
// the next Tab descends into it; a symlink to a directory counts as one —
|
|
63
|
+
// `withFileTypes` reports the link itself, so the target needs one stat.
|
|
64
|
+
const candidates = entries
|
|
65
|
+
.filter((entry) => entry.name.startsWith(prefix) && (prefix.startsWith(".") || !entry.name.startsWith(".")))
|
|
66
|
+
.map((entry) => (isDir(dir, entry) ? `${entry.name}/` : entry.name));
|
|
67
|
+
if (candidates.length === 0) return null;
|
|
68
|
+
if (candidates.length === 1) {
|
|
69
|
+
// Worth returning only when it adds something the user did not already
|
|
70
|
+
// type (`renameTag` typed against the single file match `renameTag` → null).
|
|
71
|
+
return candidates[0] === prefix ? null : word.slice(0, word.length - prefix.length) + candidates[0];
|
|
72
|
+
}
|
|
73
|
+
const shared = candidates.reduce(commonPrefix);
|
|
74
|
+
// Recombine over the untouched dirPart, so `~/Doc⇥` becomes `~/Documents/`
|
|
75
|
+
// and never `/home/xonecas/Documents/`; no exact-name short-circuit here.
|
|
76
|
+
return shared.length > prefix.length ? word.slice(0, word.length - prefix.length) + shared : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Case-sensitive longest common prefix; commands.ts uses it for command
|
|
80
|
+
* completion, completePath for path candidates. */
|
|
81
|
+
export function commonPrefix(a: string, b: string): string {
|
|
82
|
+
let i = 0;
|
|
83
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
84
|
+
return a.slice(0, i);
|
|
85
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
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
|
+
private masked = false;
|
|
43
|
+
|
|
44
|
+
text(): string {
|
|
45
|
+
return this.lines.join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Renders each character as `*` without changing the underlying text. */
|
|
49
|
+
setMasked(masked: boolean): void {
|
|
50
|
+
this.masked = masked;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private display(line: string): string {
|
|
54
|
+
return this.masked ? "*".repeat(codePoints(line).length) : line;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
clear(): void {
|
|
58
|
+
this.lines = [""];
|
|
59
|
+
this.row = 0;
|
|
60
|
+
this.col = 0;
|
|
61
|
+
this.scroll = 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setText(text: string): void {
|
|
65
|
+
this.lines = text.split("\n");
|
|
66
|
+
this.row = this.lines.length - 1;
|
|
67
|
+
this.col = codePoints(this.lines[this.row]).length;
|
|
68
|
+
this.scroll = 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
handle(key: Key): "submit" | "changed" | "none" {
|
|
72
|
+
switch (key.type) {
|
|
73
|
+
case "submit":
|
|
74
|
+
return "submit";
|
|
75
|
+
case "newline":
|
|
76
|
+
this.insert("\n");
|
|
77
|
+
return "changed";
|
|
78
|
+
case "text":
|
|
79
|
+
this.insert(key.text);
|
|
80
|
+
return "changed";
|
|
81
|
+
case "backspace":
|
|
82
|
+
this.backspace();
|
|
83
|
+
return "changed";
|
|
84
|
+
case "delete":
|
|
85
|
+
this.deleteForward();
|
|
86
|
+
return "changed";
|
|
87
|
+
case "wordBack":
|
|
88
|
+
this.wordBack();
|
|
89
|
+
return "changed";
|
|
90
|
+
case "left":
|
|
91
|
+
this.left();
|
|
92
|
+
return "changed";
|
|
93
|
+
case "right":
|
|
94
|
+
this.right();
|
|
95
|
+
return "changed";
|
|
96
|
+
case "wordLeft":
|
|
97
|
+
this.wordLeft();
|
|
98
|
+
return "changed";
|
|
99
|
+
case "wordRight":
|
|
100
|
+
this.wordRight();
|
|
101
|
+
return "changed";
|
|
102
|
+
case "up":
|
|
103
|
+
this.up();
|
|
104
|
+
return "changed";
|
|
105
|
+
case "down":
|
|
106
|
+
this.down();
|
|
107
|
+
return "changed";
|
|
108
|
+
case "home":
|
|
109
|
+
this.col = 0;
|
|
110
|
+
return "changed";
|
|
111
|
+
case "end":
|
|
112
|
+
this.col = codePoints(this.lines[this.row]).length;
|
|
113
|
+
return "changed";
|
|
114
|
+
case "docStart":
|
|
115
|
+
this.row = 0;
|
|
116
|
+
this.col = 0;
|
|
117
|
+
return "changed";
|
|
118
|
+
case "docEnd":
|
|
119
|
+
this.row = this.lines.length - 1;
|
|
120
|
+
this.col = codePoints(this.lines[this.row]).length;
|
|
121
|
+
return "changed";
|
|
122
|
+
default:
|
|
123
|
+
return "none";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Clipped to `maxRows`, scrolled only as far as the caret requires. */
|
|
128
|
+
render(width: number, maxRows: number): EditorRender {
|
|
129
|
+
this.width = Math.max(1, width);
|
|
130
|
+
const rows: string[] = [];
|
|
131
|
+
let cursorRow = 0;
|
|
132
|
+
let cursorCol = 0;
|
|
133
|
+
for (let line = 0; line < this.lines.length; line++) {
|
|
134
|
+
const chunks = wrapLine(expandTabs(this.display(this.lines[line]), TAB), this.width);
|
|
135
|
+
if (line === this.row) {
|
|
136
|
+
const caret = this.caret();
|
|
137
|
+
cursorRow = rows.length + caret.row;
|
|
138
|
+
cursorCol = caret.col;
|
|
139
|
+
}
|
|
140
|
+
rows.push(...chunks);
|
|
141
|
+
}
|
|
142
|
+
const view = Math.max(1, maxRows);
|
|
143
|
+
if (cursorRow < this.scroll) this.scroll = cursorRow;
|
|
144
|
+
else if (cursorRow >= this.scroll + view) this.scroll = cursorRow - view + 1;
|
|
145
|
+
this.scroll = Math.min(Math.max(this.scroll, 0), Math.max(0, rows.length - view));
|
|
146
|
+
return {
|
|
147
|
+
rows: rows.slice(this.scroll, this.scroll + view),
|
|
148
|
+
cursorRow: cursorRow - this.scroll,
|
|
149
|
+
cursorCol,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The caret's display row within its logical line, and its cell column. */
|
|
154
|
+
private caret(): { row: number; col: number } {
|
|
155
|
+
const line = this.display(this.lines[this.row]);
|
|
156
|
+
const chunks = wrapLine(expandTabs(line, TAB), this.width);
|
|
157
|
+
const cell = this.cells(line)[this.col];
|
|
158
|
+
const row = Math.floor(cell / this.width);
|
|
159
|
+
if (row >= chunks.length) return { row: chunks.length - 1, col: displayWidth(chunks[chunks.length - 1]) };
|
|
160
|
+
return { row, col: cell - row * this.width };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Display column of every code point boundary in `line`, tabs expanded. */
|
|
164
|
+
private cells(line: string): number[] {
|
|
165
|
+
const out = [0];
|
|
166
|
+
let col = 0;
|
|
167
|
+
for (const ch of codePoints(line)) {
|
|
168
|
+
col += ch === "\t" ? TAB - (col % TAB) : displayWidth(ch);
|
|
169
|
+
out.push(col);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The caret column nearest `cell`, never past the end of the line. */
|
|
175
|
+
private colAtCell(line: string, cell: number): number {
|
|
176
|
+
const cells = this.cells(line);
|
|
177
|
+
let i = cells.length - 1;
|
|
178
|
+
while (i > 0 && cells[i] > cell) i--;
|
|
179
|
+
return i;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private insert(text: string): void {
|
|
183
|
+
const parts = text.split("\n");
|
|
184
|
+
const current = codePoints(this.lines[this.row]);
|
|
185
|
+
const before = current.slice(0, this.col).join("");
|
|
186
|
+
const after = current.slice(this.col).join("");
|
|
187
|
+
if (parts.length === 1) {
|
|
188
|
+
this.lines[this.row] = before + parts[0] + after;
|
|
189
|
+
this.col += codePoints(parts[0]).length;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const head = before + parts[0];
|
|
193
|
+
const tail = parts[parts.length - 1] + after;
|
|
194
|
+
this.lines.splice(this.row, 1, head, ...parts.slice(1, -1), tail);
|
|
195
|
+
this.row += parts.length - 1;
|
|
196
|
+
this.col = codePoints(parts[parts.length - 1]).length;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private backspace(): void {
|
|
200
|
+
if (this.col > 0) {
|
|
201
|
+
const current = codePoints(this.lines[this.row]);
|
|
202
|
+
this.lines[this.row] = current.slice(0, this.col - 1).join("") + current.slice(this.col).join("");
|
|
203
|
+
this.col--;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (this.row > 0) {
|
|
207
|
+
const previous = codePoints(this.lines[this.row - 1]).length;
|
|
208
|
+
this.lines[this.row - 1] += this.lines[this.row];
|
|
209
|
+
this.lines.splice(this.row, 1);
|
|
210
|
+
this.row--;
|
|
211
|
+
this.col = previous;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private deleteForward(): void {
|
|
216
|
+
const current = codePoints(this.lines[this.row]);
|
|
217
|
+
if (this.col < current.length) {
|
|
218
|
+
this.lines[this.row] = current.slice(0, this.col).join("") + current.slice(this.col + 1).join("");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (this.row < this.lines.length - 1) {
|
|
222
|
+
this.lines[this.row] += this.lines[this.row + 1];
|
|
223
|
+
this.lines.splice(this.row + 1, 1);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private left(): void {
|
|
228
|
+
if (this.col > 0) this.col--;
|
|
229
|
+
else if (this.row > 0) {
|
|
230
|
+
this.row--;
|
|
231
|
+
this.col = codePoints(this.lines[this.row]).length;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private right(): void {
|
|
236
|
+
if (this.col < codePoints(this.lines[this.row]).length) this.col++;
|
|
237
|
+
else if (this.row < this.lines.length - 1) {
|
|
238
|
+
this.row++;
|
|
239
|
+
this.col = 0;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** One display row, so the caret crosses the wrapped rows of a long line. */
|
|
244
|
+
private up(): void {
|
|
245
|
+
const line = this.lines[this.row];
|
|
246
|
+
const caret = this.caret();
|
|
247
|
+
if (caret.row > 0) {
|
|
248
|
+
this.col = this.colAtCell(line, this.cells(line)[this.col] - this.width);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (this.row === 0) return;
|
|
252
|
+
this.row--;
|
|
253
|
+
const previous = this.lines[this.row];
|
|
254
|
+
const lastRow = wrapLine(expandTabs(previous, TAB), this.width).length - 1;
|
|
255
|
+
this.col = this.colAtCell(previous, lastRow * this.width + caret.col);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private down(): void {
|
|
259
|
+
const line = this.lines[this.row];
|
|
260
|
+
const caret = this.caret();
|
|
261
|
+
if (caret.row < wrapLine(expandTabs(line, TAB), this.width).length - 1) {
|
|
262
|
+
this.col = this.colAtCell(line, this.cells(line)[this.col] + this.width);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (this.row === this.lines.length - 1) return;
|
|
266
|
+
this.row++;
|
|
267
|
+
this.col = this.colAtCell(this.lines[this.row], caret.col);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private wordLeft(): void {
|
|
271
|
+
const cps = codePoints(this.lines[this.row]);
|
|
272
|
+
const start = wordStart(cps, this.col);
|
|
273
|
+
if (start !== this.col) {
|
|
274
|
+
this.col = start;
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (this.row === 0) return;
|
|
278
|
+
this.row--;
|
|
279
|
+
const previous = codePoints(this.lines[this.row]);
|
|
280
|
+
this.col = wordStart(previous, previous.length);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private wordRight(): void {
|
|
284
|
+
const cps = codePoints(this.lines[this.row]);
|
|
285
|
+
const end = wordEnd(cps, this.col);
|
|
286
|
+
if (end !== this.col) {
|
|
287
|
+
this.col = end;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (this.row === this.lines.length - 1) return;
|
|
291
|
+
this.row++;
|
|
292
|
+
this.col = wordEnd(codePoints(this.lines[this.row]), 0);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private wordBack(): void {
|
|
296
|
+
const current = codePoints(this.lines[this.row]);
|
|
297
|
+
const start = wordStart(current, this.col);
|
|
298
|
+
this.lines[this.row] = current.slice(0, start).join("") + current.slice(this.col).join("");
|
|
299
|
+
this.col = start;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Applies `step` to the word ending at the caret, replacing it; false when
|
|
304
|
+
* there is no word before the caret. Whitespace is `isSpace` (Unicode `\s`),
|
|
305
|
+
* so a path word may carry `/`, `~`, `.`, `$` — no path-specific separator
|
|
306
|
+
* set. No completion-undo: bash's TAB-_ restore is out; the user backspaces
|
|
307
|
+
* or re-types instead, with Esc and the editor's own keys as recovery.
|
|
308
|
+
*/
|
|
309
|
+
completeWord(step: (word: string) => string | null): boolean {
|
|
310
|
+
const current = codePoints(this.lines[this.row]);
|
|
311
|
+
const start = wordStart(current, this.col);
|
|
312
|
+
if (start === this.col || isSpace(current[this.col - 1])) return false;
|
|
313
|
+
const word = current.slice(start, this.col).join("");
|
|
314
|
+
const completed = step(word);
|
|
315
|
+
if (completed === null) return false;
|
|
316
|
+
this.lines[this.row] = current.slice(0, start).join("") + completed + current.slice(this.col).join("");
|
|
317
|
+
this.col = start + codePoints(completed).length;
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import Parser from "tree-sitter";
|
|
5
|
+
import JavaScript from "tree-sitter-javascript";
|
|
6
|
+
import TypeScript from "tree-sitter-typescript";
|
|
7
|
+
import Markdown from "@tree-sitter-grammars/tree-sitter-markdown";
|
|
8
|
+
import { HEADINGS, INLINE_CODE, NORMAL_BG, NORMAL_FG, STYLES, channels, sgrPlain, type Style } from "./theme.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Syntax highlighting for committed scrollback, once, over the whole block.
|
|
12
|
+
* Grammar queries ship with the grammar packages and are consumed as data, so
|
|
13
|
+
* a capture name is the only thing that reaches the palette. The colours come
|
|
14
|
+
* from the TokyoNight `night` palette in `theme.ts`, mapped the way
|
|
15
|
+
* `folke/tokyonight.nvim` maps capture names to highlight groups. Truecolor only.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function styleFor(capture: string): Style | undefined {
|
|
19
|
+
for (let name = capture; ; name = name.slice(0, name.lastIndexOf("."))) {
|
|
20
|
+
const style = STYLES[name];
|
|
21
|
+
if (style !== undefined) return style;
|
|
22
|
+
if (!name.includes(".")) return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
|
|
28
|
+
/** A grammar package keeps its queries next to its sources. */
|
|
29
|
+
function querySource(pkg: string, ...path: string[]): string {
|
|
30
|
+
return readFileSync(join(dirname(require.resolve(`${pkg}/package.json`)), ...path), "utf8");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Syntax {
|
|
34
|
+
parser: Parser;
|
|
35
|
+
query: Parser.Query;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function syntax(language: Parser.Language, ...sources: string[]): Syntax {
|
|
39
|
+
const parser = new Parser();
|
|
40
|
+
parser.setLanguage(language);
|
|
41
|
+
return { parser, query: new Parser.Query(language, sources.join("\n")) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const JS_QUERIES = querySource("tree-sitter-javascript", "queries", "highlights.scm");
|
|
45
|
+
const TS_QUERIES = querySource("tree-sitter-typescript", "queries", "highlights.scm");
|
|
46
|
+
|
|
47
|
+
const JAVASCRIPT = syntax(JavaScript, JS_QUERIES);
|
|
48
|
+
/** TypeScript's own query only covers what it adds to JavaScript. */
|
|
49
|
+
const TYPESCRIPT = syntax(TypeScript.typescript, JS_QUERIES, TS_QUERIES);
|
|
50
|
+
const TSX = syntax(TypeScript.tsx, JS_QUERIES, TS_QUERIES);
|
|
51
|
+
|
|
52
|
+
/** Fence info string to grammar, for the languages we carry. */
|
|
53
|
+
const CODE: Record<string, Syntax | undefined> = {
|
|
54
|
+
js: JAVASCRIPT,
|
|
55
|
+
javascript: JAVASCRIPT,
|
|
56
|
+
jsx: JAVASCRIPT,
|
|
57
|
+
ts: TYPESCRIPT,
|
|
58
|
+
typescript: TYPESCRIPT,
|
|
59
|
+
tsx: TSX,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const MARKDOWN = syntax(
|
|
63
|
+
Markdown,
|
|
64
|
+
querySource("@tree-sitter-grammars/tree-sitter-markdown", "tree-sitter-markdown", "queries", "highlights.scm"),
|
|
65
|
+
);
|
|
66
|
+
const MARKDOWN_INLINE = syntax(
|
|
67
|
+
Markdown.inline,
|
|
68
|
+
querySource(
|
|
69
|
+
"@tree-sitter-grammars/tree-sitter-markdown",
|
|
70
|
+
"tree-sitter-markdown-inline",
|
|
71
|
+
"queries",
|
|
72
|
+
"highlights.scm",
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
/** One styled range of a line, in byte offsets into that line. */
|
|
77
|
+
interface Span {
|
|
78
|
+
start: number;
|
|
79
|
+
end: number;
|
|
80
|
+
style: Style;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function spansOf(syntax: Syntax, node: Parser.SyntaxNode, offset = 0): Span[] {
|
|
84
|
+
const spans: Span[] = [];
|
|
85
|
+
for (const capture of syntax.query.captures(node)) {
|
|
86
|
+
const style = styleFor(capture.name);
|
|
87
|
+
if (style === undefined) continue;
|
|
88
|
+
spans.push({ start: offset + capture.node.startIndex, end: offset + capture.node.endIndex, style });
|
|
89
|
+
}
|
|
90
|
+
return spans;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parse(syntax: Syntax, text: string): Parser.SyntaxNode {
|
|
94
|
+
return syntax.parser.parse(text).rootNode;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A style's own attributes, with the defaults spelled out: a span that names no
|
|
99
|
+
* color must not inherit the color of the span or token before it, and the
|
|
100
|
+
* fallback is the palette's `Normal`, never the terminal's own pair.
|
|
101
|
+
*/
|
|
102
|
+
function sgr(style: Style): string {
|
|
103
|
+
let out = "\x1b[";
|
|
104
|
+
if (style.bold) out += "1;";
|
|
105
|
+
if (style.italic) out += "3;";
|
|
106
|
+
if (style.underline) out += "4;";
|
|
107
|
+
out += `38;2;${channels(style.fg ?? NORMAL_FG)};48;2;${channels(style.bg ?? NORMAL_BG)}m`;
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Wraps every span in its color. Spans nest, so the innermost one wins; text
|
|
113
|
+
* outside any span falls back to `Normal`. The trailing close restores the same
|
|
114
|
+
* pair, so a span may cross a line break and the last line still ends plain —
|
|
115
|
+
* and no cell is ever left to the terminal. Because each row is painted on its
|
|
116
|
+
* own, a span that outlives a line break re-asserts itself on the next line.
|
|
117
|
+
*/
|
|
118
|
+
function paint(text: string, spans: Span[]): string {
|
|
119
|
+
const events: { at: number; open: boolean; span: Span }[] = [];
|
|
120
|
+
for (const span of spans) {
|
|
121
|
+
if (span.end <= span.start) continue;
|
|
122
|
+
events.push({ at: span.start, open: true, span }, { at: span.end, open: false, span });
|
|
123
|
+
}
|
|
124
|
+
events.sort((a, b) => a.at - b.at || Number(a.open) - Number(b.open));
|
|
125
|
+
|
|
126
|
+
const active: Span[] = [];
|
|
127
|
+
let out = "";
|
|
128
|
+
let plain = true;
|
|
129
|
+
let at = 0;
|
|
130
|
+
const emit = (end: number): void => {
|
|
131
|
+
if (end <= at) return;
|
|
132
|
+
const style = active.length === 0 ? undefined : active[active.length - 1].style;
|
|
133
|
+
if (style === undefined) {
|
|
134
|
+
if (!plain) out += sgrPlain();
|
|
135
|
+
plain = true;
|
|
136
|
+
} else {
|
|
137
|
+
out += sgr(style);
|
|
138
|
+
plain = false;
|
|
139
|
+
}
|
|
140
|
+
const chunk = text.slice(at, end);
|
|
141
|
+
out += style === undefined ? chunk : chunk.replace(/\n/g, `\n${sgr(style)}`);
|
|
142
|
+
at = end;
|
|
143
|
+
};
|
|
144
|
+
for (const event of events) {
|
|
145
|
+
emit(event.at);
|
|
146
|
+
if (event.open) active.push(event.span);
|
|
147
|
+
else active.splice(active.indexOf(event.span), 1);
|
|
148
|
+
}
|
|
149
|
+
emit(text.length);
|
|
150
|
+
return plain ? out : `${out}${sgrPlain()}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Colors one committed block of Markdown. Inline markup is a separate grammar,
|
|
155
|
+
* run over the ranges the block grammar hands over, as its injections do.
|
|
156
|
+
*/
|
|
157
|
+
export function highlightMarkdown(text: string): string {
|
|
158
|
+
const root = parse(MARKDOWN, text);
|
|
159
|
+
let spans = spansOf(MARKDOWN, root);
|
|
160
|
+
// Two things the grammar's queries predate: a whole heading is colored by its
|
|
161
|
+
// level, and inline code carries a background. Both replace what covers them.
|
|
162
|
+
for (const inline of root.descendantsOfType("inline")) {
|
|
163
|
+
const inlineRoot = parse(MARKDOWN_INLINE, inline.text);
|
|
164
|
+
spans.push(...spansOf(MARKDOWN_INLINE, inlineRoot, inline.startIndex));
|
|
165
|
+
for (const code of inlineRoot.descendantsOfType("code_span")) {
|
|
166
|
+
spans = recolor(spans, code, INLINE_CODE, inline.startIndex);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const heading of root.descendantsOfType(["atx_heading", "setext_heading"])) {
|
|
170
|
+
const marker = heading.namedChildren.find((child) => /^(atx|setext)_h/.test(child.type))?.type;
|
|
171
|
+
const level = marker === undefined ? 1 : Number(marker.replace(/\D/g, ""));
|
|
172
|
+
spans = recolor(spans, heading, HEADINGS[Math.min(level, HEADINGS.length) - 1]);
|
|
173
|
+
}
|
|
174
|
+
return paint(text, spans);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Replaces every span inside `node` with one span covering the whole node. */
|
|
178
|
+
function recolor(spans: Span[], node: Parser.SyntaxNode, style: Style, offset = 0): Span[] {
|
|
179
|
+
const start = offset + node.startIndex;
|
|
180
|
+
const end = offset + node.endIndex;
|
|
181
|
+
const kept = spans.filter((span) => span.start < start || span.end > end);
|
|
182
|
+
return [...kept, { start, end, style }];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Colors a fenced code block's body, by its fence info string. */
|
|
186
|
+
export function highlightCode(info: string, text: string): string {
|
|
187
|
+
const grammar = CODE[info.trim().toLowerCase()];
|
|
188
|
+
return grammar === undefined ? text : paint(text, spansOf(grammar, parse(grammar, text)));
|
|
189
|
+
}
|