/** * Markdown → ANSI for /bin/agent replies (TTY-friendly, no deps). */ /** * @param {boolean} useColor * @param {'bold'|'dim'|'italic'|'code'|'heading'|'quote'|'hr'|'bullet'|'link'} kind */ function bareAgentMdSgr(useColor, kind) { if (!useColor) return '' switch (kind) { case 'bold': return '\x1b[1m' case 'dim': return '\x1b[2m' case 'italic': return '\x1b[3m' case 'code': return '\x1b[36m' case 'heading': return '\x1b[1;36m' case 'quote': return '\x1b[2;37m' case 'hr': return '\x1b[2m' case 'bullet': return '\x1b[33m' case 'link': return '\x1b[4;36m' default: return '' } } /** * Inline markdown on a single line (no nested fences). * @param {string} line * @param {boolean} useColor */ function bareAgentMdInline(line, useColor) { const reset = useColor ? '\x1b[0m' : '' let s = String(line || '') // escape-ish: leave raw backslashes mostly alone /** @type {string[]} */ const out = [] let i = 0 while (i < s.length) { // inline code if (s[i] === '`') { const end = s.indexOf('`', i + 1) if (end > i) { out.push( bareAgentMdSgr(useColor, 'code') + s.slice(i + 1, end) + reset ) i = end + 1 continue } } // bold ** or __ if ( (s[i] === '*' && s[i + 1] === '*') || (s[i] === '_' && s[i + 1] === '_') ) { const mark = s[i] const end = s.indexOf(mark + mark, i + 2) if (end > i) { out.push( bareAgentMdSgr(useColor, 'bold') + bareAgentMdInline(s.slice(i + 2, end), useColor) + reset ) i = end + 2 continue } } // italic * or _ if (s[i] === '*' || s[i] === '_') { const mark = s[i] // avoid matching list markers at start handled elsewhere const end = s.indexOf(mark, i + 1) if (end > i + 1) { out.push( bareAgentMdSgr(useColor, 'italic') + s.slice(i + 1, end) + reset ) i = end + 1 continue } } // strike ~~ if (s[i] === '~' && s[i + 1] === '~') { const end = s.indexOf('~~', i + 2) if (end > i) { out.push('\x1b[9m' + s.slice(i + 2, end) + reset) i = end + 2 continue } } // links [text](url) if (s[i] === '[') { const mid = s.indexOf('](', i + 1) const end = mid >= 0 ? s.indexOf(')', mid + 2) : -1 if (mid > i && end > mid) { const label = s.slice(i + 1, mid) const url = s.slice(mid + 2, end) out.push( bareAgentMdSgr(useColor, 'link') + label + reset + bareAgentMdSgr(useColor, 'dim') + ' (' + url + ')' + reset ) i = end + 1 continue } } out.push(s[i]) i++ } return out.join('') } /** * Visible width ignoring ANSI CSI sequences. * @param {string} s */ function bareAgentMdVisibleWidth(s) { return String(s || '') .replace(/\x1b\[[0-9;]*m/g, '') .length } /** * Wrap a string to width by visible columns (ANSI-aware, crude). * @param {string} s * @param {number} width * @returns {string[]} */ function bareAgentMdWrapAnsi(s, width) { const w = Math.max(8, width | 0) const raw = String(s || '') if (!raw) return [''] /** @type {string[]} */ const lines = [] let cur = '' let vis = 0 let i = 0 while (i < raw.length) { if (raw[i] === '\x1b' && raw[i + 1] === '[') { let j = i + 2 while (j < raw.length && raw[j] !== 'm') j++ cur += raw.slice(i, Math.min(j + 1, raw.length)) i = Math.min(j + 1, raw.length) continue } if (raw[i] === '\n') { lines.push(cur) cur = '' vis = 0 i++ continue } if (vis >= w) { lines.push(cur) cur = '' vis = 0 } cur += raw[i] vis++ i++ } if (cur || !lines.length) lines.push(cur) return lines } /** * Render markdown source to ANSI lines (no trailing newline join). * @param {string} md * @param {{ useColor?: boolean, width?: number }} [opts] * @returns {string} */ function bareAgentRenderMarkdown(md, opts) { const useColor = !opts || opts.useColor !== false const width = Math.max( 40, Math.min(500, (opts && opts.width) || 80) ) const reset = useColor ? '\x1b[0m' : '' const src = String(md || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') const rows = src.split('\n') /** @type {string[]} */ const out = [] let i = 0 let inFence = false let fenceLang = '' while (i < rows.length) { const line = rows[i] // fenced code const fenceOpen = /^```\s*(\S*)\s*$/.exec(line) if (fenceOpen && !inFence) { inFence = true fenceLang = fenceOpen[1] || '' const label = fenceLang ? bareAgentMdSgr(useColor, 'dim') + '┌─ ' + fenceLang + ' ' + reset : bareAgentMdSgr(useColor, 'dim') + '┌──' + reset out.push(label) i++ continue } if (inFence) { if (/^```\s*$/.test(line)) { inFence = false out.push(bareAgentMdSgr(useColor, 'dim') + '└──' + reset) i++ continue } out.push( bareAgentMdSgr(useColor, 'dim') + '│ ' + reset + bareAgentMdSgr(useColor, 'code') + line + reset ) i++ continue } // hr if (/^\s*(?:---+|\*\*\*+|___+)\s*$/.test(line)) { out.push( bareAgentMdSgr(useColor, 'hr') + '─'.repeat(Math.min(width, 40)) + reset ) i++ continue } // headings const hm = /^(#{1,6})\s+(.*)$/.exec(line) if (hm) { const level = hm[1].length const text = hm[2] const prefix = level <= 2 ? '' : ' '.repeat(level - 2) out.push( prefix + bareAgentMdSgr(useColor, 'heading') + bareAgentMdInline(text, useColor) + reset ) if (level === 1) { out.push( bareAgentMdSgr(useColor, 'dim') + '━'.repeat(Math.min(width, Math.max(8, text.length))) + reset ) } i++ continue } // blockquote const qm = /^>\s?(.*)$/.exec(line) if (qm) { out.push( bareAgentMdSgr(useColor, 'quote') + '┃ ' + reset + bareAgentMdInline(qm[1], useColor) ) i++ continue } // unordered list const ul = /^(\s*)([-*+])\s+(.*)$/.exec(line) if (ul) { const indent = Math.min(6, Math.floor(ul[1].length / 2)) const pad = ' '.repeat(indent) out.push( pad + bareAgentMdSgr(useColor, 'bullet') + '• ' + reset + bareAgentMdInline(ul[3], useColor) ) i++ continue } // ordered list const ol = /^(\s*)(\d+)\.\s+(.*)$/.exec(line) if (ol) { const indent = Math.min(6, Math.floor(ol[1].length / 2)) const pad = ' '.repeat(indent) out.push( pad + bareAgentMdSgr(useColor, 'bullet') + ol[2] + '. ' + reset + bareAgentMdInline(ol[3], useColor) ) i++ continue } // empty if (!line.trim()) { out.push('') i++ continue } // paragraph — wrap const rendered = bareAgentMdInline(line, useColor) for (const wline of bareAgentMdWrapAnsi(rendered, width)) out.push(wline) i++ } // Ensure fence close if stream was truncated if (inFence) out.push(bareAgentMdSgr(useColor, 'dim') + '└──' + reset) return out.join('\n') + (out.length ? '\n' : '') }