oakode 0.0.0-stage → 0.5.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 (53) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +9 -0
  3. package/README.md +243 -2
  4. package/SECURITY.md +13 -0
  5. package/dist/account.js +34 -0
  6. package/dist/agent.js +570 -0
  7. package/dist/agents/approval.js +116 -0
  8. package/dist/agents/budget.js +293 -0
  9. package/dist/agents/child.js +102 -0
  10. package/dist/agents/host.js +289 -0
  11. package/dist/agents/orchestrator.js +286 -0
  12. package/dist/agents/plan.js +143 -0
  13. package/dist/agents/types.js +20 -0
  14. package/dist/agents/verify.js +94 -0
  15. package/dist/app.js +619 -0
  16. package/dist/auth.js +456 -0
  17. package/dist/brief.js +97 -0
  18. package/dist/commands.js +587 -0
  19. package/dist/compact.js +231 -0
  20. package/dist/config.js +82 -0
  21. package/dist/diff.js +98 -0
  22. package/dist/doctor.js +106 -0
  23. package/dist/errors.js +15 -0
  24. package/dist/fileio.js +688 -0
  25. package/dist/input.js +244 -0
  26. package/dist/jobs.js +262 -0
  27. package/dist/main.js +560 -0
  28. package/dist/mask.js +63 -0
  29. package/dist/mcp.js +721 -0
  30. package/dist/models.js +106 -0
  31. package/dist/modes.js +114 -0
  32. package/dist/oicki.js +475 -0
  33. package/dist/output.js +101 -0
  34. package/dist/permissions.js +389 -0
  35. package/dist/permissions_ui.js +134 -0
  36. package/dist/print.js +76 -0
  37. package/dist/proc.js +63 -0
  38. package/dist/runners.js +264 -0
  39. package/dist/serial.js +9 -0
  40. package/dist/session.js +452 -0
  41. package/dist/shell.js +40 -0
  42. package/dist/skills.js +322 -0
  43. package/dist/syntax.js +80 -0
  44. package/dist/tools.js +480 -0
  45. package/dist/tui/editor.js +279 -0
  46. package/dist/tui/highlight.js +78 -0
  47. package/dist/tui/markdown.js +254 -0
  48. package/dist/tui/screen.js +146 -0
  49. package/dist/tui/tui.js +505 -0
  50. package/dist/tui/view.js +337 -0
  51. package/dist/ui.js +105 -0
  52. package/dist/update.js +64 -0
  53. package/package.json +57 -4
