SPB Git

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%
10.9 KB · 330 lines typescript
Raw Blame History
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