@xingjisoft/neutrino 0.1.0 → 0.1.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.
@@ -0,0 +1,630 @@
1
+ import { C } from "./colors.js";
2
+ import { displayWidth, truncateByWidth, wcwidth } from "./width.js";
3
+ // ---------------------------------------------------------------------------
4
+ // Style helpers
5
+ // ---------------------------------------------------------------------------
6
+ function mergeStyle(base, over) {
7
+ return {
8
+ fg: over.fg ?? base.fg,
9
+ bg: over.bg ?? base.bg,
10
+ bold: over.bold ?? base.bold,
11
+ dim: over.dim ?? base.dim,
12
+ inverse: over.inverse ?? base.inverse,
13
+ italic: over.italic ?? base.italic,
14
+ };
15
+ }
16
+ function styleKeyOf(st) {
17
+ return JSON.stringify([
18
+ st.fg ? `f${st.fg[0]},${st.fg[1]},${st.fg[2]}` : "",
19
+ st.bg ? `b${st.bg[0]},${st.bg[1]},${st.bg[2]}` : "",
20
+ st.bold ? 1 : 0,
21
+ st.dim ? 1 : 0,
22
+ st.inverse ? 1 : 0,
23
+ st.italic ? 1 : 0,
24
+ ]);
25
+ }
26
+ function decodeEntities(s) {
27
+ return s
28
+ .replace(/&/g, "&")
29
+ .replace(/&lt;/g, "<")
30
+ .replace(/&gt;/g, ">")
31
+ .replace(/&quot;/g, '"')
32
+ .replace(/&#39;/g, "'")
33
+ .replace(/&nbsp;/g, " ");
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // Inline parsing (bold / italic / code / links / strikethrough / escapes)
37
+ // ---------------------------------------------------------------------------
38
+ const WORD_RE = /[\p{L}\p{N}]/u;
39
+ function codePointBefore(text, i) {
40
+ let j = i - 1;
41
+ if (j < 0)
42
+ return "";
43
+ const code = text.charCodeAt(j);
44
+ if (code >= 0xdc00 && code <= 0xdfff && j > 0) {
45
+ const hi = text.charCodeAt(j - 1);
46
+ if (hi >= 0xd800 && hi <= 0xdbff)
47
+ return text.slice(j - 1, j + 1);
48
+ }
49
+ return text[j];
50
+ }
51
+ /** `*` / `_` opening emphasis must not be glued to a word and must have content after it. */
52
+ function isEmphasisOpen(text, i) {
53
+ const prev = codePointBefore(text, i);
54
+ if (prev && WORD_RE.test(prev))
55
+ return false;
56
+ const next = text.codePointAt(i + 1) ?? 0;
57
+ if (!next)
58
+ return false;
59
+ if (/\s/.test(String.fromCodePoint(next)))
60
+ return false;
61
+ return true;
62
+ }
63
+ export function parseInline(text, base = {}) {
64
+ const spans = [];
65
+ const append = (sp) => {
66
+ const last = spans[spans.length - 1];
67
+ if (last && styleKeyOf(last.style) === styleKeyOf(sp.style)) {
68
+ last.text += sp.text;
69
+ }
70
+ else {
71
+ spans.push(sp);
72
+ }
73
+ };
74
+ const push = (raw, over) => {
75
+ if (!raw)
76
+ return;
77
+ append({ text: decodeEntities(raw), style: mergeStyle(base, over) });
78
+ };
79
+ const pushNested = (raw, over) => {
80
+ const inner = parseInline(raw, mergeStyle(base, over));
81
+ for (const sp of inner)
82
+ append(sp);
83
+ };
84
+ let i = 0;
85
+ const n = text.length;
86
+ while (i < n) {
87
+ const cp = text.codePointAt(i) ?? 0;
88
+ const ch = String.fromCodePoint(cp);
89
+ const unitLen = ch.length;
90
+ // backslash escape: \*
91
+ if (ch === "\\" && i + 1 < n) {
92
+ const ncp = text.codePointAt(i + 1) ?? 0;
93
+ push(String.fromCodePoint(ncp), {});
94
+ i += 1 + String.fromCodePoint(ncp).length;
95
+ continue;
96
+ }
97
+ // inline code `...`
98
+ if (ch === "`") {
99
+ const end = text.indexOf("`", i + 1);
100
+ if (end > i) {
101
+ push(text.slice(i + 1, end), { fg: C.green, bg: C.codeBg });
102
+ i = end + 1;
103
+ continue;
104
+ }
105
+ }
106
+ // link [label](url)
107
+ if (ch === "[") {
108
+ const close = text.indexOf("]", i + 1);
109
+ if (close > i && text[close + 1] === "(") {
110
+ const pend = text.indexOf(")", close + 2);
111
+ if (pend > close + 2) {
112
+ const label = text.slice(i + 1, close);
113
+ const url = text.slice(close + 2, pend);
114
+ push(label || url, { fg: C.accent, bold: true });
115
+ if (label && url)
116
+ push(" (" + url + ")", { fg: C.dim });
117
+ i = pend + 1;
118
+ continue;
119
+ }
120
+ }
121
+ }
122
+ // bold + italic *** / ___
123
+ if ((ch === "*" && text.startsWith("***", i)) || (ch === "_" && text.startsWith("___", i))) {
124
+ const delim = text.slice(i, i + 3);
125
+ const end = text.indexOf(delim, i + 3);
126
+ if (end > i + 3) {
127
+ pushNested(text.slice(i + 3, end), { bold: true, italic: true });
128
+ i = end + 3;
129
+ continue;
130
+ }
131
+ }
132
+ // bold ** / __
133
+ if ((ch === "*" && text.startsWith("**", i)) || (ch === "_" && text.startsWith("__", i))) {
134
+ const prev = codePointBefore(text, i);
135
+ if (!prev || !WORD_RE.test(prev)) {
136
+ const delim = text.slice(i, i + 2);
137
+ const end = text.indexOf(delim, i + 2);
138
+ if (end > i + 2) {
139
+ pushNested(text.slice(i + 2, end), { bold: true });
140
+ i = end + 2;
141
+ continue;
142
+ }
143
+ }
144
+ }
145
+ // strikethrough ~~
146
+ if (ch === "~" && text.startsWith("~~", i)) {
147
+ const end = text.indexOf("~~", i + 2);
148
+ if (end > i + 2) {
149
+ pushNested(text.slice(i + 2, end), { dim: true });
150
+ i = end + 2;
151
+ continue;
152
+ }
153
+ }
154
+ // emphasis * / _
155
+ if (ch === "*" || ch === "_") {
156
+ if (isEmphasisOpen(text, i)) {
157
+ const end = text.indexOf(ch, i + 1);
158
+ if (end > i + 1) {
159
+ pushNested(text.slice(i + 1, end), { italic: true });
160
+ i = end + 1;
161
+ continue;
162
+ }
163
+ }
164
+ }
165
+ // plain text run: fast path, also lets HTML entities decode as a unit
166
+ let j = i;
167
+ while (j < n) {
168
+ const c = text[j];
169
+ if (c === "\\" || c === "`" || c === "[" || c === "*" || c === "_" || c === "~")
170
+ break;
171
+ j++;
172
+ }
173
+ if (j > i) {
174
+ push(text.slice(i, j), {});
175
+ i = j;
176
+ continue;
177
+ }
178
+ push(ch, {});
179
+ i += unitLen;
180
+ }
181
+ return spans;
182
+ }
183
+ // ---------------------------------------------------------------------------
184
+ // Block parsing
185
+ // ---------------------------------------------------------------------------
186
+ function indentWidth(line) {
187
+ const m = line.match(/^\s*/);
188
+ return m ? m[0].replace(/\t/g, " ").length : 0;
189
+ }
190
+ function isDelimiterRow(line) {
191
+ let s = line.trim();
192
+ if (s.startsWith("|"))
193
+ s = s.slice(1);
194
+ if (s.endsWith("|"))
195
+ s = s.slice(0, -1);
196
+ const cells = s.split("|");
197
+ if (cells.length < 2)
198
+ return false;
199
+ return cells.every((c) => /^\s*:?-{1,}:?\s*$/.test(c.trim()));
200
+ }
201
+ function looksLikeTable(src, i) {
202
+ if (i + 1 >= src.length)
203
+ return false;
204
+ if (!src[i].includes("|"))
205
+ return false;
206
+ return isDelimiterRow(src[i + 1]);
207
+ }
208
+ function splitRow(line) {
209
+ let s = line.trim();
210
+ if (s.startsWith("|"))
211
+ s = s.slice(1);
212
+ if (s.endsWith("|"))
213
+ s = s.slice(0, -1);
214
+ return s.split("|").map((c) => c.trim());
215
+ }
216
+ function parseTable(src, i, base) {
217
+ const header = splitRow(src[i]).map((c) => parseInline(c, base));
218
+ const delim = splitRow(src[i + 1]);
219
+ const align = delim.map((c) => {
220
+ const t = c.trim();
221
+ if (t.startsWith(":") && t.endsWith(":"))
222
+ return "center";
223
+ if (t.endsWith(":"))
224
+ return "right";
225
+ return "left";
226
+ });
227
+ const rows = [];
228
+ let j = i + 2;
229
+ while (j < src.length && src[j].trim() && src[j].includes("|")) {
230
+ rows.push(splitRow(src[j]).map((c) => parseInline(c, base)));
231
+ j++;
232
+ }
233
+ return { block: { type: "table", header, rows, align }, consumed: j - i };
234
+ }
235
+ export function parseMarkdown(text, base = {}) {
236
+ const blocks = [];
237
+ const src = text.replace(/\r\n?/g, "\n").split("\n");
238
+ const n = src.length;
239
+ let i = 0;
240
+ const isFence = (line) => line.match(/^\s*(`{3,}|~{3,})(.*)$/);
241
+ const isHeading = (line) => line.match(/^(#{1,6})\s+(.*)$/);
242
+ const isRule = (line) => /^\s*([-*_])(?:\s*\1){2,}\s*$/.test(line);
243
+ const isQuote = (line) => /^\s*>/.test(line);
244
+ const isList = (line) => line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
245
+ while (i < n) {
246
+ const line = src[i];
247
+ if (!line.trim()) {
248
+ blocks.push({ type: "blank" });
249
+ i++;
250
+ continue;
251
+ }
252
+ // fenced code block
253
+ const fence = isFence(line);
254
+ if (fence) {
255
+ const lang = (fence[2].trim().split(/\s+/)[0] ?? "").replace(/^`+/, "");
256
+ const closeRe = new RegExp(`^\\s*${fence[1][0]}{${fence[1].length},}\\s*$`);
257
+ const codeLines = [];
258
+ i++;
259
+ while (i < n && !closeRe.test(src[i])) {
260
+ codeLines.push(src[i]);
261
+ i++;
262
+ }
263
+ i++; // skip closing fence (or past the end)
264
+ blocks.push({ type: "code", lang, lines: codeLines });
265
+ continue;
266
+ }
267
+ // ATX heading
268
+ const heading = isHeading(line);
269
+ if (heading) {
270
+ blocks.push({ type: "heading", level: heading[1].length, spans: parseInline(heading[2], base) });
271
+ i++;
272
+ continue;
273
+ }
274
+ // horizontal rule
275
+ if (isRule(line)) {
276
+ blocks.push({ type: "rule" });
277
+ i++;
278
+ continue;
279
+ }
280
+ // blockquote
281
+ if (isQuote(line)) {
282
+ const quoteLines = [];
283
+ while (i < n && isQuote(src[i])) {
284
+ quoteLines.push(src[i].replace(/^\s*>\s?/, ""));
285
+ i++;
286
+ }
287
+ blocks.push({ type: "quote", spans: parseInline(quoteLines.join("\n"), base) });
288
+ continue;
289
+ }
290
+ // table
291
+ if (looksLikeTable(src, i)) {
292
+ const tbl = parseTable(src, i, base);
293
+ blocks.push(tbl.block);
294
+ i += tbl.consumed;
295
+ continue;
296
+ }
297
+ // list
298
+ const listMatch = isList(line);
299
+ if (listMatch) {
300
+ while (i < n) {
301
+ const l = src[i];
302
+ if (!l.trim())
303
+ break;
304
+ const lm = isList(l);
305
+ if (!lm) {
306
+ // indented continuation line of the previous item
307
+ const prev = blocks[blocks.length - 1];
308
+ if (indentWidth(l) > 0 && prev && prev.type === "listItem") {
309
+ const item = prev;
310
+ item.spans = [...item.spans, { text: " ", style: {} }, ...parseInline(l.trim(), base)];
311
+ i++;
312
+ continue;
313
+ }
314
+ break;
315
+ }
316
+ const indentStr = lm[1].replace(/\t/g, " ");
317
+ const marker = lm[2];
318
+ const ordered = /^\d+\.$/.test(marker);
319
+ const number = ordered ? parseInt(marker, 10) : 0;
320
+ blocks.push({
321
+ type: "listItem",
322
+ ordered,
323
+ number,
324
+ level: Math.min(6, Math.floor(indentStr.length / 2)),
325
+ spans: parseInline(lm[3], base),
326
+ });
327
+ i++;
328
+ }
329
+ continue;
330
+ }
331
+ // plain paragraph: collect until a block boundary or blank line
332
+ const paraLines = [];
333
+ while (i < n) {
334
+ const l = src[i];
335
+ if (!l.trim())
336
+ break;
337
+ if (isHeading(l) || isRule(l) || isQuote(l) || isFence(l) || isList(l))
338
+ break;
339
+ paraLines.push(l);
340
+ i++;
341
+ }
342
+ blocks.push({ type: "paragraph", spans: parseInline(paraLines.join("\n"), base) });
343
+ }
344
+ return blocks;
345
+ }
346
+ // ---------------------------------------------------------------------------
347
+ // Wrapping
348
+ // ---------------------------------------------------------------------------
349
+ function chunkByWidth(text, width) {
350
+ if (width < 1)
351
+ width = 1;
352
+ const chunks = [];
353
+ let cur = "";
354
+ let curW = 0;
355
+ for (const ch of text) {
356
+ const w = wcwidth(ch.codePointAt(0) ?? 0);
357
+ if (curW + w > width && cur.length > 0) {
358
+ chunks.push(cur);
359
+ cur = ch;
360
+ curW = w;
361
+ }
362
+ else {
363
+ cur += ch;
364
+ curW += w;
365
+ }
366
+ }
367
+ if (cur.length > 0)
368
+ chunks.push(cur);
369
+ return chunks;
370
+ }
371
+ /** Wrap a single (newline-free) run of spans into lines, word by word. */
372
+ function wrapSegment(spans, width) {
373
+ if (width < 1)
374
+ width = 1;
375
+ const words = [];
376
+ let pendingSpace = false;
377
+ for (const s of spans) {
378
+ const parts = s.text.split(/(\s+)/);
379
+ for (const p of parts) {
380
+ if (!p)
381
+ continue;
382
+ if (/^\s+$/.test(p)) {
383
+ pendingSpace = true;
384
+ }
385
+ else {
386
+ words.push({ text: p, style: s.style, spaceBefore: pendingSpace });
387
+ pendingSpace = false;
388
+ }
389
+ }
390
+ }
391
+ const lines = [];
392
+ let cur = [];
393
+ let curW = 0;
394
+ const pushLine = () => {
395
+ if (cur.length)
396
+ lines.push({ spans: cur });
397
+ cur = [];
398
+ curW = 0;
399
+ };
400
+ const addWord = (w) => {
401
+ const ww = displayWidth(w.text);
402
+ if (cur.length === 0) {
403
+ if (ww <= width) {
404
+ cur.push({ text: w.text, style: w.style });
405
+ curW = ww;
406
+ }
407
+ else {
408
+ const chunks = chunkByWidth(w.text, width);
409
+ for (let c = 0; c < chunks.length - 1; c++) {
410
+ lines.push({ spans: [{ text: chunks[c], style: w.style }] });
411
+ }
412
+ cur.push({ text: chunks[chunks.length - 1], style: w.style });
413
+ curW = displayWidth(chunks[chunks.length - 1]);
414
+ }
415
+ return;
416
+ }
417
+ const extra = (w.spaceBefore ? 1 : 0) + ww;
418
+ if (curW + extra <= width) {
419
+ if (w.spaceBefore)
420
+ cur.push({ text: " ", style: w.style });
421
+ cur.push({ text: w.text, style: w.style });
422
+ curW += extra;
423
+ }
424
+ else {
425
+ pushLine();
426
+ addWord({ ...w, spaceBefore: false });
427
+ }
428
+ };
429
+ for (const w of words)
430
+ addWord(w);
431
+ pushLine();
432
+ return lines;
433
+ }
434
+ /**
435
+ * Wrap spans into lines, optionally prefixing the first line and indenting
436
+ * continuation lines. Used for headings, list items and blockquotes.
437
+ */
438
+ export function wrapSpans(spans, width, opts = {}) {
439
+ const prefix = opts.prefix ?? [];
440
+ const prefixW = displayWidth(prefix.map((p) => p.text).join(""));
441
+ const indent = opts.indent ?? prefixW;
442
+ const contentW = Math.max(4, width - Math.max(prefixW, indent));
443
+ const inner = wrapSegment(spans, contentW);
444
+ const out = [];
445
+ if (inner.length === 0) {
446
+ out.push({ spans: [...prefix] });
447
+ return out;
448
+ }
449
+ for (let k = 0; k < inner.length; k++) {
450
+ const lead = k === 0 ? prefix : [{ text: " ".repeat(indent), style: {} }];
451
+ out.push({ spans: [...lead, ...inner[k].spans] });
452
+ }
453
+ return out;
454
+ }
455
+ // ---------------------------------------------------------------------------
456
+ // Block rendering
457
+ // ---------------------------------------------------------------------------
458
+ export function renderBlocks(blocks, width) {
459
+ const out = [];
460
+ let pendingBlank = false;
461
+ for (const b of blocks) {
462
+ if (b.type === "blank") {
463
+ pendingBlank = true;
464
+ continue;
465
+ }
466
+ if (pendingBlank && out.length > 0)
467
+ out.push({ spans: [] });
468
+ pendingBlank = false;
469
+ for (const ln of renderBlock(b, width))
470
+ out.push(ln);
471
+ }
472
+ return out;
473
+ }
474
+ function renderBlock(b, width) {
475
+ switch (b.type) {
476
+ case "paragraph":
477
+ return wrapSpans(b.spans, width);
478
+ case "heading": {
479
+ const level = Math.min(6, b.level);
480
+ const prefix = [{ text: "#".repeat(level) + " ", style: { fg: C.accent, bold: true } }];
481
+ const spans = b.spans.map((s) => ({ ...s, style: { ...s.style, bold: true } }));
482
+ return wrapSpans(spans, width, { prefix });
483
+ }
484
+ case "listItem": {
485
+ const marker = b.ordered ? `${b.number || 1}.` : "•";
486
+ const prefix = [{ text: marker + " ", style: { fg: C.accent, bold: true } }];
487
+ const pad = b.level > 0 ? [{ text: " ".repeat(b.level), style: {} }] : [];
488
+ return wrapSpans(b.spans, width, { prefix, indent: 2 }).map((ln) => ({
489
+ spans: [...pad, ...ln.spans],
490
+ }));
491
+ }
492
+ case "quote": {
493
+ const prefix = [{ text: "│ ", style: { fg: C.dim } }];
494
+ const spans = b.spans.map((s) => ({ ...s, style: { ...s.style, dim: true } }));
495
+ return wrapSpans(spans, width, { prefix, indent: 2 });
496
+ }
497
+ case "code": {
498
+ const lines = [];
499
+ const style = { fg: C.green, bg: C.codeBg };
500
+ for (const raw of b.lines) {
501
+ for (const chunk of chunkByWidth(raw, width)) {
502
+ lines.push({ spans: [{ text: chunk, style }] });
503
+ }
504
+ }
505
+ return lines;
506
+ }
507
+ case "rule":
508
+ return [{ spans: [{ text: "─".repeat(Math.max(4, width)), style: { fg: C.dim } }] }];
509
+ case "table":
510
+ return renderTable(b, width);
511
+ default:
512
+ return [];
513
+ }
514
+ }
515
+ function wrapCell(cell, w) {
516
+ if (!cell.length)
517
+ return [{ spans: [] }];
518
+ const lines = wrapSegment(cell, w);
519
+ return lines.length ? lines : [{ spans: [] }];
520
+ }
521
+ function renderTable(t, width) {
522
+ const nCols = Math.max(1, t.header.length);
523
+ const allRows = [t.header, ...t.rows];
524
+ const out = [];
525
+ // Natural width per column (max display width of any single line of text).
526
+ const natural = new Array(nCols).fill(1);
527
+ for (const row of allRows) {
528
+ for (let c = 0; c < nCols; c++) {
529
+ const cell = row[c] ?? [];
530
+ let cw = 0;
531
+ for (const s of cell) {
532
+ for (const part of s.text.split("\n")) {
533
+ cw = Math.max(cw, displayWidth(part));
534
+ }
535
+ }
536
+ if (cw > natural[c])
537
+ natural[c] = Math.min(cw, width);
538
+ }
539
+ }
540
+ // Total row width = sum(colW) + 3*nCols + 1 (borders + padding).
541
+ const budget = Math.max(nCols, width - 3 * nCols - 1);
542
+ const totalNatural = natural.reduce((a, b) => a + b, 0);
543
+ let colW;
544
+ if (totalNatural + 3 * nCols + 1 <= width) {
545
+ colW = natural.slice();
546
+ }
547
+ else {
548
+ colW = natural.map((w) => Math.max(1, Math.floor((w * budget) / totalNatural)));
549
+ const used = colW.reduce((a, b) => a + b, 0);
550
+ colW[nCols - 1] = Math.max(1, colW[nCols - 1] + (budget - used));
551
+ }
552
+ const borderStyle = { fg: C.dim };
553
+ const mkBorder = (left, mid, right) => {
554
+ const spans = [];
555
+ for (let c = 0; c < nCols; c++) {
556
+ spans.push({ text: (c === 0 ? left : mid) + "─".repeat(colW[c] + 2), style: borderStyle });
557
+ }
558
+ spans.push({ text: right, style: borderStyle });
559
+ return spans;
560
+ };
561
+ const renderRow = (row, isHeader) => {
562
+ const wrapped = row.map((cell, c) => wrapCell(cell, colW[c]));
563
+ const rowH = Math.max(1, ...wrapped.map((w) => w.length));
564
+ for (let li = 0; li < rowH; li++) {
565
+ const spans = [];
566
+ for (let c = 0; c < nCols; c++) {
567
+ if (c === 0)
568
+ spans.push({ text: "│ ", style: borderStyle });
569
+ const cellLine = wrapped[c][li]?.spans ?? [];
570
+ for (const s of cellLine) {
571
+ spans.push(isHeader ? { ...s, style: { ...s.style, bold: true } } : s);
572
+ }
573
+ const used = displayWidth(cellLine.map((s) => s.text).join(""));
574
+ const pad = Math.max(0, colW[c] - used);
575
+ let padLeft = 0;
576
+ let padRight = pad;
577
+ const a = t.align[c] ?? "left";
578
+ if (a === "center") {
579
+ padLeft = Math.floor(pad / 2);
580
+ padRight = pad - padLeft;
581
+ }
582
+ else if (a === "right") {
583
+ padLeft = pad;
584
+ padRight = 0;
585
+ }
586
+ if (padLeft > 0)
587
+ spans.push({ text: " ".repeat(padLeft), style: {} });
588
+ if (padRight > 0)
589
+ spans.push({ text: " ".repeat(padRight), style: {} });
590
+ spans.push({ text: c < nCols - 1 ? " │ " : " │", style: borderStyle });
591
+ }
592
+ out.push({ spans });
593
+ }
594
+ };
595
+ out.push({ spans: mkBorder("┌", "┬", "┐") });
596
+ renderRow(t.header, true);
597
+ out.push({ spans: mkBorder("├", "┼", "┤") });
598
+ for (const r of t.rows)
599
+ renderRow(r, false);
600
+ out.push({ spans: mkBorder("└", "┴", "┘") });
601
+ return out;
602
+ }
603
+ // ---------------------------------------------------------------------------
604
+ // Entry points
605
+ // ---------------------------------------------------------------------------
606
+ /** Parse and render a markdown string into styled lines. */
607
+ export function renderMarkdownText(text, width, base = {}) {
608
+ return renderBlocks(parseMarkdown(text, base), width);
609
+ }
610
+ /**
611
+ * Draw a sequence of styled spans onto the buffer, starting at (x, y),
612
+ * clipped to `maxWidth` columns.
613
+ */
614
+ export function drawSpans(buf, x, y, spans, maxWidth) {
615
+ let col = x;
616
+ const limit = x + maxWidth;
617
+ for (const s of spans) {
618
+ if (col >= limit)
619
+ break;
620
+ const avail = limit - col;
621
+ if (avail <= 0)
622
+ break;
623
+ const w = displayWidth(s.text);
624
+ const text = w > avail ? truncateByWidth(s.text, avail) : s.text;
625
+ if (!text)
626
+ continue;
627
+ buf.writeText(col, y, text, s.style);
628
+ col += displayWidth(text);
629
+ }
630
+ }
@@ -1,7 +1,8 @@
1
1
  import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
3
  import { drawHeader } from "../header.js";
4
- import { wrapText } from "../ui.js";
4
+ import { drawHint } from "../ui.js";
5
+ import { drawSpans, renderMarkdownText } from "../markdown.js";
5
6
  import { ellipsize } from "../width.js";
6
7
  export class AgentConversationScreen extends Screen {
7
8
  title;
@@ -47,24 +48,22 @@ export class AgentConversationScreen extends Screen {
47
48
  }
48
49
  this.app.render();
49
50
  }
51
+ else if (ev.type === "click" && ev.button === 2) {
52
+ this.app.popScreen();
53
+ }
50
54
  }