@@ -0,0 +1,279 @@
1
+ import { width } from "../ui.js";
2
+ const PASTE_MARKER = /\[paste #(\d+)[^\]]*\]/g;
3
+ const LONG_PASTE_LINES = 6;
4
+ const LONG_PASTE_CHARS = 600;
5
+ export class Editor {
6
+ buf = "";
7
+ cur = 0;
8
+ history = [];
9
+ multiline = true;
10
+ histIdx = -1;
11
+ draft = "";
12
+ pastes = new Map();
13
+ pasteSeq = 0;
14
+ typedBackslash = false;
15
+ /** The first character was typed (or recalled from history), not pasted: only then is a leading "!" a command. */
16
+ startTyped = false;
17
+ reset(text = "") {
18
+ this.startTyped = false;
19
+ this.buf = text;
20
+ this.cur = text.length;
21
+ this.histIdx = -1;
22
+ this.pastes.clear();
23
+ this.typedBackslash = false;
24
+ }
25
+ get empty() {
26
+ return this.buf === "";
27
+ }
28
+ beforeCursor() {
29
+ return this.buf.slice(0, this.cur);
30
+ }
31
+ /** The message to send: paste markers expanded to the pasted text. */
32
+ text() {
33
+ return this.buf.replace(PASTE_MARKER, (m, id) => this.pastes.get(Number(id)) ?? m);
34
+ }
35
+ remember(text) {
36
+ if (text.trim() && this.history.at(-1) !== text)
37
+ this.history.push(text);
38
+ }
39
+ /** Replace [from, cursor) with `text` (completion). */
40
+ replaceBeforeCursor(from, text) {
41
+ this.buf = this.buf.slice(0, from) + text + this.buf.slice(this.cur);
42
+ this.cur = from + text.length;
43
+ }
44
+ /** Apply a key. Returns "submit" when Enter should send the message, "edited" when the text or cursor changed. */
45
+ handle(key) {
46
+ const first = this.buf[0], atStart = this.cur === 0;
47
+ const r = this.apply(key);
48
+ if (key.k === "text" && atStart)
49
+ this.startTyped = true;
50
+ else if (key.k === "up" || key.k === "down")
51
+ this.startTyped = this.buf[0] !== first ? true : this.startTyped;
52
+ else if ((key.k === "paste" && atStart) || this.buf[0] !== first)
53
+ this.startTyped = false;
54
+ return r;
55
+ }
56
+ apply(key) {
57
+ const wasTypedBackslash = this.typedBackslash;
58
+ this.typedBackslash = false;
59
+ switch (key.k) {
60
+ case "text": {
61
+ const s = this.multiline ? key.s : key.s.replace(/\n/g, " ");
62
+ this.insert(s);
63
+ this.typedBackslash = s.endsWith("\\");
64
+ return "edited";
65
+ }
66
+ case "paste":
67
+ this.insertPaste(key.s);
68
+ return "edited";
69
+ case "newline":
70
+ if (this.multiline)
71
+ this.insert("\n");
72
+ return "edited";
73
+ case "enter":
74
+ // A typed (never pasted) trailing backslash continues the message on the next line.
75
+ if (this.multiline && wasTypedBackslash && this.buf[this.cur - 1] === "\\") {
76
+ this.buf = this.buf.slice(0, this.cur - 1) + "\n" + this.buf.slice(this.cur);
77
+ return "edited";
78
+ }
79
+ return "submit";
80
+ case "backspace":
81
+ if (this.cur > 0)
82
+ this.remove(this.prevIdx(this.cur), this.cur);
83
+ return "edited";
84
+ case "delete":
85
+ if (this.cur < this.buf.length)
86
+ this.remove(this.cur, this.nextIdx(this.cur));
87
+ return "edited";
88
+ case "left":
89
+ this.cur = this.outOfMarker(this.prevIdx(this.cur), -1);
90
+ return "edited";
91
+ case "right":
92
+ this.cur = this.outOfMarker(this.nextIdx(this.cur), 1);
93
+ return "edited";
94
+ case "home":
95
+ this.cur = this.lineStart(this.cur);
96
+ return "edited";
97
+ case "end":
98
+ this.cur = this.lineEnd(this.cur);
99
+ return "edited";
100
+ case "wordleft":
101
+ this.cur = this.outOfMarker(this.wordLeft(this.cur), -1);
102
+ return "edited";
103
+ case "wordright":
104
+ this.cur = this.outOfMarker(this.wordRight(this.cur), 1);
105
+ return "edited";
106
+ case "delword":
107
+ this.remove(this.wordLeft(this.cur), this.cur);
108
+ return "edited";
109
+ case "up":
110
+ this.vertical(-1);
111
+ this.cur = this.outOfMarker(this.cur, 1);
112
+ return "edited";
113
+ case "down":
114
+ this.vertical(1);
115
+ this.cur = this.outOfMarker(this.cur, 1);
116
+ return "edited";
117
+ case "ctrl":
118
+ if (key.c === "a")
119
+ this.cur = this.lineStart(this.cur);
120
+ else if (key.c === "e")
121
+ this.cur = this.lineEnd(this.cur);
122
+ else if (key.c === "k")
123
+ this.remove(this.cur, this.lineEnd(this.cur) === this.cur && this.cur < this.buf.length ? this.cur + 1 : this.lineEnd(this.cur));
124
+ else if (key.c === "u")
125
+ this.remove(this.lineStart(this.cur), this.cur);
126
+ else if (key.c === "w")
127
+ this.remove(this.wordLeft(this.cur), this.cur);
128
+ else
129
+ return "none";
130
+ return "edited";
131
+ default: return "none";
132
+ }
133
+ }
134
+ /** True when Up/Down would move within the text (so completion menus should not steal them) */
135
+ get multiLine() {
136
+ return this.buf.includes("\n");
137
+ }
138
+ insert(s) {
139
+ this.buf = this.buf.slice(0, this.cur) + s + this.buf.slice(this.cur);
140
+ this.cur += s.length;
141
+ }
142
+ insertPaste(text) {
143
+ if (!this.multiline)
144
+ return this.insert(text.replace(/\n/g, " "));
145
+ const lines = text.split("\n").length;
146
+ if (lines >= LONG_PASTE_LINES || text.length >= LONG_PASTE_CHARS) {
147
+ const id = ++this.pasteSeq;
148
+ this.pastes.set(id, text);
149
+ this.insert(lines > 1 ? `[paste #${id} +${lines} lines]` : `[paste #${id} ${text.length} chars]`);
150
+ }
151
+ else
152
+ this.insert(text);
153
+ }
154
+ /** The [start, end) of every paste marker whose paste is still held. */
155
+ markers() {
156
+ return [...this.buf.matchAll(PASTE_MARKER)].filter((m) => this.pastes.has(Number(m[1]))).map((m) => [m.index, m.index + m[0].length]);
157
+ }
158
+ /**
159
+ * A paste marker is one unit: a deletion that touches it removes all of it (and its paste). Deleting part of it would
160
+ * leave text that no longer expands, and the paste would be lost without a word.
161
+ */
162
+ remove(from, to) {
163
+ for (const [s, e] of this.markers()) {
164
+ if (from < e && to > s) {
165
+ from = Math.min(from, s);
166
+ to = Math.max(to, e);
167
+ }
168
+ }
169
+ this.buf = this.buf.slice(0, from) + this.buf.slice(to);
170
+ this.cur = from;
171
+ }
172
+ /** The cursor never rests inside a marker: moving into one goes to its far side. */
173
+ outOfMarker(i, dir) {
174
+ const m = this.markers().find(([s, e]) => i > s && i < e);
175
+ return m ? (dir < 0 ? m[0] : m[1]) : i;
176
+ }
177
+ prevIdx(i) {
178
+ if (i <= 0)
179
+ return 0;
180
+ const lo = this.buf.charCodeAt(i - 1);
181
+ return lo >= 0xdc00 && lo <= 0xdfff && i >= 2 ? i - 2 : i - 1;
182
+ }
183
+ nextIdx(i) {
184
+ if (i >= this.buf.length)
185
+ return this.buf.length;
186
+ const hi = this.buf.charCodeAt(i);
187
+ return hi >= 0xd800 && hi <= 0xdbff ? Math.min(i + 2, this.buf.length) : i + 1;
188
+ }
189
+ lineStart(i) {
190
+ return this.buf.lastIndexOf("\n", i - 1) + 1;
191
+ }
192
+ lineEnd(i) {
193
+ const e = this.buf.indexOf("\n", i);
194
+ return e < 0 ? this.buf.length : e;
195
+ }
196
+ wordLeft(i) {
197
+ while (i > 0 && /\s/.test(this.buf[i - 1]))
198
+ i--;
199
+ while (i > 0 && !/\s/.test(this.buf[i - 1]))
200
+ i--;
201
+ return i;
202
+ }
203
+ wordRight(i) {
204
+ while (i < this.buf.length && /\s/.test(this.buf[i]))
205
+ i++;
206
+ while (i < this.buf.length && !/\s/.test(this.buf[i]))
207
+ i++;
208
+ return i;
209
+ }
210
+ /** Up/Down move between lines; at the first/last line they browse history. */
211
+ vertical(dir) {
212
+ const start = this.lineStart(this.cur);
213
+ const col = this.cur - start;
214
+ if (dir < 0 && start > 0) {
215
+ const ps = this.lineStart(start - 1);
216
+ this.cur = Math.min(ps + col, start - 1);
217
+ }
218
+ else if (dir > 0 && this.lineEnd(this.cur) < this.buf.length) {
219
+ const ns = this.lineEnd(this.cur) + 1;
220
+ this.cur = Math.min(ns + col, this.lineEnd(ns));
221
+ }
222
+ else
223
+ this.browse(dir);
224
+ }
225
+ browse(dir) {
226
+ if (!this.history.length)
227
+ return;
228
+ if (this.histIdx < 0) {
229
+ if (dir > 0)
230
+ return;
231
+ this.draft = this.buf;
232
+ this.histIdx = this.history.length;
233
+ }
234
+ this.histIdx += dir;
235
+ if (this.histIdx >= this.history.length) {
236
+ this.histIdx = -1;
237
+ this.buf = this.draft;
238
+ }
239
+ else
240
+ this.buf = this.history[Math.max(0, this.histIdx)];
241
+ this.cur = this.buf.length;
242
+ }
243
+ /** Visual rows for `cols` columns (display width aware: Hangul/CJK are two columns), with the cursor position. */
244
+ render(cols, prompt, promptWidth) {
245
+ const avail = Math.max(4, cols - promptWidth);
246
+ const rows = [];
247
+ let cursor = { row: 0, col: promptWidth };
248
+ let offset = 0;
249
+ this.buf.split("\n").forEach((line, li) => {
250
+ let cur = "";
251
+ let w = 0;
252
+ let rowStart = rows.length;
253
+ const startOfLine = offset;
254
+ const place = (idx) => {
255
+ if (this.cur === idx)
256
+ cursor = { row: rows.length, col: promptWidth + w };
257
+ };
258
+ let idx = startOfLine;
259
+ for (const ch of line) {
260
+ const cw = width(ch);
261
+ place(idx);
262
+ if (w + cw > avail) {
263
+ rows.push(cur);
264
+ cur = "";
265
+ w = 0;
266
+ place(idx);
267
+ }
268
+ cur += ch;
269
+ w += cw;
270
+ idx += ch.length;
271
+ }
272
+ place(idx);
273
+ rows.push(cur);
274
+ void rowStart;
275
+ offset += line.length + 1;
276
+ });
277
+ return { lines: rows.map((r, i) => (i === 0 ? prompt : " ".repeat(promptWidth)) + r), cursor };
278
+ }
279
+ }
@@ -0,0 +1,78 @@
1
+ /** Syntax highlighting for fenced code: highlight.js (core + a fixed set of languages) converted to ANSI. */
2
+ import hljs from "highlight.js/lib/core";
3
+ import bash from "highlight.js/lib/languages/bash";
4
+ import c from "highlight.js/lib/languages/c";
5
+ import cpp from "highlight.js/lib/languages/cpp";
6
+ import css from "highlight.js/lib/languages/css";
7
+ import diff from "highlight.js/lib/languages/diff";
8
+ import go from "highlight.js/lib/languages/go";
9
+ import ini from "highlight.js/lib/languages/ini";
10
+ import java from "highlight.js/lib/languages/java";
11
+ import javascript from "highlight.js/lib/languages/javascript";
12
+ import json from "highlight.js/lib/languages/json";
13
+ import kotlin from "highlight.js/lib/languages/kotlin";
14
+ import markdown from "highlight.js/lib/languages/markdown";
15
+ import php from "highlight.js/lib/languages/php";
16
+ import python from "highlight.js/lib/languages/python";
17
+ import ruby from "highlight.js/lib/languages/ruby";
18
+ import rust from "highlight.js/lib/languages/rust";
19
+ import sql from "highlight.js/lib/languages/sql";
20
+ import swift from "highlight.js/lib/languages/swift";
21
+ import typescript from "highlight.js/lib/languages/typescript";
22
+ import xml from "highlight.js/lib/languages/xml";
23
+ import yaml from "highlight.js/lib/languages/yaml";
24
+ import { colorEnabled } from "../ui.js";
25
+ const langs = { bash, c, cpp, css, diff, go, ini, java, javascript, json, kotlin, markdown, php, python, ruby, rust, sql, swift, typescript, xml, yaml };
26
+ for (const [name, def] of Object.entries(langs))
27
+ hljs.registerLanguage(name, def);
28
+ const ALIASES = {
29
+ js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript", ts: "typescript", tsx: "typescript", sh: "bash", shell: "bash", zsh: "bash",
30
+ console: "bash", py: "python", yml: "yaml", html: "xml", svg: "xml", rs: "rust", rb: "ruby", md: "markdown", golang: "go", "c++": "cpp", cc: "cpp", h: "c",
31
+ kt: "kotlin", toml: "ini", jsonc: "json", json5: "json", patch: "diff",
32
+ };
33
+ // hljs class -> SGR (foreground only; works on dark and light backgrounds)
34
+ const STYLE = [
35
+ [/keyword|selector-tag|doctag|operator-word/, "35"],
36
+ [/string|regexp|template-variable|addition/, "32"],
37
+ [/number|literal|symbol|bullet/, "33"],
38
+ [/comment|quote|meta/, "90;3"],
39
+ [/title|function|section/, "34"],
40
+ [/built_in|type|class|tag|name|selector-class|selector-id/, "36"],
41
+ [/attr|attribute|property|variable|params/, "36"],
42
+ [/deletion/, "31"],
43
+ ];
44
+ const sgrFor = (cls) => STYLE.find(([re]) => re.test(cls))?.[1];
45
+ const entities = (s) => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#x27;/g, "'").replace(/&#39;/g, "'").replace(/&amp;/g, "&");
46
+ export function languageFor(info) {
47
+ const l = (info ?? "").trim().split(/\s+/)[0].toLowerCase();
48
+ const name = ALIASES[l] ?? l;
49
+ return hljs.getLanguage(name) ? name : undefined;
50
+ }
51
+ /** Highlighted lines of `code` (one string per line, each self-contained ANSI). Plain text when the language is unknown. */
52
+ export function highlightLines(code, info) {
53
+ const lang = languageFor(info);
54
+ if (!lang || !colorEnabled)
55
+ return code.split("\n");
56
+ let html;
57
+ try {
58
+ html = hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
59
+ }
60
+ catch {
61
+ return code.split("\n");
62
+ }
63
+ const stack = [];
64
+ let out = "";
65
+ for (const m of html.matchAll(/<span class="([^"]*)">|<\/span>|([^<]+)/g)) {
66
+ if (m[1] !== undefined)
67
+ stack.push(m[1].split(/\s+/).map((c) => c.replace(/^hljs-/, "")).map(sgrFor).find(Boolean));
68
+ else if (m[2] === undefined)
69
+ stack.pop();
70
+ else {
71
+ const text = entities(m[2]);
72
+ const style = [...stack].reverse().find(Boolean);
73
+ // style each line separately so every returned line is self-contained
74
+ out += text.split("\n").map((l) => (style && l ? `\x1b[${style}m${l}\x1b[0m` : l)).join("\n");
75
+ }
76
+ }
77
+ return out.split("\n");
78
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Markdown -> terminal lines. `renderMarkdown` renders a complete document; `MarkdownStream` renders a stream:
3
+ * blocks that are certainly finished are returned once for committing to scrollback, and only the unfinished last block
4
+ * is re-rendered (as "live" lines). Parsing uses `marked` with a CJK-friendly emphasis rule (so `**굵게**한글` works).
5
+ */
6
+ import { Marked } from "marked";
7
+ import markedCjkFriendly from "marked-cjk-friendly";
8
+ import { bold, cyan, dim, gray, italic, padEnd, strike, underline, blue, width, wrap, yellow, green } from "../ui.js";
9
+ import { highlightLines } from "./highlight.js";
10
+ const marked = new Marked(markedCjkFriendly(), { gfm: true, breaks: false });
11
+ const lex = (src) => marked.lexer(src);
12
+ // ---------------------------------------------------------------- inline
13
+ function inline(tokens) {
14
+ if (!tokens)
15
+ return "";
16
+ let out = "";
17
+ for (const t of tokens) {
18
+ switch (t.type) {
19
+ case "text":
20
+ case "escape":
21
+ out += "tokens" in t && t.tokens ? inline(t.tokens) : decode(t.text);
22
+ break;
23
+ case "strong":
24
+ out += bold(inline(t.tokens));
25
+ break;
26
+ case "em":
27
+ out += italic(inline(t.tokens));
28
+ break;
29
+ case "del":
30
+ out += strike(inline(t.tokens));
31
+ break;
32
+ case "codespan":
33
+ out += yellow(decode(t.text));
34
+ break;
35
+ case "br":
36
+ out += "\n";
37
+ break;
38
+ case "link": {
39
+ const l = t;
40
+ const text = inline(l.tokens);
41
+ const plain = decode(l.text);
42
+ out += l.href === plain || l.href === "mailto:" + plain ? underline(blue(text)) : `${underline(blue(text))} ${dim("(" + l.href + ")")}`;
43
+ break;
44
+ }
45
+ case "image":
46
+ out += dim(`[image: ${t.text || "image"}] ${t.href}`);
47
+ break;
48
+ case "html":
49
+ out += dim(t.text);
50
+ break;
51
+ default: out += "raw" in t ? String(t.raw) : "";
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+ const decode = (s) => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
57
+ // ---------------------------------------------------------------- blocks
58
+ const bar = (s) => gray(s);
59
+ function codeBlock(t, cols) {
60
+ const lang = (t.lang ?? "").trim().split(/\s+/)[0];
61
+ const body = highlightLines(t.text.replace(/\t/g, " "), t.lang);
62
+ // no gutter and no wrapping: a copied block is the code as written (the terminal soft-wraps long lines)
63
+ return [bar("┌ ") + dim(lang || "code"), ...body, bar("└")];
64
+ }
65
+ function listBlock(t, cols, live) {
66
+ const out = [];
67
+ const start = typeof t.start === "number" ? t.start : 1;
68
+ t.items.forEach((item, i) => {
69
+ const marker = t.ordered ? `${start + i}.` : item.task ? (item.checked ? "☑" : "☐") : "•";
70
+ const lead = " " + marker + " ";
71
+ const pad = " ".repeat(width(lead));
72
+ const inner = blocks(item.tokens, Math.max(10, cols - width(lead)), live, true);
73
+ // an item's own paragraphs are kept tight (no blank line between a bullet and its text)
74
+ const lines = inner.length ? inner : [""];
75
+ lines.forEach((l, j) => out.push((j === 0 ? dim(lead) : pad) + l));
76
+ });
77
+ return out;
78
+ }
79
+ function tableBlock(t, cols) {
80
+ const rows = [t.header.map((c) => inline(c.tokens)), ...t.rows.map((r) => r.map((c) => inline(c.tokens)))];
81
+ const n = t.header.length;
82
+ const natural = Array.from({ length: n }, (_, c) => Math.max(3, ...rows.map((r) => Math.max(...(r[c] ?? "").split("\n").map(width)))));
83
+ const avail = cols - (3 * n + 1);
84
+ if (avail < 3 * n) {
85
+ // too narrow for a drawn table: one "header: value" block per row
86
+ const out = [];
87
+ rows.slice(1).forEach((r, i) => {
88
+ if (i)
89
+ out.push("");
90
+ r.forEach((cell, c) => out.push(...wrap(`${bold(rows[0][c])}: ${cell}`, cols)));
91
+ });
92
+ return out;
93
+ }
94
+ let w = [...natural];
95
+ if (natural.reduce((a, b) => a + b, 0) > avail) {
96
+ // shrink the widest columns first, never below 6 (or what fits)
97
+ const min = Math.max(3, Math.min(6, Math.floor(avail / n)));
98
+ let over = natural.reduce((a, b) => a + b, 0) - Math.max(avail, min * n);
99
+ while (over > 0) {
100
+ const i = w.indexOf(Math.max(...w));
101
+ if (w[i] <= min)
102
+ break;
103
+ w[i]--;
104
+ over--;
105
+ }
106
+ }
107
+ const line = (l, m, r) => bar(l + w.map((x) => "─".repeat(x + 2)).join(m) + r);
108
+ const row = (cells, head) => {
109
+ const wrapped = cells.map((c, i) => wrap(head ? bold(c) : c, w[i]));
110
+ const h = Math.max(...wrapped.map((x) => x.length));
111
+ const out = [];
112
+ for (let k = 0; k < h; k++) {
113
+ out.push(bar("│") +
114
+ wrapped
115
+ .map((cell, i) => {
116
+ const txt = cell[k] ?? "";
117
+ const pad = " ".repeat(Math.max(0, w[i] - width(txt)));
118
+ const al = t.align[i];
119
+ return " " + (al === "right" ? pad + txt : al === "center" ? " ".repeat(Math.floor(pad.length / 2)) + txt + " ".repeat(Math.ceil(pad.length / 2)) : txt + pad) + " ";
120
+ })
121
+ .join(bar("│")) +
122
+ bar("│"));
123
+ }
124
+ return out;
125
+ };
126
+ return [line("┌", "┬", "┐"), ...row(rows[0], true), line("├", "┼", "┤"), ...rows.slice(1).flatMap((r) => row(r, false)), line("└", "┴", "┘")];
127
+ }
128
+ function blocks(tokens, cols, live, inItem = false) {
129
+ const out = [];
130
+ let first = true;
131
+ const push = (lines, tight = false) => {
132
+ if (!lines.length)
133
+ return;
134
+ if (!first && !tight)
135
+ out.push("");
136
+ out.push(...lines);
137
+ first = false;
138
+ };
139
+ for (const t of tokens) {
140
+ switch (t.type) {
141
+ case "space":
142
+ case "checkbox": break; // the box is drawn from item.checked
143
+ case "heading": {
144
+ const h = t;
145
+ const text = inline(h.tokens);
146
+ push(wrap(h.depth === 1 ? bold(underline(cyan(text))) : h.depth === 2 ? bold(blue(text)) : bold(text), cols));
147
+ break;
148
+ }
149
+ case "paragraph":
150
+ push(wrap(inline(t.tokens), cols));
151
+ break;
152
+ case "text":
153
+ push(wrap(inline(t.tokens ?? [t]), cols), true);
154
+ break; // list-item text: tight
155
+ case "code":
156
+ push(codeBlock(t, cols));
157
+ break;
158
+ case "blockquote":
159
+ push(blocks(t.tokens, Math.max(10, cols - 2), live).map((l) => bar("▎ ") + italic(l)));
160
+ break;
161
+ case "list":
162
+ push(listBlock(t, cols, live), inItem);
163
+ break; // a list nested in an item stays tight
164
+ case "table":
165
+ push(live ? t.raw.trimEnd().split("\n").map((l) => dim(l)) : tableBlock(t, cols));
166
+ break;
167
+ case "hr":
168
+ push([dim("─".repeat(Math.min(cols, 80)))]);
169
+ break;
170
+ case "html":
171
+ push(t.text.trimEnd().split("\n").map((l) => dim(l)));
172
+ break;
173
+ default: push(wrap(String(t.raw ?? "").trimEnd(), cols));
174
+ }
175
+ }
176
+ return out;
177
+ }
178
+ /** A complete Markdown document as terminal lines hard-wrapped to `cols`. */
179
+ export function renderMarkdown(src, cols) {
180
+ return blocks(lex(src), cols, false);
181
+ }
182
+ // ---------------------------------------------------------------- streaming
183
+ /**
184
+ * Feed text deltas with `push`; call `pull` each frame. `commit` lines are final (print them once, above the live
185
+ * region); `live` lines are the unfinished block, re-rendered every frame. If the live block is taller than `maxRows`,
186
+ * its head is committed early when that is safe (code blocks), so the live region stays small.
187
+ */
188
+ export class MarkdownStream {
189
+ buf = "";
190
+ consumed = 0; // characters of `buf` that belong to committed blocks
191
+ emitted = false; // any block committed (a blank line separates blocks)
192
+ skip = 0; // lines of the current live block already committed early
193
+ push(delta) {
194
+ this.buf += delta;
195
+ }
196
+ get text() {
197
+ return this.buf;
198
+ }
199
+ get empty() {
200
+ return this.buf.trim() === "";
201
+ }
202
+ render(tokens, cols, live) {
203
+ return blocks(tokens, cols, live);
204
+ }
205
+ pull(cols, maxRows) {
206
+ const rest = this.buf.slice(this.consumed);
207
+ const tokens = lex(rest);
208
+ let last = tokens.length - 1;
209
+ while (last >= 0 && tokens[last].type === "space")
210
+ last--;
211
+ const done = tokens.slice(0, Math.max(0, last));
212
+ const commit = [];
213
+ if (done.length) {
214
+ const lines = this.render(done, cols, false);
215
+ if (lines.length)
216
+ commit.push(...this.withGap(lines).slice(this.skip));
217
+ this.emitted = this.emitted || lines.length > 0;
218
+ this.consumed += done.map((t) => t.raw).join("").length;
219
+ this.skip = 0;
220
+ }
221
+ if (last < 0)
222
+ return { commit, live: [] };
223
+ let live = this.render([tokens[last]], cols, true);
224
+ if (live.length)
225
+ live = this.withGap(live).slice(this.skip);
226
+ if (live.length > maxRows) {
227
+ const extra = live.length - maxRows;
228
+ // Only a code block's head is final (its lines never change when more text arrives). For lists, quotes, tables and
229
+ // paragraphs later text can re-parse earlier lines (nesting, **spanning lines**), so show the tail and commit once.
230
+ if (tokens[last].type === "code") {
231
+ commit.push(...live.slice(0, extra));
232
+ this.skip += extra;
233
+ }
234
+ live = live.slice(extra);
235
+ }
236
+ return { commit, live };
237
+ }
238
+ /** A block after an earlier one starts with a blank line. `skip` counts lines already committed, that blank included. */
239
+ withGap(lines) {
240
+ return this.emitted ? ["", ...lines] : lines;
241
+ }
242
+ /** The end of the message: everything still pending, rendered as final. */
243
+ finish(cols) {
244
+ const tokens = lex(this.buf.slice(this.consumed));
245
+ const lines = this.render(tokens, cols, false);
246
+ const out = lines.length ? this.withGap(lines).slice(this.skip) : [];
247
+ this.buf = "";
248
+ this.consumed = 0;
249
+ this.emitted = false;
250
+ this.skip = 0;
251
+ return out;
252
+ }
253
+ }
254
+ export { padEnd, green };