331 lines
7.6 KiB
JavaScript
331 lines
7.6 KiB
JavaScript
/**
|
|
* 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' : '')
|
|
}
|