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.
Files changed (49) 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 +193 -272
  7. package/src/auth.ts +84 -0
  8. package/src/cli.ts +99 -0
  9. package/src/config.ts +179 -0
  10. package/src/prompt.ts +54 -207
  11. package/src/session.ts +124 -69
  12. package/src/tools/bash.ts +86 -0
  13. package/src/tools/common.ts +32 -0
  14. package/src/tools/edit.ts +41 -0
  15. package/src/tools/index.ts +47 -0
  16. package/src/tools/read.ts +64 -0
  17. package/src/tui/commands.ts +199 -0
  18. package/src/tui/complete.ts +85 -0
  19. package/src/tui/editor.ts +320 -0
  20. package/src/tui/highlight.ts +189 -0
  21. package/src/tui/stream.ts +142 -0
  22. package/src/tui/styles.ts +20 -0
  23. package/src/tui/term.ts +436 -0
  24. package/src/tui/theme.ts +120 -0
  25. package/src/tui/tui.ts +758 -0
  26. package/src/tui/usage.ts +67 -0
  27. package/tsconfig.json +8 -8
  28. package/bin/mc.ts +0 -11
  29. package/bun.lock +0 -350
  30. package/nono-mini-coder.json +0 -42
  31. package/src/args.ts +0 -252
  32. package/src/error-handling.test.ts +0 -163
  33. package/src/git.ts +0 -23
  34. package/src/headless.ts +0 -66
  35. package/src/index.ts +0 -43
  36. package/src/models.ts +0 -191
  37. package/src/oauth.ts +0 -147
  38. package/src/shared.ts +0 -119
  39. package/src/themes.ts +0 -234
  40. package/src/tool-bash.ts +0 -77
  41. package/src/tool-edit.ts +0 -121
  42. package/src/tool-read.ts +0 -100
  43. package/src/tui-components.ts +0 -127
  44. package/src/tui-conversation.ts +0 -218
  45. package/src/tui-editor.ts +0 -29
  46. package/src/tui-overlay.ts +0 -604
  47. package/src/tui.ts +0 -314
  48. package/src/types.ts +0 -194
  49. 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
+ }