51
55
  buildLines(width) {
52
56
  const lines = [];
53
- const push = (text, style) => {
54
- for (const ln of wrapText(text, width))
55
- lines.push({ text: ln, style });
56
- };
57
57
  for (const m of this.messages) {
58
58
  if (m.role === "user") {
59
- lines.push({ text: "You:", style: { fg: C.accent, bold: true } });
60
- push(m.content, { fg: C.user });
61
- lines.push({ text: "", style: {} });
59
+ lines.push({ spans: [{ text: "You:", style: { fg: C.accent, bold: true } }] });
60
+ lines.push(...renderMarkdownText(m.content, width, { fg: C.user }));
62
61
  }
63
62
  else {
64
- lines.push({ text: "Assistant:", style: { fg: C.accent, bold: true } });
65
- push(m.content, { fg: C.assistant });
66
- lines.push({ text: "", style: {} });
63
+ lines.push({ spans: [{ text: "Assistant:", style: { fg: C.accent, bold: true } }] });
64
+ lines.push(...renderMarkdownText(m.content, width, { fg: C.assistant }));
67
65
  }
66
+ lines.push({ spans: [] });
68
67
  }
69
68
  return lines;
70
69
  }
@@ -90,9 +89,8 @@ export class AgentConversationScreen extends Screen {
90
89
  const li = this.scrollTop + (y - logTop);
91
90
  if (li >= lines.length)
92
91
  break;
93
- const line = lines[li];
94
- buf.writeText(2, y, line.text.slice(0, w - 2), line.style);
92
+ drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
95
93
  }
96
- buf.centerText(h - 1, "↑/↓ scroll · Esc back", { fg: C.dim });
94
+ drawHint(buf, h - 1, "↑/↓ scroll · wheel scroll · right-click back · Esc back");
97
95
  }
98
96
  }