codeply-cli 0.2.0 → 0.3.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 (43) hide show
  1. package/THIRD_PARTY_NOTICES.md +63 -0
  2. package/bin/codeply.js +562 -370
  3. package/lib/agent.mjs +1627 -160
  4. package/lib/ai.js +1107 -387
  5. package/lib/apply-limit.js +33 -51
  6. package/lib/apply-patch.js +217 -0
  7. package/lib/arity-table.json +138 -0
  8. package/lib/arity.js +70 -0
  9. package/lib/auth.js +118 -112
  10. package/lib/commands.js +103 -0
  11. package/lib/config.js +392 -136
  12. package/lib/design-library/artifact-template.html +500 -0
  13. package/lib/design-library/assemble-artifact.mjs +21 -0
  14. package/lib/design-library/build.mjs +226 -0
  15. package/lib/design-library/cleanup.mjs +45 -0
  16. package/lib/design-library/index.json +22380 -0
  17. package/lib/design-library/make-table-data.mjs +39 -0
  18. package/lib/design-library/query.mjs +104 -0
  19. package/lib/diagnostics.js +192 -0
  20. package/lib/edit-engine.js +171 -14
  21. package/lib/github-agent.mjs +290 -0
  22. package/lib/history.js +75 -0
  23. package/lib/mcp.js +258 -0
  24. package/lib/native-tools.mjs +189 -0
  25. package/lib/oauth-connectors.js +478 -0
  26. package/lib/permissions.js +109 -0
  27. package/lib/plugins.js +353 -0
  28. package/lib/render-md.mjs +617 -617
  29. package/lib/server.mjs +457 -0
  30. package/lib/session-db.js +220 -0
  31. package/lib/share.js +168 -0
  32. package/lib/skills.js +80 -22
  33. package/lib/snapshot.js +146 -0
  34. package/lib/subagents.js +164 -0
  35. package/lib/terminal-hints.js +154 -0
  36. package/lib/textarea.mjs +258 -258
  37. package/lib/tools.mjs +1658 -79
  38. package/lib/tui.js +7 -7
  39. package/lib/tui.mjs +1086 -992
  40. package/lib/typecheck-worker.js +147 -0
  41. package/lib/web-tools.js +158 -0
  42. package/package.json +4 -3
  43. package/skills/premium-web-design/SKILL.md +154 -0
