spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/tui/markdown/render.ts4 * Description: Settled-block markdown renderer — headings, inline styles, fenced code, lists, tables, quotes, rules.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { highlightLine } from "./highlight.js";11import type { StyleRole, Theme } from "../theme.js";1213interface InlineSpan {14 text: string;15 roles: StyleRole[];16}1718const FENCE_RE = /^ {0,3}(```+|~~~+)\s*(\S+)?\s*$/;19const HEADING_RE = /^(#{1,6})\s+(.*)$/;20const HR_RE = /^ {0,3}(?:-{3,}|_{3,}|\*{3,})\s*$/;21const UL_RE = /^(\s*)([-*+])\s+(.*)$/;22const OL_RE = /^(\s*)(\d+)[.)]\s+(.*)$/;23const QUOTE_RE = /^ {0,3}>\s?(.*)$/;2425/**26 * Render one settled markdown block to styled terminal lines. Called exactly27 * once per block (TUI_DESIGN §8) — output is printed to scrollback and never28 * touched again. Pure: (source, width, theme) → lines.29 */30export function renderMarkdownBlock(source: string, width: number, theme: Theme): string[] {31 const w = Math.max(20, width);32 const lines = source.split("\n");33 const out: string[] = [];3435 let i = 0;36 while (i < lines.length) {37 const line = lines[i] as string;3839 const fence = FENCE_RE.exec(line);40 if (fence) {41 const lang = fence[2] ?? null;42 const body: string[] = [];43 i += 1;44 while (i < lines.length && !FENCE_RE.exec(lines[i] as string)) {45 body.push(lines[i] as string);46 i += 1;47 }48 i += 1; // closing fence (or end)49 out.push(...renderCodeBlock(body, lang, w, theme));50 continue;51 }5253 const heading = HEADING_RE.exec(line);54 if (heading) {55 const level = (heading[1] as string).length;56 const raw = heading[2] as string;57 const text = renderInline(parseInline(raw), theme);58 if (out.length > 0) out.push("");59 // H1 gets the brand gradient when it carries no inline markup;60 // gradients only ever run over plain text (never over escapes).61 if (level === 1 && !/[`*[]/.test(raw)) {62 out.push(theme.paint("bold", theme.paintGradient("brand", raw)));63 } else if (level <= 2) {64 out.push(theme.paint("bold", theme.paint("accent", text)));65 } else {66 out.push(theme.paint("bold", text));67 }68 i += 1;69 continue;70 }7172 if (HR_RE.test(line) && line.trim().length >= 3) {73 out.push(theme.paint("dim", "─".repeat(Math.min(w, 60))));74 i += 1;75 continue;76 }7778 const quote = QUOTE_RE.exec(line);79 if (quote) {80 const quoted: string[] = [];81 while (i < lines.length) {82 const q = QUOTE_RE.exec(lines[i] as string);83 if (!q) break;84 quoted.push(q[1] as string);85 i += 1;86 }87 const inner = wrapSpans(parseInline(quoted.join(" ")), w - 2);88 for (const spanLine of inner) {89 out.push(theme.paint("dim", "│ ") + renderInline(spanLine, theme, "dim"));90 }91 continue;92 }9394 const ul = UL_RE.exec(line);95 const ol = OL_RE.exec(line);96 if (ul || ol) {97 const indent = ((ul ?? ol) as RegExpExecArray)[1] as string;98 const marker = ul ? "•" : `${(ol as RegExpExecArray)[2] as string}.`;99 const body = ((ul ?? ol) as RegExpExecArray)[3] as string;100 const pad = " ".repeat(Math.min(indent.length, 8));101 const head = `${pad}${theme.paint("dim", marker)} `;102 const hang = " ".repeat(pad.length + [...marker].length + 1);103 const wrapped = wrapSpans(parseInline(body), w - hang.length);104 wrapped.forEach((spanLine, idx) => {105 out.push((idx === 0 ? head : hang) + renderInline(spanLine, theme));106 });107 i += 1;108 continue;109 }110111 if (line.includes("|") && isTableRow(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1] as string)) {112 const rows: string[] = [line];113 i += 1; // separator consumed below114 const separator = lines[i] as string;115 i += 1;116 while (i < lines.length && isTableRow(lines[i] as string)) {117 rows.push(lines[i] as string);118 i += 1;119 }120 out.push(...renderTable(rows, separator, w, theme));121 continue;122 }123124 if (line.trim() === "") {125 out.push("");126 i += 1;127 continue;128 }129130 // Paragraph: merge consecutive plain lines, wrap once.131 const para: string[] = [line];132 i += 1;133 while (i < lines.length && isPlainParagraphLine(lines[i] as string)) {134 para.push(lines[i] as string);135 i += 1;136 }137 for (const spanLine of wrapSpans(parseInline(para.join(" ")), w)) {138 out.push(renderInline(spanLine, theme));139 }140 }141142 return out;143}144145/**146 * Cheap styling for the live raw tail (TUI_DESIGN §8 step 1): inline code and147 * bold get regex styling, nothing structural. Pure per-line.148 */149export function renderTailLine(line: string, theme: Theme): string {150 return line151 .replace(/`([^`]+)`/g, (_m, code: string) => theme.paint("code", code))152 .replace(/\*\*([^*]+)\*\*/g, (_m, b: string) => theme.paint("bold", b));153}154155// ───────────────────────────── internals ─────────────────────────────156157function isPlainParagraphLine(line: string): boolean {158 return (159 line.trim() !== "" &&160 !FENCE_RE.test(line) &&161 !HEADING_RE.test(line) &&162 !HR_RE.test(line) &&163 !UL_RE.test(line) &&164 !OL_RE.test(line) &&165 !QUOTE_RE.test(line) &&166 !isTableRow(line)167 );168}169170function isTableRow(line: string): boolean {171 const t = line.trim();172 return t.startsWith("|") && t.endsWith("|") && t.length > 2;173}174175function isTableSeparator(line: string): boolean {176 const t = line.trim();177 return isTableRow(line) && /^\|(?:\s*:?-+:?\s*\|)+$/.test(t);178}179180function splitCells(row: string): string[] {181 const t = row.trim().replace(/^\|/, "").replace(/\|$/, "");182 return t.split("|").map((c) => c.trim());183}184185/** Tables render as aligned plain columns; degrade further when too wide (§8). */186function renderTable(rows: string[], _separator: string, width: number, theme: Theme): string[] {187 const parsed = rows.map(splitCells);188 const cols = Math.max(...parsed.map((r) => r.length));189 const widths: number[] = [];190 for (let c = 0; c < cols; c++) {191 widths.push(Math.max(...parsed.map((r) => [...(r[c] ?? "")].length)));192 }193 const total = widths.reduce((a, b) => a + b, 0) + (cols - 1) * 3;194 if (total > width) {195 // Too wide: plain row-per-line degradation, no alignment games.196 return parsed.map((r, idx) => {197 const text = r.join(theme.paint("dim", " · "));198 return idx === 0 ? theme.paint("bold", text) : text;199 });200 }201 const out: string[] = [];202 parsed.forEach((r, idx) => {203 const cells = r.map((cell, c) => cell.padEnd(widths[c] ?? 0));204 const rowText = cells.join(theme.paint("dim", " │ "));205 out.push(idx === 0 ? theme.paint("bold", rowText) : rowText);206 if (idx === 0) {207 out.push(theme.paint("dim", widths.map((cw) => "─".repeat(cw)).join("─┼─")));208 }209 });210 return out;211}212213/**214 * Fenced code: dim `│` gutter (no background fills that poison copied text),215 * per-line syntax highlighting, hard wrap with a dim `↪` continuation marker.216 */217function renderCodeBlock(body: string[], lang: string | null, width: number, theme: Theme): string[] {218 const gutter = theme.paint("dim", "│ ");219 const contWidth = Math.max(8, width - 4);220 const out: string[] = [];221 for (const raw of body) {222 const chunks: { text: string; first: boolean }[] = [];223 if ([...raw].length <= contWidth) {224 chunks.push({ text: raw, first: true });225 } else {226 const cps = [...raw];227 for (let start = 0; start < cps.length; start += contWidth) {228 chunks.push({ text: cps.slice(start, start + contWidth).join(""), first: start === 0 });229 }230 }231 for (const chunk of chunks) {232 const spans = highlightLine(chunk.text, lang);233 const styled = spans234 .map((s) => (s.role === "text" ? s.text : theme.paint(s.role, s.text)))235 .join("");236 out.push(gutter + (chunk.first ? "" : theme.paint("dim", "↪ ")) + styled);237 }238 }239 return out;240}241242// Inline parsing: `code`, **bold**, *italic*/_italic_, ~~strike~~, [text](url).243244function parseInline(text: string): InlineSpan[] {245 const spans: InlineSpan[] = [];246 const re =247 /(`[^`]+`)|(\*\*[^*]+\*\*)|(~~[^~]+~~)|(\*[^*\s][^*]*\*)|(_[^_\s][^_]*_)|(\[[^\]]+\]\([^)]+\))/g;248 let last = 0;249 let m: RegExpExecArray | null;250 while ((m = re.exec(text)) !== null) {251 if (m.index > last) spans.push({ text: text.slice(last, m.index), roles: [] });252 const token = m[0];253 if (m[1]) spans.push({ text: token.slice(1, -1), roles: ["code"] });254 else if (m[2]) spans.push({ text: token.slice(2, -2), roles: ["bold"] });255 else if (m[3]) spans.push({ text: token.slice(2, -2), roles: ["strike"] });256 else if (m[4] || m[5]) spans.push({ text: token.slice(1, -1), roles: ["italic"] });257 else if (m[6]) {258 const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token);259 if (link) {260 spans.push({ text: link[1] as string, roles: ["accent"] });261 spans.push({ text: ` (${link[2] as string})`, roles: ["dim"] });262 }263 }264 last = m.index + token.length;265 }266 if (last < text.length) spans.push({ text: text.slice(last), roles: [] });267 return spans;268}269270/** Word-wrap styled spans by visible width — styling applied after wrapping. */271function wrapSpans(spans: InlineSpan[], width: number): InlineSpan[][] {272 const w = Math.max(8, width);273 const lines: InlineSpan[][] = [];274 let current: InlineSpan[] = [];275 let used = 0;276277 const pushWord = (word: string, roles: StyleRole[]): void => {278 const wordLen = [...word].length;279 const sep = used > 0 ? 1 : 0;280 if (used + sep + wordLen <= w) {281 if (sep) appendText(current, " ", []);282 appendText(current, word, roles);283 used += sep + wordLen;284 return;285 }286 if (current.length > 0) {287 lines.push(current);288 current = [];289 used = 0;290 }291 let rest = word;292 while ([...rest].length > w) {293 lines.push([{ text: [...rest].slice(0, w).join(""), roles }]);294 rest = [...rest].slice(w).join("");295 }296 appendText(current, rest, roles);297 used = [...rest].length;298 };299300 for (const span of spans) {301 for (const word of span.text.split(" ")) {302 if (word === "") continue;303 pushWord(word, span.roles);304 }305 }306 if (current.length > 0) lines.push(current);307 return lines.length > 0 ? lines : [[]];308}309310function appendText(line: InlineSpan[], text: string, roles: StyleRole[]): void {311 const lastSpan = line[line.length - 1];312 if (lastSpan && sameRoles(lastSpan.roles, roles)) lastSpan.text += text;313 else line.push({ text, roles });314}315316function sameRoles(a: StyleRole[], b: StyleRole[]): boolean {317 return a.length === b.length && a.every((r, i) => r === b[i]);318}319320function renderInline(spans: InlineSpan[], theme: Theme, baseRole?: StyleRole): string {321 return spans322 .map((s) => {323 let text = s.text;324 for (const role of s.roles) text = theme.paint(role, text);325 if (s.roles.length === 0 && baseRole) text = theme.paint(baseRole, text);326 return text;327 })328 .join("");329}330