package/lib/render-md.mjs CHANGED
@@ -1,617 +1,617 @@
1
- /**
2
- * Codeply TUI — Markdown renderer for AI responses.
3
- * Parses markdown into Ink-compatible React elements with syntax coloring.
4
- */
5
- import React from 'react';
6
- import { Box, Text } from 'ink';
7
-
8
- const G = {
9
- blue: '#4285F4',
10
- red: '#EA4335',
11
- yellow: '#FBBC05',
12
- green: '#34A853',
13
- };
14
-
15
- // Minimal syntax highlighter for code blocks
16
- function highlightCode(code, lang) {
17
- const lines = code.split('\n');
18
- return lines.map((line) => {
19
- let colored = line;
20
-
21
- // Comments
22
- if (/^\s*\/\//.test(line) || /^\s*#/.test(line) || /^\s*\/\*/.test(line) || /^\s*\*/.test(line) || /^\s*<!--/.test(line)) {
23
- return [{ text: line, color: '#5f6368' }];
24
- }
25
-
26
- // Strings (basic)
27
- const parts = [];
28
- let remaining = line;
29
- // Match strings, keywords, tags, numbers, properties
30
- const regex = /("[^"]*"|'[^']*'|`[^`]*`)|(\b(?:function|const|let|var|if|else|return|for|while|do|switch|case|break|continue|new|this|class|extends|import|export|from|default|async|await|try|catch|throw|typeof|instanceof|in|of|void|delete|null|undefined|true|false)\b)|(<\/?[a-zA-Z][a-zA-Z0-9]*(?:\s[^>]*)?>?)|(\b\d+\.?\d*\b)|(#[0-9a-fA-F]{3,8})|(\b(?:style|color|display|margin|padding|border|background|width|height|font|position|top|left|right|bottom|text|align|overflow|flex|grid|gap|opacity|transform|transition|animation|content|justify|items|center|none|block|inline|relative|absolute|fixed|solid|dashed|hidden|auto|inherit|initial|unset)\b)/g;
31
-
32
- let lastIdx = 0;
33
- let match;
34
- const result = [];
35
-
36
- while ((match = regex.exec(remaining)) !== null) {
37
- // Text before match
38
- if (match.index > lastIdx) {
39
- result.push({ text: remaining.slice(lastIdx, match.index), color: '#e8eaed' });
40
- }
41
-
42
- if (match[1]) {
43
- // String
44
- result.push({ text: match[0], color: '#34A853' });
45
- } else if (match[2]) {
46
- // Keyword
47
- result.push({ text: match[0], color: '#4285F4' });
48
- } else if (match[3]) {
49
- // HTML tag
50
- result.push({ text: match[0], color: '#EA4335' });
51
- } else if (match[4]) {
52
- // Number
53
- result.push({ text: match[0], color: '#FBBC05' });
54
- } else if (match[5]) {
55
- // Hex color
56
- result.push({ text: match[0], color: '#FBBC05' });
57
- } else if (match[6]) {
58
- // CSS property
59
- result.push({ text: match[0], color: '#e8eaed' });
60
- } else {
61
- result.push({ text: match[0], color: '#e8eaed' });
62
- }
63
- lastIdx = match.index + match[0].length;
64
- }
65
-
66
- if (lastIdx < remaining.length) {
67
- result.push({ text: remaining.slice(lastIdx), color: '#e8eaed' });
68
- }
69
-
70
- return result.length ? result : [{ text: line, color: '#e8eaed' }];
71
- });
72
- }
73
-
74
- // Convert hex color to ANSI escape sequence
75
- function hexToAnsi(hex) {
76
- if (!hex) return '';
77
- const h = hex.replace('#', '');
78
- const r = parseInt(h.substring(0, 2), 16);
79
- const g = parseInt(h.substring(2, 4), 16);
80
- const b = parseInt(h.substring(4, 6), 16);
81
- return `\x1b[38;2;${r};${g};${b}m`;
82
- }
83
-
84
- /** Background counterpart of hexToAnsi. */
85
- function hexToAnsiBg(hex) {
86
- if (!hex) return '';
87
- const h = hex.replace('#', '');
88
- const r = parseInt(h.substring(0, 2), 16);
89
- const g = parseInt(h.substring(2, 4), 16);
90
- const b = parseInt(h.substring(4, 6), 16);
91
- return `\x1b[48;2;${r};${g};${b}m`;
92
- }
93
-
94
- const ANSI_RESET = '\x1b[0m';
95
- const ANSI_BOLD = '\x1b[1m';
96
- const ANSI_DIM = '\x1b[2m';
97
- const ANSI_ITALIC = '\x1b[3m';
98
- const ANSI_BG_CODE = '\x1b[48;2;45;46;48m'; // #2d2e30
99
-
100
- // Surface colours for filled blocks. Ink 5's <Box> has no backgroundColor, so
101
- // every filled row is built by hand: set the bg, emit the content, pad out to
102
- // the panel width so the fill reaches the right edge, then reset. Padding has
103
- // to be measured on the *visible* text, which is why widths are tracked
104
- // explicitly rather than using String#padEnd on an ANSI-laden string.
105
- const SURFACE = {
106
- codeHeader: '#20242b',
107
- codeBody: '#15181c',
108
- gutter: '#1b1f24',
109
- plan: '#161b22',
110
- planHeader: '#1d2530',
111
- diffAdd: '#0e2417',
112
- diffDel: '#2a1418',
113
- };
114
-
115
- const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
116
- const visibleLength = (s) => s.replace(ANSI_PATTERN, '').length;
117
-
118
- /**
119
- * Render coloured segments clipped to `width`, returning the ANSI string and
120
- * how many visible columns it used. Clipping happens per segment so a cut never
121
- * lands inside an escape sequence.
122
- */
123
- function clipSegments(segments, width) {
124
- let out = '';
125
- let used = 0;
126
- for (const seg of segments) {
127
- if (used >= width) break;
128
- const room = width - used;
129
- const text = seg.text.length > room ? seg.text.slice(0, room) : seg.text;
130
- if (!text) continue;
131
- out += (seg.color ? hexToAnsi(seg.color) : '') + text;
132
- used += text.length;
133
- }
134
- return { text: out, used };
135
- }
136
-
137
- /** One full-width row of a filled panel. */
138
- function fillRow(content, usedWidth, width, bg) {
139
- const pad = Math.max(0, width - usedWidth);
140
- return hexToAnsiBg(bg) + content + ' '.repeat(pad) + ANSI_RESET;
141
- }
142
-
143
- function InlineText({ segments }) {
144
- // Build a single string with ANSI escape codes for styling.
145
- // This avoids creating multiple <Text> elements which Ink wraps independently.
146
- let flat = '';
147
- for (const seg of segments) {
148
- let prefix = '';
149
- let suffix = '';
150
- if (seg.bold) prefix += ANSI_BOLD;
151
- if (seg.italic) prefix += ANSI_ITALIC;
152
- if (seg.code) {
153
- prefix += hexToAnsi(G.yellow) + ANSI_BG_CODE;
154
- suffix = ANSI_RESET;
155
- flat += prefix + ` ${seg.text} ` + suffix;
156
- } else if (seg.color) {
157
- prefix += hexToAnsi(seg.color);
158
- suffix = ANSI_RESET;
159
- flat += prefix + seg.text + suffix;
160
- } else {
161
- flat += seg.text;
162
- }
163
- }
164
- return React.createElement(Text, { wrap: 'wrap' }, flat);
165
- }
166
-
167
- function parseInline(text) {
168
- const segments = [];
169
- let remaining = text;
170
-
171
- while (remaining.length > 0) {
172
- // Bold: **text** or __text__
173
- let match = remaining.match(/^(.*?)\*\*(.+?)\*\*/s);
174
- if (!match) match = remaining.match(/^(.*?)__(.+?)__/s);
175
- if (match && match[1].length < remaining.length) {
176
- if (match[1]) segments.push({ text: match[1] });
177
- segments.push({ text: match[2], bold: true });
178
- remaining = remaining.slice(match[0].length);
179
- continue;
180
- }
181
-
182
- // Italic: *text* or _text_
183
- match = remaining.match(/^(.*?)\*(.+?)\*/s);
184
- if (!match) match = remaining.match(/^(.*?)_(.+?)_/s);
185
- if (match && match[1].length < remaining.length) {
186
- if (match[1]) segments.push({ text: match[1] });
187
- segments.push({ text: match[2], italic: true });
188
- remaining = remaining.slice(match[0].length);
189
- continue;
190
- }
191
-
192
- // Inline code: `text`
193
- match = remaining.match(/^(.*?)`(.+?)`/s);
194
- if (match && match[1].length < remaining.length) {
195
- if (match[1]) segments.push({ text: match[1] });
196
- segments.push({ text: match[2], code: true });
197
- remaining = remaining.slice(match[0].length);
198
- continue;
199
- }
200
-
201
- // No more formatting
202
- segments.push({ text: remaining });
203
- break;
204
- }
205
-
206
- return segments;
207
- }
208
-
209
- const MAX_VISIBLE_LINES = 30;
210
- const DEFAULT_PANEL_WIDTH = 76;
211
-
212
- /**
213
- * A fenced block with no language that is plainly prose, not code.
214
- *
215
- * Models routinely wrap their closing summary in a bare ``` fence. Rendering
216
- * that as a code panel — line numbers, syntax colours, "15 lines" — is actively
217
- * misleading, so it gets rendered as markdown instead. Kept deliberately
218
- * conservative: any whiff of real code and it stays a code block.
219
- */
220
- function looksLikeProse(lang, code) {
221
- if (lang) return false;
222
- const lines = code.split('\n').filter((l) => l.trim());
223
- if (lines.length === 0) return false;
224
-
225
- const codeish = lines.filter((l) =>
226
- /[;{}]\s*$/.test(l) ||
227
- /^\s*(?:const|let|var|function|import|export|class|def|return|if|for|while|#include)\b/.test(l) ||
228
- /^\s*<\/?[a-zA-Z]/.test(l) ||
229
- /^\s*[\w-]+\s*:\s*[^\s].*;/.test(l)
230
- ).length;
231
- if (codeish > 0) return false;
232
-
233
- const proseish = lines.filter((l) =>
234
- /\*\*[^*]+\*\*/.test(l) || // bold
235
- /^\s*#{1,6}\s+\S/.test(l) || // heading
236
- /^\s*[-*]\s+\S/.test(l) || // bullet
237
- /^\s*\d+\.\s+\S/.test(l) // numbered
238
- ).length;
239
-
240
- return proseish / lines.length >= 0.4;
241
- }
242
-
243
- function CodeBlock({ lang, code, truncated, width }) {
244
- const allLines = code.split('\n');
245
- const totalLines = allLines.length;
246
- const shouldTruncate = truncated !== false && totalLines > MAX_VISIBLE_LINES;
247
- const visibleLines = shouldTruncate ? allLines.slice(0, MAX_VISIBLE_LINES) : allLines;
248
- const highlighted = highlightCode(visibleLines.join('\n'), lang);
249
- const langLabel = lang || 'code';
250
-
251
- const panelWidth = Math.max(24, Math.min(width || DEFAULT_PANEL_WIDTH, 120));
252
- const gutterWidth = String(visibleLines.length).length + 1;
253
- const bodyWidth = panelWidth - gutterWidth - 2;
254
-
255
- const countLabel = shouldTruncate
256
- ? `${totalLines} lines · first ${MAX_VISIBLE_LINES}`
257
- : `${totalLines} line${totalLines === 1 ? '' : 's'}`;
258
-
259
- // Header: language on the left, line count pushed to the right edge.
260
- const headLeft = ' ' + langLabel;
261
- const headGap = Math.max(1, panelWidth - headLeft.length - countLabel.length - 1);
262
- const header = fillRow(
263
- hexToAnsi(G.blue) + ANSI_BOLD + headLeft + ANSI_RESET + hexToAnsiBg(SURFACE.codeHeader) +
264
- ' '.repeat(headGap) + hexToAnsi('#6b7280') + countLabel + ' ',
265
- headLeft.length + headGap + countLabel.length + 1,
266
- panelWidth,
267
- SURFACE.codeHeader
268
- );
269
-
270
- const rows = highlighted.map((segments, i) => {
271
- const raw = visibleLines[i] ?? '';
272
- // Diff hunks get a tinted row so +/- reads at a glance.
273
- const isAdd = /^\+(?!\+)/.test(raw);
274
- const isDel = /^-(?!-)/.test(raw);
275
- const bg = isAdd ? SURFACE.diffAdd : isDel ? SURFACE.diffDel : SURFACE.codeBody;
276
-
277
- const num = String(i + 1).padStart(gutterWidth - 1);
278
- const gutter = hexToAnsiBg(SURFACE.gutter) + hexToAnsi('#4b5563') + ' ' + num + ANSI_RESET;
279
- const { text, used } = clipSegments(segments, bodyWidth);
280
- const body = hexToAnsiBg(bg) + ' ' + text;
281
-
282
- return React.createElement(
283
- Text,
284
- { key: i },
285
- gutter + fillRow(body, used + 1, panelWidth - gutterWidth, bg)
286
- );
287
- });
288
-
289
- return React.createElement(
290
- Box,
291
- { flexDirection: 'column', marginBottom: 1 },
292
- React.createElement(Text, null, header),
293
- // One <Text> per line, pre-flattened to ANSI. Ink allocates a Yoga layout
294
- // node per element, so per-token <Text> nodes make long blocks crawl.
295
- ...rows,
296
- shouldTruncate && React.createElement(
297
- Text,
298
- null,
299
- fillRow(
300
- hexToAnsiBg(SURFACE.codeHeader) + hexToAnsi('#6b7280') +
301
- ` ⋯ ${totalLines - MAX_VISIBLE_LINES} more lines`,
302
- ` ⋯ ${totalLines - MAX_VISIBLE_LINES} more lines`.length,
303
- panelWidth,
304
- SURFACE.codeHeader
305
- )
306
- )
307
- );
308
- }
309
-
310
- const ANSI_BOLD_OFF = '\x1b[22m';
311
- const ANSI_ITALIC_OFF = '\x1b[23m';
312
-
313
- /**
314
- * Style one segment without ever emitting a full reset.
315
- * ANSI_RESET would clear the panel's background mid-row and punch a hole in the
316
- * fill, so attributes are turned off individually instead.
317
- */
318
- function styleSegment(seg, defaultColor) {
319
- let out = '';
320
- if (seg.bold) out += ANSI_BOLD;
321
- if (seg.italic) out += ANSI_ITALIC;
322
- out += hexToAnsi(seg.color || defaultColor);
323
- out += seg.text;
324
- if (seg.italic) out += ANSI_ITALIC_OFF;
325
- if (seg.bold) out += ANSI_BOLD_OFF;
326
- return out;
327
- }
328
-
329
- /**
330
- * Word-wrap styled segments to `width`, returning rows of segments.
331
- * Wrapping happens on words rather than characters, and a word longer than the
332
- * whole width is hard-split so it can never overflow the panel.
333
- */
334
- function wrapStyledSegments(segments, width, nextWidth = width) {
335
- const rows = [];
336
- let row = [];
337
- let used = 0;
338
- // Continuation rows carry a hanging indent, so they have less room than the
339
- // first. Wrapping every row at the first row's width overflows the panel by
340
- // exactly the indent.
341
- let limit = width;
342
- const flush = () => { rows.push(row); row = []; used = 0; limit = nextWidth; };
343
-
344
- for (const seg of segments) {
345
- for (const word of seg.text.split(/(\s+)/)) {
346
- if (!word) continue;
347
- const isSpace = /^\s+$/.test(word);
348
- if (isSpace && used === 0) continue; // no leading space on a wrapped row
349
- if (used + word.length <= limit) {
350
- row.push({ ...seg, text: word });
351
- used += word.length;
352
- continue;
353
- }
354
- if (isSpace) { flush(); continue; } // break at the space itself
355
- if (used > 0) flush();
356
- let rest = word;
357
- while (rest.length > limit) { // pathologically long token
358
- row.push({ ...seg, text: rest.slice(0, limit) });
359
- used = limit;
360
- flush();
361
- rest = rest.slice(limit);
362
- }
363
- if (rest) { row.push({ ...seg, text: rest }); used += rest.length; }
364
- }
365
- }
366
- if (row.length || rows.length === 0) flush();
367
- return rows;
368
- }
369
-
370
- /** Turn one raw markdown line into an indent + styled segments. */
371
- function formatPanelLine(raw) {
372
- const indentMatch = raw.match(/^(\s*)/);
373
- const indent = Math.min(indentMatch ? indentMatch[1].length : 0, 12);
374
- const line = raw.slice(indent);
375
-
376
- const heading = line.match(/^(#{1,6})\s+(.*)$/);
377
- if (heading) {
378
- const level = heading[1].length;
379
- const color = level <= 2 ? G.blue : level === 3 ? G.green : G.yellow;
380
- return {
381
- indent,
382
- hanging: 0,
383
- segments: parseInline(heading[2]).map((s) => ({ ...s, bold: true, color })),
384
- };
385
- }
386
-
387
- if (/^[-*_]{3,}\s*$/.test(line)) {
388
- return { indent, hanging: 0, rule: true, segments: [] };
389
- }
390
-
391
- const bullet = line.match(/^([-*])\s+(.*)$/);
392
- if (bullet) {
393
- return {
394
- indent,
395
- hanging: 2,
396
- segments: [{ text: '• ', color: G.blue }, ...parseInline(bullet[2])],
397
- };
398
- }
399
-
400
- const numbered = line.match(/^(\d+)\.\s+(.*)$/);
401
- if (numbered) {
402
- const marker = `${numbered[1]}. `;
403
- return {
404
- indent,
405
- hanging: marker.length,
406
- segments: [{ text: marker, color: G.blue }, ...parseInline(numbered[2])],
407
- };
408
- }
409
-
410
- return { indent, hanging: 0, segments: parseInline(line) };
411
- }
412
-
413
- /**
414
- * Filled panel for a plan / reasoning block — same visual family as CodeBlock
415
- * so the transcript reads as one system, but without the line-number gutter
416
- * since prose is not addressed by line.
417
- *
418
- * Content is wrapped, never clipped: a plan is meant to be read, so losing the
419
- * end of every long sentence defeats the point.
420
- */
421
- function Panel({ title, lines, width, accent = G.yellow, bg = SURFACE.plan, headerBg = SURFACE.planHeader }) {
422
- const panelWidth = Math.max(24, Math.min(width || DEFAULT_PANEL_WIDTH, 120));
423
- const textColor = '#d5d9df';
424
-
425
- const head = ' ' + title;
426
- const header = fillRow(
427
- hexToAnsi(accent) + ANSI_BOLD + head + ANSI_BOLD_OFF,
428
- head.length,
429
- panelWidth,
430
- headerBg
431
- );
432
-
433
- const rendered = [];
434
- for (const raw of lines) {
435
- const { indent, hanging, segments, rule } = formatPanelLine(raw);
436
-
437
- if (rule) {
438
- const dash = '─'.repeat(Math.max(4, panelWidth - 4));
439
- rendered.push(hexToAnsiBg(bg) + ' ' + hexToAnsi('#3a4048') + dash);
440
- continue;
441
- }
442
- if (segments.length === 0) {
443
- rendered.push(hexToAnsiBg(bg) + ' ');
444
- continue;
445
- }
446
-
447
- const avail = Math.max(8, panelWidth - 2 - indent);
448
- const rows = wrapStyledSegments(segments, avail, Math.max(4, avail - hanging));
449
- rows.forEach((row, i) => {
450
- // Continuation rows line up under the text, not under the bullet.
451
- const pad = ' '.repeat(indent + (i > 0 ? hanging : 0));
452
- const body = row.map((s) => styleSegment(s, textColor)).join('');
453
- rendered.push(hexToAnsiBg(bg) + ' ' + pad + body);
454
- });
455
- }
456
-
457
- return React.createElement(
458
- Box,
459
- { flexDirection: 'column', marginBottom: 1 },
460
- React.createElement(Text, { key: 'h' }, header),
461
- ...rendered.map((content, i) => {
462
- const used = visibleLength(content);
463
- return React.createElement(
464
- Text,
465
- { key: i },
466
- hexToAnsiBg(bg) + content + ' '.repeat(Math.max(0, panelWidth - used)) + ANSI_RESET
467
- );
468
- })
469
- );
470
- }
471
-
472
- function Heading({ level, children }) {
473
- const color = level === 1 ? G.blue : level === 2 ? G.green : G.yellow;
474
- return React.createElement(
475
- Box,
476
- { marginBottom: 1, marginTop: level <= 2 ? 1 : 0 },
477
- React.createElement(Text, { color, bold: level <= 2, wrap: 'wrap' }, children)
478
- );
479
- }
480
-
481
- function ListItem({ children, ordered, index }) {
482
- const bullet = ordered ? `${index}. ` : ' • ';
483
- const bulletColored = hexToAnsi(G.blue) + bullet + ANSI_RESET;
484
- const segments = parseInline(children);
485
- let content = '';
486
- for (const seg of segments) {
487
- let prefix = '';
488
- if (seg.bold) prefix += ANSI_BOLD;
489
- if (seg.italic) prefix += ANSI_ITALIC;
490
- if (seg.color) prefix += hexToAnsi(seg.color);
491
- content += prefix + seg.text + (prefix ? ANSI_RESET : '');
492
- }
493
- return React.createElement(Text, { wrap: 'wrap' }, ' ' + bulletColored + content);
494
- }
495
-
496
- function HorizontalRule({ width }) {
497
- return React.createElement(
498
- Box,
499
- { marginY: 1 },
500
- React.createElement(Text, { color: '#3c4043' }, '─'.repeat(Math.max(8, Math.min(width || 50, 72))))
501
- );
502
- }
503
-
504
- function Blockquote({ children }) {
505
- return React.createElement(
506
- Box,
507
- {
508
- borderStyle: 'bold',
509
- borderTop: false,
510
- borderRight: false,
511
- borderBottom: false,
512
- borderLeftColor: G.blue,
513
- paddingLeft: 1,
514
- marginBottom: 1,
515
- },
516
- React.createElement(Text, { color: '#9aa0a6', italic: true, wrap: 'wrap' }, children)
517
- );
518
- }
519
-
520
- function renderMarkdown(md, width) {
521
- const elements = [];
522
- const lines = md.split('\n');
523
- let i = 0;
524
-
525
- while (i < lines.length) {
526
- const line = lines[i];
527
-
528
- // Code block: ```lang ... ```
529
- if (line.trim().startsWith('```')) {
530
- const lang = line.trim().slice(3).trim();
531
- const codeLines = [];
532
- i++;
533
- while (i < lines.length && !lines[i].trim().startsWith('```')) {
534
- codeLines.push(lines[i]);
535
- i++;
536
- }
537
- i++; // skip closing ```
538
- const body = codeLines.join('\n');
539
- if (looksLikeProse(lang, body)) {
540
- // A bare fence around a written summary — render it as what it is.
541
- for (const el of renderMarkdown(body, width)) {
542
- elements.push(React.cloneElement(el, { key: `fenced-${elements.length}-${el.key}` }));
543
- }
544
- } else {
545
- elements.push(React.createElement(CodeBlock, {
546
- key: `code-${elements.length}`, lang, code: body, width,
547
- }));
548
- }
549
- continue;
550
- }
551
-
552
- // Heading: # ## ###
553
- const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
554
- if (headingMatch) {
555
- elements.push(React.createElement(Heading, { key: `h-${elements.length}`, level: headingMatch[1].length }, headingMatch[2]));
556
- i++;
557
- continue;
558
- }
559
-
560
- // Horizontal rule: --- or *** or ___
561
- if (/^[-*_]{3,}\s*$/.test(line.trim())) {
562
- elements.push(React.createElement(HorizontalRule, { key: `hr-${elements.length}`, width }));
563
- i++;
564
- continue;
565
- }
566
-
567
- // Blockquote: > text
568
- if (line.trim().startsWith('>')) {
569
- elements.push(React.createElement(Blockquote, { key: `bq-${elements.length}`, children: line.trim().slice(1).trim() }));
570
- i++;
571
- continue;
572
- }
573
-
574
- // Unordered list: - or * text
575
- const listMatch = line.match(/^(\s*)([-*])\s+(.+)/);
576
- if (listMatch) {
577
- elements.push(React.createElement(ListItem, { key: `li-${elements.length}`, children: listMatch[3] }));
578
- i++;
579
- continue;
580
- }
581
-
582
- // Ordered list: 1. text
583
- const olMatch = line.match(/^(\s*)(\d+)\.\s+(.+)/);
584
- if (olMatch) {
585
- elements.push(React.createElement(ListItem, { key: `oli-${elements.length}`, children: olMatch[3], ordered: true, index: parseInt(olMatch[2]) }));
586
- i++;
587
- continue;
588
- }
589
-
590
- // Empty line
591
- if (line.trim() === '') {
592
- i++;
593
- continue;
594
- }
595
-
596
- // Paragraph: collect consecutive non-empty lines
597
- const paraLines = [];
598
- while (i < lines.length && lines[i].trim() !== '' && !lines[i].trim().startsWith('```') && !lines[i].trim().startsWith('#') && !lines[i].trim().startsWith('>') && !/^[-*]\s/.test(lines[i].trim()) && !/^\d+\.\s/.test(lines[i].trim()) && !/^[-*_]{3,}\s*$/.test(lines[i].trim())) {
599
- paraLines.push(lines[i]);
600
- i++;
601
- }
602
- if (paraLines.length) {
603
- elements.push(
604
- React.createElement(InlineText, { key: `p-${elements.length}`, segments: parseInline(paraLines.join(' ')) })
605
- );
606
- }
607
- }
608
-
609
- return elements;
610
- }
611
-
612
- export {
613
- renderMarkdown, CodeBlock, Panel, parseInline, looksLikeProse,
614
- hexToAnsi, hexToAnsiBg, fillRow, clipSegments, visibleLength, SURFACE,
615
- ANSI_RESET, ANSI_BOLD, ANSI_DIM, ANSI_ITALIC,
616
- };
617
- export default renderMarkdown;
1
+ /**
2
+ * Codeply TUI - Markdown renderer for AI responses.
3
+ * Parses markdown into Ink-compatible React elements with syntax coloring.
4
+ */
5
+ import React from 'react';
6
+ import { Box, Text } from 'ink';
7
+
8
+ const G = {
9
+ blue: '#4285F4',
10
+ red: '#EA4335',
11
+ yellow: '#FBBC05',
12
+ green: '#34A853',
13
+ };
14
+
15
+ // Minimal syntax highlighter for code blocks
16
+ function highlightCode(code, lang) {
17
+ const lines = code.split('\n');
18
+ return lines.map((line) => {
19
+ let colored = line;
20
+
21
+ // Comments
22
+ if (/^\s*\/\//.test(line) || /^\s*#/.test(line) || /^\s*\/\*/.test(line) || /^\s*\*/.test(line) || /^\s*<!--/.test(line)) {
23
+ return [{ text: line, color: '#5f6368' }];
24
+ }
25
+
26
+ // Strings (basic)
27
+ const parts = [];
28
+ let remaining = line;
29
+ // Match strings, keywords, tags, numbers, properties
30
+ const regex = /("[^"]*"|'[^']*'|`[^`]*`)|(\b(?:function|const|let|var|if|else|return|for|while|do|switch|case|break|continue|new|this|class|extends|import|export|from|default|async|await|try|catch|throw|typeof|instanceof|in|of|void|delete|null|undefined|true|false)\b)|(<\/?[a-zA-Z][a-zA-Z0-9]*(?:\s[^>]*)?>?)|(\b\d+\.?\d*\b)|(#[0-9a-fA-F]{3,8})|(\b(?:style|color|display|margin|padding|border|background|width|height|font|position|top|left|right|bottom|text|align|overflow|flex|grid|gap|opacity|transform|transition|animation|content|justify|items|center|none|block|inline|relative|absolute|fixed|solid|dashed|hidden|auto|inherit|initial|unset)\b)/g;
31
+
32
+ let lastIdx = 0;
33
+ let match;
34
+ const result = [];
35
+
36
+ while ((match = regex.exec(remaining)) !== null) {
37
+ // Text before match
38
+ if (match.index > lastIdx) {
39
+ result.push({ text: remaining.slice(lastIdx, match.index), color: '#e8eaed' });
40
+ }
41
+
42
+ if (match[1]) {
43
+ // String
44
+ result.push({ text: match[0], color: '#34A853' });
45
+ } else if (match[2]) {
46
+ // Keyword
47
+ result.push({ text: match[0], color: '#4285F4' });
48
+ } else if (match[3]) {
49
+ // HTML tag
50
+ result.push({ text: match[0], color: '#EA4335' });
51
+ } else if (match[4]) {
52
+ // Number
53
+ result.push({ text: match[0], color: '#FBBC05' });
54
+ } else if (match[5]) {
55
+ // Hex color
56
+ result.push({ text: match[0], color: '#FBBC05' });
57
+ } else if (match[6]) {
58
+ // CSS property
59
+ result.push({ text: match[0], color: '#e8eaed' });
60
+ } else {
61
+ result.push({ text: match[0], color: '#e8eaed' });
62
+ }
63
+ lastIdx = match.index + match[0].length;
64
+ }
65
+
66
+ if (lastIdx < remaining.length) {
67
+ result.push({ text: remaining.slice(lastIdx), color: '#e8eaed' });
68
+ }
69
+
70
+ return result.length ? result : [{ text: line, color: '#e8eaed' }];
71
+ });
72
+ }
73
+
74
+ // Convert hex color to ANSI escape sequence
75
+ function hexToAnsi(hex) {
76
+ if (!hex) return '';
77
+ const h = hex.replace('#', '');
78
+ const r = parseInt(h.substring(0, 2), 16);
79
+ const g = parseInt(h.substring(2, 4), 16);
80
+ const b = parseInt(h.substring(4, 6), 16);
81
+ return `\x1b[38;2;${r};${g};${b}m`;
82
+ }
83
+
84
+ /** Background counterpart of hexToAnsi. */
85
+ function hexToAnsiBg(hex) {
86
+ if (!hex) return '';
87
+ const h = hex.replace('#', '');
88
+ const r = parseInt(h.substring(0, 2), 16);
89
+ const g = parseInt(h.substring(2, 4), 16);
90
+ const b = parseInt(h.substring(4, 6), 16);
91
+ return `\x1b[48;2;${r};${g};${b}m`;
92
+ }
93
+
94
+ const ANSI_RESET = '\x1b[0m';
95
+ const ANSI_BOLD = '\x1b[1m';
96
+ const ANSI_DIM = '\x1b[2m';
97
+ const ANSI_ITALIC = '\x1b[3m';
98
+ const ANSI_BG_CODE = '\x1b[48;2;45;46;48m'; // #2d2e30
99
+
100
+ // Surface colours for filled blocks. Ink 5's <Box> has no backgroundColor, so
101
+ // every filled row is built by hand: set the bg, emit the content, pad out to
102
+ // the panel width so the fill reaches the right edge, then reset. Padding has
103
+ // to be measured on the *visible* text, which is why widths are tracked
104
+ // explicitly rather than using String#padEnd on an ANSI-laden string.
105
+ const SURFACE = {
106
+ codeHeader: '#20242b',
107
+ codeBody: '#15181c',
108
+ gutter: '#1b1f24',
109
+ plan: '#161b22',
110
+ planHeader: '#1d2530',
111
+ diffAdd: '#0e2417',
112
+ diffDel: '#2a1418',
113
+ };
114
+
115
+ const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
116
+ const visibleLength = (s) => s.replace(ANSI_PATTERN, '').length;
117
+
118
+ /**
119
+ * Render coloured segments clipped to `width`, returning the ANSI string and
120
+ * how many visible columns it used. Clipping happens per segment so a cut never
121
+ * lands inside an escape sequence.
122
+ */
123
+ function clipSegments(segments, width) {
124
+ let out = '';
125
+ let used = 0;
126
+ for (const seg of segments) {
127
+ if (used >= width) break;
128
+ const room = width - used;
129
+ const text = seg.text.length > room ? seg.text.slice(0, room) : seg.text;
130
+ if (!text) continue;
131
+ out += (seg.color ? hexToAnsi(seg.color) : '') + text;
132
+ used += text.length;
133
+ }
134
+ return { text: out, used };
135
+ }
136
+
137
+ /** One full-width row of a filled panel. */
138
+ function fillRow(content, usedWidth, width, bg) {
139
+ const pad = Math.max(0, width - usedWidth);
140
+ return hexToAnsiBg(bg) + content + ' '.repeat(pad) + ANSI_RESET;
141
+ }
142
+
143
+ function InlineText({ segments }) {
144
+ // Build a single string with ANSI escape codes for styling.
145
+ // This avoids creating multiple <Text> elements which Ink wraps independently.
146
+ let flat = '';
147
+ for (const seg of segments) {
148
+ let prefix = '';
149
+ let suffix = '';
150
+ if (seg.bold) prefix += ANSI_BOLD;
151
+ if (seg.italic) prefix += ANSI_ITALIC;
152
+ if (seg.code) {
153
+ prefix += hexToAnsi(G.yellow) + ANSI_BG_CODE;
154
+ suffix = ANSI_RESET;
155
+ flat += prefix + ` ${seg.text} ` + suffix;
156
+ } else if (seg.color) {
157
+ prefix += hexToAnsi(seg.color);
158
+ suffix = ANSI_RESET;
159
+ flat += prefix + seg.text + suffix;
160
+ } else {
161
+ flat += seg.text;
162
+ }
163
+ }
164
+ return React.createElement(Text, { wrap: 'wrap' }, flat);
165
+ }
166
+
167
+ function parseInline(text) {
168
+ const segments = [];
169
+ let remaining = text;
170
+
171
+ while (remaining.length > 0) {
172
+ // Bold: **text** or __text__
173
+ let match = remaining.match(/^(.*?)\*\*(.+?)\*\*/s);
174
+ if (!match) match = remaining.match(/^(.*?)__(.+?)__/s);
175
+ if (match && match[1].length < remaining.length) {
176
+ if (match[1]) segments.push({ text: match[1] });
177
+ segments.push({ text: match[2], bold: true });
178
+ remaining = remaining.slice(match[0].length);
179
+ continue;
180
+ }
181
+
182
+ // Italic: *text* or _text_
183
+ match = remaining.match(/^(.*?)\*(.+?)\*/s);
184
+ if (!match) match = remaining.match(/^(.*?)_(.+?)_/s);
185
+ if (match && match[1].length < remaining.length) {
186
+ if (match[1]) segments.push({ text: match[1] });
187
+ segments.push({ text: match[2], italic: true });
188
+ remaining = remaining.slice(match[0].length);
189
+ continue;
190
+ }
191
+
192
+ // Inline code: `text`
193
+ match = remaining.match(/^(.*?)`(.+?)`/s);
194
+ if (match && match[1].length < remaining.length) {
195
+ if (match[1]) segments.push({ text: match[1] });
196
+ segments.push({ text: match[2], code: true });
197
+ remaining = remaining.slice(match[0].length);
198
+ continue;
199
+ }
200
+
201
+ // No more formatting
202
+ segments.push({ text: remaining });
203
+ break;
204
+ }
205
+
206
+ return segments;
207
+ }
208
+
209
+ const MAX_VISIBLE_LINES = 30;
210
+ const DEFAULT_PANEL_WIDTH = 76;
211
+
212
+ /**
213
+ * A fenced block with no language that is plainly prose, not code.
214
+ *
215
+ * Models routinely wrap their closing summary in a bare ``` fence. Rendering
216
+ * that as a code panel - line numbers, syntax colours, "15 lines" - is actively
217
+ * misleading, so it gets rendered as markdown instead. Kept deliberately
218
+ * conservative: any whiff of real code and it stays a code block.
219
+ */
220
+ function looksLikeProse(lang, code) {
221
+ if (lang) return false;
222
+ const lines = code.split('\n').filter((l) => l.trim());
223
+ if (lines.length === 0) return false;
224
+
225
+ const codeish = lines.filter((l) =>
226
+ /[;{}]\s*$/.test(l) ||
227
+ /^\s*(?:const|let|var|function|import|export|class|def|return|if|for|while|#include)\b/.test(l) ||
228
+ /^\s*<\/?[a-zA-Z]/.test(l) ||
229
+ /^\s*[\w-]+\s*:\s*[^\s].*;/.test(l)
230
+ ).length;
231
+ if (codeish > 0) return false;
232
+
233
+ const proseish = lines.filter((l) =>
234
+ /\*\*[^*]+\*\*/.test(l) || // bold
235
+ /^\s*#{1,6}\s+\S/.test(l) || // heading
236
+ /^\s*[-*]\s+\S/.test(l) || // bullet
237
+ /^\s*\d+\.\s+\S/.test(l) // numbered
238
+ ).length;
239
+
240
+ return proseish / lines.length >= 0.4;
241
+ }
242
+
243
+ function CodeBlock({ lang, code, truncated, width }) {
244
+ const allLines = code.split('\n');
245
+ const totalLines = allLines.length;
246
+ const shouldTruncate = truncated !== false && totalLines > MAX_VISIBLE_LINES;
247
+ const visibleLines = shouldTruncate ? allLines.slice(0, MAX_VISIBLE_LINES) : allLines;
248
+ const highlighted = highlightCode(visibleLines.join('\n'), lang);
249
+ const langLabel = lang || 'code';
250
+
251
+ const panelWidth = Math.max(24, Math.min(width || DEFAULT_PANEL_WIDTH, 120));
252
+ const gutterWidth = String(visibleLines.length).length + 1;
253
+ const bodyWidth = panelWidth - gutterWidth - 2;
254
+
255
+ const countLabel = shouldTruncate
256
+ ? `${totalLines} lines · first ${MAX_VISIBLE_LINES}`
257
+ : `${totalLines} line${totalLines === 1 ? '' : 's'}`;
258
+
259
+ // Header: language on the left, line count pushed to the right edge.
260
+ const headLeft = ' ' + langLabel;
261
+ const headGap = Math.max(1, panelWidth - headLeft.length - countLabel.length - 1);
262
+ const header = fillRow(
263
+ hexToAnsi(G.blue) + ANSI_BOLD + headLeft + ANSI_RESET + hexToAnsiBg(SURFACE.codeHeader) +
264
+ ' '.repeat(headGap) + hexToAnsi('#6b7280') + countLabel + ' ',
265
+ headLeft.length + headGap + countLabel.length + 1,
266
+ panelWidth,
267
+ SURFACE.codeHeader
268
+ );
269
+
270
+ const rows = highlighted.map((segments, i) => {
271
+ const raw = visibleLines[i] ?? '';
272
+ // Diff hunks get a tinted row so +/- reads at a glance.
273
+ const isAdd = /^\+(?!\+)/.test(raw);
274
+ const isDel = /^-(?!-)/.test(raw);
275
+ const bg = isAdd ? SURFACE.diffAdd : isDel ? SURFACE.diffDel : SURFACE.codeBody;
276
+
277
+ const num = String(i + 1).padStart(gutterWidth - 1);
278
+ const gutter = hexToAnsiBg(SURFACE.gutter) + hexToAnsi('#4b5563') + ' ' + num + ANSI_RESET;
279
+ const { text, used } = clipSegments(segments, bodyWidth);
280
+ const body = hexToAnsiBg(bg) + ' ' + text;
281
+
282
+ return React.createElement(
283
+ Text,
284
+ { key: i },
285
+ gutter + fillRow(body, used + 1, panelWidth - gutterWidth, bg)
286
+ );
287
+ });
288
+
289
+ return React.createElement(
290
+ Box,
291
+ { flexDirection: 'column', marginBottom: 1 },
292
+ React.createElement(Text, null, header),
293
+ // One <Text> per line, pre-flattened to ANSI. Ink allocates a Yoga layout
294
+ // node per element, so per-token <Text> nodes make long blocks crawl.
295
+ ...rows,
296
+ shouldTruncate && React.createElement(
297
+ Text,
298
+ null,
299
+ fillRow(
300
+ hexToAnsiBg(SURFACE.codeHeader) + hexToAnsi('#6b7280') +
301
+ ` ⋯ ${totalLines - MAX_VISIBLE_LINES} more lines`,
302
+ ` ⋯ ${totalLines - MAX_VISIBLE_LINES} more lines`.length,
303
+ panelWidth,
304
+ SURFACE.codeHeader
305
+ )
306
+ )
307
+ );
308
+ }
309
+
310
+ const ANSI_BOLD_OFF = '\x1b[22m';
311
+ const ANSI_ITALIC_OFF = '\x1b[23m';
312
+
313
+ /**
314
+ * Style one segment without ever emitting a full reset.
315
+ * ANSI_RESET would clear the panel's background mid-row and punch a hole in the
316
+ * fill, so attributes are turned off individually instead.
317
+ */
318
+ function styleSegment(seg, defaultColor) {
319
+ let out = '';
320
+ if (seg.bold) out += ANSI_BOLD;
321
+ if (seg.italic) out += ANSI_ITALIC;
322
+ out += hexToAnsi(seg.color || defaultColor);
323
+ out += seg.text;
324
+ if (seg.italic) out += ANSI_ITALIC_OFF;
325
+ if (seg.bold) out += ANSI_BOLD_OFF;
326
+ return out;
327
+ }
328
+
329
+ /**
330
+ * Word-wrap styled segments to `width`, returning rows of segments.
331
+ * Wrapping happens on words rather than characters, and a word longer than the
332
+ * whole width is hard-split so it can never overflow the panel.
333
+ */
334
+ function wrapStyledSegments(segments, width, nextWidth = width) {
335
+ const rows = [];
336
+ let row = [];
337
+ let used = 0;
338
+ // Continuation rows carry a hanging indent, so they have less room than the
339
+ // first. Wrapping every row at the first row's width overflows the panel by
340
+ // exactly the indent.
341
+ let limit = width;
342
+ const flush = () => { rows.push(row); row = []; used = 0; limit = nextWidth; };
343
+
344
+ for (const seg of segments) {
345
+ for (const word of seg.text.split(/(\s+)/)) {
346
+ if (!word) continue;
347
+ const isSpace = /^\s+$/.test(word);
348
+ if (isSpace && used === 0) continue; // no leading space on a wrapped row
349
+ if (used + word.length <= limit) {
350
+ row.push({ ...seg, text: word });
351
+ used += word.length;
352
+ continue;
353
+ }
354
+ if (isSpace) { flush(); continue; } // break at the space itself
355
+ if (used > 0) flush();
356
+ let rest = word;
357
+ while (rest.length > limit) { // pathologically long token
358
+ row.push({ ...seg, text: rest.slice(0, limit) });
359
+ used = limit;
360
+ flush();
361
+ rest = rest.slice(limit);
362
+ }
363
+ if (rest) { row.push({ ...seg, text: rest }); used += rest.length; }
364
+ }
365
+ }
366
+ if (row.length || rows.length === 0) flush();
367
+ return rows;
368
+ }
369
+
370
+ /** Turn one raw markdown line into an indent + styled segments. */
371
+ function formatPanelLine(raw) {
372
+ const indentMatch = raw.match(/^(\s*)/);
373
+ const indent = Math.min(indentMatch ? indentMatch[1].length : 0, 12);
374
+ const line = raw.slice(indent);
375
+
376
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
377
+ if (heading) {
378
+ const level = heading[1].length;
379
+ const color = level <= 2 ? G.blue : level === 3 ? G.green : G.yellow;
380
+ return {
381
+ indent,
382
+ hanging: 0,
383
+ segments: parseInline(heading[2]).map((s) => ({ ...s, bold: true, color })),
384
+ };
385
+ }
386
+
387
+ if (/^[-*_]{3,}\s*$/.test(line)) {
388
+ return { indent, hanging: 0, rule: true, segments: [] };
389
+ }
390
+
391
+ const bullet = line.match(/^([-*])\s+(.*)$/);
392
+ if (bullet) {
393
+ return {
394
+ indent,
395
+ hanging: 2,
396
+ segments: [{ text: '• ', color: G.blue }, ...parseInline(bullet[2])],
397
+ };
398
+ }
399
+
400
+ const numbered = line.match(/^(\d+)\.\s+(.*)$/);
401
+ if (numbered) {
402
+ const marker = `${numbered[1]}. `;
403
+ return {
404
+ indent,
405
+ hanging: marker.length,
406
+ segments: [{ text: marker, color: G.blue }, ...parseInline(numbered[2])],
407
+ };
408
+ }
409
+
410
+ return { indent, hanging: 0, segments: parseInline(line) };
411
+ }
412
+
413
+ /**
414
+ * Filled panel for a plan / reasoning block - same visual family as CodeBlock
415
+ * so the transcript reads as one system, but without the line-number gutter
416
+ * since prose is not addressed by line.
417
+ *
418
+ * Content is wrapped, never clipped: a plan is meant to be read, so losing the
419
+ * end of every long sentence defeats the point.
420
+ */
421
+ function Panel({ title, lines, width, accent = G.yellow, bg = SURFACE.plan, headerBg = SURFACE.planHeader }) {
422
+ const panelWidth = Math.max(24, Math.min(width || DEFAULT_PANEL_WIDTH, 120));
423
+ const textColor = '#d5d9df';
424
+
425
+ const head = ' ' + title;
426
+ const header = fillRow(
427
+ hexToAnsi(accent) + ANSI_BOLD + head + ANSI_BOLD_OFF,
428
+ head.length,
429
+ panelWidth,
430
+ headerBg
431
+ );
432
+
433
+ const rendered = [];
434
+ for (const raw of lines) {
435
+ const { indent, hanging, segments, rule } = formatPanelLine(raw);
436
+
437
+ if (rule) {
438
+ const dash = '─'.repeat(Math.max(4, panelWidth - 4));
439
+ rendered.push(hexToAnsiBg(bg) + ' ' + hexToAnsi('#3a4048') + dash);
440
+ continue;
441
+ }
442
+ if (segments.length === 0) {
443
+ rendered.push(hexToAnsiBg(bg) + ' ');
444
+ continue;
445
+ }
446
+
447
+ const avail = Math.max(8, panelWidth - 2 - indent);
448
+ const rows = wrapStyledSegments(segments, avail, Math.max(4, avail - hanging));
449
+ rows.forEach((row, i) => {
450
+ // Continuation rows line up under the text, not under the bullet.
451
+ const pad = ' '.repeat(indent + (i > 0 ? hanging : 0));
452
+ const body = row.map((s) => styleSegment(s, textColor)).join('');
453
+ rendered.push(hexToAnsiBg(bg) + ' ' + pad + body);
454
+ });
455
+ }
456
+
457
+ return React.createElement(
458
+ Box,
459
+ { flexDirection: 'column', marginBottom: 1 },
460
+ React.createElement(Text, { key: 'h' }, header),
461
+ ...rendered.map((content, i) => {
462
+ const used = visibleLength(content);
463
+ return React.createElement(
464
+ Text,
465
+ { key: i },
466
+ hexToAnsiBg(bg) + content + ' '.repeat(Math.max(0, panelWidth - used)) + ANSI_RESET
467
+ );
468
+ })
469
+ );
470
+ }
471
+
472
+ function Heading({ level, children }) {
473
+ const color = level === 1 ? G.blue : level === 2 ? G.green : G.yellow;
474
+ return React.createElement(
475
+ Box,
476
+ { marginBottom: 1, marginTop: level <= 2 ? 1 : 0 },
477
+ React.createElement(Text, { color, bold: level <= 2, wrap: 'wrap' }, children)
478
+ );
479
+ }
480
+
481
+ function ListItem({ children, ordered, index }) {
482
+ const bullet = ordered ? `${index}. ` : ' • ';
483
+ const bulletColored = hexToAnsi(G.blue) + bullet + ANSI_RESET;
484
+ const segments = parseInline(children);
485
+ let content = '';
486
+ for (const seg of segments) {
487
+ let prefix = '';
488
+ if (seg.bold) prefix += ANSI_BOLD;
489
+ if (seg.italic) prefix += ANSI_ITALIC;
490
+ if (seg.color) prefix += hexToAnsi(seg.color);
491
+ content += prefix + seg.text + (prefix ? ANSI_RESET : '');
492
+ }
493
+ return React.createElement(Text, { wrap: 'wrap' }, ' ' + bulletColored + content);
494
+ }
495
+
496
+ function HorizontalRule({ width }) {
497
+ return React.createElement(
498
+ Box,
499
+ { marginY: 1 },
500
+ React.createElement(Text, { color: '#3c4043' }, '─'.repeat(Math.max(8, Math.min(width || 50, 72))))
501
+ );
502
+ }
503
+
504
+ function Blockquote({ children }) {
505
+ return React.createElement(
506
+ Box,
507
+ {
508
+ borderStyle: 'bold',
509
+ borderTop: false,
510
+ borderRight: false,
511
+ borderBottom: false,
512
+ borderLeftColor: G.blue,
513
+ paddingLeft: 1,
514
+ marginBottom: 1,
515
+ },
516
+ React.createElement(Text, { color: '#9aa0a6', italic: true, wrap: 'wrap' }, children)
517
+ );
518
+ }
519
+
520
+ function renderMarkdown(md, width) {
521
+ const elements = [];
522
+ const lines = md.split('\n');
523
+ let i = 0;
524
+
525
+ while (i < lines.length) {
526
+ const line = lines[i];
527
+
528
+ // Code block: ```lang ... ```
529
+ if (line.trim().startsWith('```')) {
530
+ const lang = line.trim().slice(3).trim();
531
+ const codeLines = [];
532
+ i++;
533
+ while (i < lines.length && !lines[i].trim().startsWith('```')) {
534
+ codeLines.push(lines[i]);
535
+ i++;
536
+ }
537
+ i++; // skip closing ```
538
+ const body = codeLines.join('\n');
539
+ if (looksLikeProse(lang, body)) {
540
+ // A bare fence around a written summary - render it as what it is.
541
+ for (const el of renderMarkdown(body, width)) {
542
+ elements.push(React.cloneElement(el, { key: `fenced-${elements.length}-${el.key}` }));
543
+ }
544
+ } else {
545
+ elements.push(React.createElement(CodeBlock, {
546
+ key: `code-${elements.length}`, lang, code: body, width,
547
+ }));
548
+ }
549
+ continue;
550
+ }
551
+
552
+ // Heading: # ## ###
553
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
554
+ if (headingMatch) {
555
+ elements.push(React.createElement(Heading, { key: `h-${elements.length}`, level: headingMatch[1].length }, headingMatch[2]));
556
+ i++;
557
+ continue;
558
+ }
559
+
560
+ // Horizontal rule: --- or *** or ___
561
+ if (/^[-*_]{3,}\s*$/.test(line.trim())) {
562
+ elements.push(React.createElement(HorizontalRule, { key: `hr-${elements.length}`, width }));
563
+ i++;
564
+ continue;
565
+ }
566
+
567
+ // Blockquote: > text
568
+ if (line.trim().startsWith('>')) {
569
+ elements.push(React.createElement(Blockquote, { key: `bq-${elements.length}`, children: line.trim().slice(1).trim() }));
570
+ i++;
571
+ continue;
572
+ }
573
+
574
+ // Unordered list: - or * text
575
+ const listMatch = line.match(/^(\s*)([-*])\s+(.+)/);
576
+ if (listMatch) {
577
+ elements.push(React.createElement(ListItem, { key: `li-${elements.length}`, children: listMatch[3] }));
578
+ i++;
579
+ continue;
580
+ }
581
+
582
+ // Ordered list: 1. text
583
+ const olMatch = line.match(/^(\s*)(\d+)\.\s+(.+)/);
584
+ if (olMatch) {
585
+ elements.push(React.createElement(ListItem, { key: `oli-${elements.length}`, children: olMatch[3], ordered: true, index: parseInt(olMatch[2]) }));
586
+ i++;
587
+ continue;
588
+ }
589
+
590
+ // Empty line
591
+ if (line.trim() === '') {
592
+ i++;
593
+ continue;
594
+ }
595
+
596
+ // Paragraph: collect consecutive non-empty lines
597
+ const paraLines = [];
598
+ while (i < lines.length && lines[i].trim() !== '' && !lines[i].trim().startsWith('```') && !lines[i].trim().startsWith('#') && !lines[i].trim().startsWith('>') && !/^[-*]\s/.test(lines[i].trim()) && !/^\d+\.\s/.test(lines[i].trim()) && !/^[-*_]{3,}\s*$/.test(lines[i].trim())) {
599
+ paraLines.push(lines[i]);
600
+ i++;
601
+ }
602
+ if (paraLines.length) {
603
+ elements.push(
604
+ React.createElement(InlineText, { key: `p-${elements.length}`, segments: parseInline(paraLines.join(' ')) })
605
+ );
606
+ }
607
+ }
608
+
609
+ return elements;
610
+ }
611
+
612
+ export {
613
+ renderMarkdown, CodeBlock, Panel, parseInline, looksLikeProse,
614
+ hexToAnsi, hexToAnsiBg, fillRow, clipSegments, visibleLength, SURFACE,
615
+ ANSI_RESET, ANSI_BOLD, ANSI_DIM, ANSI_ITALIC,
616
+ };
617
+ export default renderMarkdown;