voyageai-cli
Version:
CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search
947 lines (818 loc) • 30.2 kB
JavaScript
;
const pc = require('picocolors');
const { render, COLORS } = require('./robot');
// ── Brand color helpers (matching robot-moments.js) ──────────────────
const fg = (r, g, b) => `\x1b[38;2;${r};${g};${b}m`;
const RST = '\x1b[0m';
const teal = (text) => `${fg(...COLORS.teal)}${text}${RST}`;
const cyan = (text) => `${fg(...COLORS.cyan)}${text}${RST}`;
const green = (text) => `${fg(...COLORS.green)}${text}${RST}`;
const dim = (text) => pc.dim(text);
const bold = (text) => pc.bold(text);
const divider = (width = 52) => dim('\u2500'.repeat(width));
// ── Layout helper: robot left, text right (same as robot-moments.js) ─
function sideBySide(poseName, textLines, gap = 3) {
const frame = render(poseName, { indent: 0 });
const robotLines = frame.split('\n');
const robotWidth = 16 + 1;
const gapStr = ' '.repeat(gap);
const indent = ' ';
const totalLines = Math.max(robotLines.length, textLines.length);
const textStart = Math.floor((robotLines.length - textLines.length) / 2);
const output = [];
for (let i = 0; i < totalLines; i++) {
const robotPart = robotLines[i] ?? ' '.repeat(robotWidth);
const textIdx = i - textStart;
const textPart = (textIdx >= 0 && textIdx < textLines.length)
? textLines[textIdx]
: '';
output.push(`${indent}${robotPart}${gapStr}${textPart}`);
}
return output.join('\n');
}
// ── Terminal helpers ──────────────────────────────────────────────────
/**
* Get the current terminal width, clamped to a sane range.
* Falls back to 80 if not a TTY.
*/
function termWidth() {
return Math.max(40, Math.min(process.stdout.columns || 80, 200));
}
// ── Word-wrap ────────────────────────────────────────────────────────
/**
* Wrap a plain-text string to fit within `width` columns.
* Respects existing newlines. Does not break inside ANSI escape sequences.
* @param {string} text
* @param {number} [width]
* @param {string} [indent=''] - prefix prepended to continuation lines
* @returns {string}
*/
function wordWrap(text, width, indent = '') {
if (!width) width = termWidth();
const effectiveWidth = width - indent.length;
if (effectiveWidth < 20) return text; // too narrow, skip wrapping
const lines = text.split('\n');
const result = [];
for (const line of lines) {
if (stripAnsi(line).length <= width) {
result.push(line);
continue;
}
// Wrap long lines at word boundaries
const words = line.split(/( +)/); // keep whitespace tokens
let current = '';
let currentVisible = 0;
for (const word of words) {
const wordVisible = stripAnsi(word).length;
if (currentVisible + wordVisible > width && currentVisible > 0) {
result.push(current);
current = indent + word.replace(/^ +/, ''); // trim leading space on continuation
currentVisible = indent.length + stripAnsi(current).length - indent.length;
} else {
current += word;
currentVisible += wordVisible;
}
}
if (current) result.push(current);
}
return result.join('\n');
}
// ── ANSI stripping ───────────────────────────────────────────────────
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1b\[[0-9;]*m/g;
function stripAnsi(str) {
return str.replace(ANSI_RE, '');
}
function splitTableCells(line) {
const trimmed = line.trim().replace(/^\|+/, '').replace(/\|+$/, '');
const cells = trimmed.split('|').map(cell => cell.trim());
while (cells.length > 1 && cells[0] === '') cells.shift();
while (cells.length > 1 && cells[cells.length - 1] === '') cells.pop();
return cells;
}
function isTableSeparatorLine(line) {
if (!line || !line.includes('|')) return false;
const cells = splitTableCells(line);
if (cells.length === 0) return false;
return cells.every(cell => /^:?-{3,}:?$/.test(cell));
}
function isTableRow(line) {
return Boolean(line && line.includes('|') && line.trim() !== '');
}
function getTableColumnWidths(rows, maxTableWidth) {
const columnCount = Math.max(...rows.map(row => row.length));
const widths = Array.from({ length: columnCount }, (_, colIdx) => (
Math.max(
6,
...rows.map(row => stripAnsi(row[colIdx] || '').length)
)
));
const maxCellWidth = Math.max(6, maxTableWidth - (3 * columnCount + 1));
if (maxCellWidth <= 0) return widths.map(() => 6);
for (let i = 0; i < widths.length; i++) {
widths[i] = Math.min(widths[i], Math.max(12, Math.floor(maxCellWidth / columnCount)));
}
while (widths.reduce((sum, width) => sum + width, 0) > maxCellWidth) {
let widestIdx = 0;
for (let i = 1; i < widths.length; i++) {
if (widths[i] > widths[widestIdx]) widestIdx = i;
}
if (widths[widestIdx] <= 6) break;
widths[widestIdx]--;
}
return widths;
}
function padAnsi(text, width) {
return text + ' '.repeat(Math.max(0, width - stripAnsi(text).length));
}
function wrapTableCell(text, width) {
const wrapped = wordWrap(text, width);
return wrapped.split('\n');
}
function renderTableBorder(widths, left, join, right) {
return pc.dim(left + widths.map(width => '─'.repeat(width + 2)).join(join) + right);
}
function renderMarkdownTable(lines, width) {
const parsedRows = lines.map(line => splitTableCells(line).map(renderInline));
const header = parsedRows[0];
const body = parsedRows.slice(2);
const rows = [header, ...body];
const maxTableWidth = Math.max(30, Math.min(width, 120));
const columnWidths = getTableColumnWidths(rows, maxTableWidth);
const out = [];
out.push(renderTableBorder(columnWidths, '┌', '┬', '┐'));
const renderRow = (row) => {
const cellLines = columnWidths.map((colWidth, idx) => wrapTableCell(row[idx] || '', colWidth));
const rowHeight = Math.max(...cellLines.map(linesForCell => linesForCell.length));
for (let lineIdx = 0; lineIdx < rowHeight; lineIdx++) {
const renderedCells = columnWidths.map((colWidth, colIdx) => {
const line = cellLines[colIdx][lineIdx] || '';
return ` ${padAnsi(line, colWidth)} `;
});
out.push(pc.dim('│') + renderedCells.join(pc.dim('│')) + pc.dim('│'));
}
};
renderRow(header);
out.push(renderTableBorder(columnWidths, '├', '┼', '┤'));
for (let i = 0; i < body.length; i++) {
renderRow(body[i]);
if (i < body.length - 1) {
out.push(renderTableBorder(columnWidths, '├', '┼', '┤'));
}
}
out.push(renderTableBorder(columnWidths, '└', '┴', '┘'));
return out.join('\n');
}
function consumeMarkdownTable(lines, startIndex) {
const header = lines[startIndex];
const separator = lines[startIndex + 1];
if (!isTableRow(header) || !isTableSeparatorLine(separator)) {
return null;
}
const tableLines = [header, separator];
let endIndex = startIndex + 2;
while (endIndex < lines.length && isTableRow(lines[endIndex])) {
tableLines.push(lines[endIndex]);
endIndex++;
}
return { tableLines, endIndex };
}
// ── Markdown → ANSI rendering ────────────────────────────────────────
/**
* Render a complete markdown string to ANSI-colored terminal output.
* Handles: headers, bold, italic, inline code, code blocks, lists,
* blockquotes, horizontal rules, and links.
*
* This is intentionally simple — no dependency, just good enough for
* LLM chat responses.
*
* @param {string} md - Markdown text
* @param {number} [width] - Terminal width for wrapping
* @returns {string}
*/
function renderMarkdown(md, width) {
if (!width) width = termWidth();
const lines = md.split('\n');
const out = [];
let inCodeBlock = false;
let codeLang = '';
let codeLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// ── Code blocks (fenced) ──
if (line.match(/^```/)) {
if (!inCodeBlock) {
inCodeBlock = true;
codeLang = line.replace(/^```\s*/, '').trim();
codeLines = [];
continue;
} else {
// Close code block
out.push(renderCodeBlock(codeLines, codeLang, width));
inCodeBlock = false;
codeLang = '';
codeLines = [];
continue;
}
}
if (inCodeBlock) {
codeLines.push(line);
continue;
}
const table = consumeMarkdownTable(lines, i);
if (table) {
out.push(renderMarkdownTable(table.tableLines, width));
i = table.endIndex - 1;
continue;
}
// ── Horizontal rule ──
if (line.match(/^(\s*[-*_]){3,}\s*$/)) {
out.push(pc.dim('─'.repeat(Math.min(width, 60))));
continue;
}
// ── Headers ──
const headerMatch = line.match(/^(#{1,6})\s+(.*)/);
if (headerMatch) {
const level = headerMatch[1].length;
const text = renderInline(headerMatch[2]);
if (level === 1) {
out.push('');
out.push(pc.bold(pc.cyan(text)));
out.push(pc.cyan('─'.repeat(Math.min(stripAnsi(text).length, width))));
} else if (level === 2) {
out.push('');
out.push(pc.bold(text));
} else {
out.push('');
out.push(pc.bold(pc.dim(text)));
}
continue;
}
// ── Blockquote ──
if (line.match(/^>\s?/)) {
const content = renderInline(line.replace(/^>\s?/, ''));
out.push(wordWrap(`${pc.dim('│')} ${pc.italic(content)}`, width - 2, ' '));
continue;
}
// ── Unordered list ──
const ulMatch = line.match(/^(\s*)[*+-]\s+(.*)/);
if (ulMatch) {
const indent = ulMatch[1] || '';
const content = renderInline(ulMatch[2]);
const prefix = indent + pc.dim('•') + ' ';
const prefixLen = indent.length + 2;
out.push(wordWrap(prefix + content, width, ' '.repeat(prefixLen)));
continue;
}
// ── Ordered list ──
const olMatch = line.match(/^(\s*)(\d+)[.)]\s+(.*)/);
if (olMatch) {
const indent = olMatch[1] || '';
const num = olMatch[2];
const content = renderInline(olMatch[3]);
const prefix = indent + pc.dim(num + '.') + ' ';
const prefixLen = indent.length + num.length + 2;
out.push(wordWrap(prefix + content, width, ' '.repeat(prefixLen)));
continue;
}
// ── Empty line ──
if (line.trim() === '') {
out.push('');
continue;
}
// ── Regular paragraph line ──
out.push(wordWrap(renderInline(line), width));
}
// Handle unclosed code block
if (inCodeBlock && codeLines.length > 0) {
out.push(renderCodeBlock(codeLines, codeLang, width));
}
return out.join('\n');
}
/**
* Render inline markdown elements: bold, italic, inline code, links, strikethrough.
* @param {string} text
* @returns {string}
*/
function renderInline(text) {
// Inline code (must come before bold/italic to avoid conflicts)
text = text.replace(/`([^`]+)`/g, (_, code) => pc.cyan(code));
// Bold+italic
text = text.replace(/\*\*\*(.+?)\*\*\*/g, (_, t) => pc.bold(pc.italic(t)));
// Bold
text = text.replace(/\*\*(.+?)\*\*/g, (_, t) => pc.bold(t));
text = text.replace(/__(.+?)__/g, (_, t) => pc.bold(t));
// Italic
text = text.replace(/\*(.+?)\*/g, (_, t) => pc.italic(t));
text = text.replace(/_(.+?)_/g, (_, t) => pc.italic(t));
// Strikethrough
text = text.replace(/~~(.+?)~~/g, (_, t) => pc.strikethrough(t));
// Links [text](url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
return `${pc.underline(pc.cyan(label))} ${pc.dim('(' + url + ')')}`;
});
return text;
}
/**
* Render a fenced code block with a subtle box border.
* @param {string[]} lines
* @param {string} lang
* @param {number} width
* @returns {string}
*/
function renderCodeBlock(lines, lang, width) {
const maxContentWidth = width - 4; // 2 for "│ " prefix, 2 for padding
const contentWidth = Math.min(
Math.max(...lines.map(l => l.length), 10),
maxContentWidth
);
const boxWidth = contentWidth + 2; // +2 for inner padding
const label = lang ? ` ${lang} ` : '';
const topRule = '┌' + label + '─'.repeat(Math.max(0, boxWidth - label.length)) + '┐';
const botRule = '└' + '─'.repeat(boxWidth) + '┘';
const out = [];
out.push(pc.dim(topRule));
for (const line of lines) {
const padded = line + ' '.repeat(Math.max(0, contentWidth - line.length));
out.push(pc.dim('│') + ' ' + padded + ' ' + pc.dim('│'));
}
out.push(pc.dim(botRule));
return out.join('\n');
}
// ── Streaming markdown renderer ──────────────────────────────────────
/**
* Create a streaming markdown renderer that processes chunks incrementally.
*
* LLM responses arrive as small text chunks. This renderer buffers lines
* and renders complete lines immediately, holding the last incomplete line
* until more data arrives or flush() is called.
*
* @param {object} [options]
* @param {number} [options.width] - Terminal width
* @param {NodeJS.WriteStream} [options.stream] - Output stream (default: process.stdout)
* @returns {{ write: (chunk: string) => void, flush: () => void }}
*/
function createStreamRenderer(options = {}) {
const width = options.width || termWidth();
const stream = options.stream || process.stdout;
let buffer = '';
let inCodeBlock = false;
let codeLang = '';
let codeLines = [];
let pendingLines = [];
let pendingTableLines = [];
let firstLine = true;
function writeLine(rendered) {
if (!firstLine) stream.write('\n');
stream.write(rendered);
firstLine = false;
}
function processLine(line) {
// Code block fence
if (line.match(/^```/)) {
if (!inCodeBlock) {
inCodeBlock = true;
codeLang = line.replace(/^```\s*/, '').trim();
codeLines = [];
return;
} else {
writeLine(renderCodeBlock(codeLines, codeLang, width));
inCodeBlock = false;
codeLang = '';
codeLines = [];
return;
}
}
if (inCodeBlock) {
codeLines.push(line);
return;
}
// Horizontal rule
if (line.match(/^(\s*[-*_]){3,}\s*$/)) {
writeLine(pc.dim('─'.repeat(Math.min(width, 60))));
return;
}
// Headers
const headerMatch = line.match(/^(#{1,6})\s+(.*)/);
if (headerMatch) {
const level = headerMatch[1].length;
const text = renderInline(headerMatch[2]);
if (level === 1) {
writeLine('');
writeLine(pc.bold(pc.cyan(text)));
writeLine(pc.cyan('─'.repeat(Math.min(stripAnsi(text).length, width))));
} else if (level === 2) {
writeLine('');
writeLine(pc.bold(text));
} else {
writeLine('');
writeLine(pc.bold(pc.dim(text)));
}
return;
}
// Blockquote
if (line.match(/^>\s?/)) {
const content = renderInline(line.replace(/^>\s?/, ''));
writeLine(wordWrap(`${pc.dim('│')} ${pc.italic(content)}`, width - 2, ' '));
return;
}
// Unordered list
const ulMatch = line.match(/^(\s*)[*+-]\s+(.*)/);
if (ulMatch) {
const indent = ulMatch[1] || '';
const content = renderInline(ulMatch[2]);
const prefix = indent + pc.dim('•') + ' ';
const prefixLen = indent.length + 2;
writeLine(wordWrap(prefix + content, width, ' '.repeat(prefixLen)));
return;
}
// Ordered list
const olMatch = line.match(/^(\s*)(\d+)[.)]\s+(.*)/);
if (olMatch) {
const indent = olMatch[1] || '';
const num = olMatch[2];
const content = renderInline(olMatch[3]);
const prefix = indent + pc.dim(num + '.') + ' ';
const prefixLen = indent.length + num.length + 2;
writeLine(wordWrap(prefix + content, width, ' '.repeat(prefixLen)));
return;
}
// Empty line
if (line.trim() === '') {
writeLine('');
return;
}
// Regular text
writeLine(wordWrap(renderInline(line), width));
}
function flushPendingLines(force = false) {
while (pendingLines.length > 0) {
if (pendingTableLines.length > 0) {
if (pendingLines.length === 0 && !force) return;
if (pendingLines.length > 0 && isTableRow(pendingLines[0])) {
pendingTableLines.push(pendingLines.shift());
continue;
}
writeLine(renderMarkdownTable(pendingTableLines, width));
pendingTableLines = [];
continue;
}
if (pendingLines.length >= 2) {
const table = consumeMarkdownTable(pendingLines, 0);
if (table) {
pendingTableLines = table.tableLines.slice();
pendingLines.splice(0, table.endIndex);
continue;
}
} else if (!force && isTableRow(pendingLines[0])) {
return;
}
processLine(pendingLines.shift());
}
if (force && pendingTableLines.length > 0) {
writeLine(renderMarkdownTable(pendingTableLines, width));
pendingTableLines = [];
}
}
return {
/**
* Feed a chunk of streamed text.
* Complete lines are rendered immediately; partial lines are buffered.
*/
write(chunk) {
buffer += chunk;
const parts = buffer.split('\n');
// All parts except the last are complete lines
buffer = parts.pop(); // keep the incomplete tail
for (const line of parts) {
pendingLines.push(line);
}
flushPendingLines(false);
},
/**
* Flush any remaining buffered content (call when streaming is done).
*/
flush() {
if (buffer) {
pendingLines.push(buffer);
buffer = '';
}
flushPendingLines(true);
// Close unclosed code block
if (inCodeBlock && codeLines.length > 0) {
writeLine(renderCodeBlock(codeLines, codeLang, width));
inCodeBlock = false;
codeLines = [];
}
stream.write('\n');
},
};
}
// ── Box-drawn source cards ───────────────────────────────────────────
/**
* Render a relevance score as a mini bar chart.
* @param {number} score - 0 to 1
* @param {number} [barWidth=12]
* @returns {string}
*/
function scoreBar(score, barWidth = 12) {
if (score == null || isNaN(score)) return pc.dim('N/A');
const filled = Math.round(score * barWidth);
const empty = barWidth - filled;
const bar = '█'.repeat(filled) + '░'.repeat(empty);
const color = score >= 0.7 ? green : score >= 0.4 ? pc.yellow : pc.red;
return color(bar) + ' ' + color(score.toFixed(2));
}
/**
* Render sources as box-drawn cards.
*
* Supports both raw sources and deduplicated sources (with `chunks` count).
* When a source has chunks > 1, shows "(N chunks)" next to the label.
*
* @param {Array<{source: string, score?: number, text?: string, chunks?: number}>} sources
* @param {object} [options]
* @param {number} [options.width] - Terminal width
* @param {boolean} [options.showPreview] - Show text preview (default false)
* @param {number} [options.previewChars] - Preview length (default 120)
* @returns {string}
*/
function renderSources(sources, options = {}) {
if (!sources || sources.length === 0) return '';
const width = options.width || termWidth();
const showPreview = options.showPreview || false;
const previewChars = options.previewChars || 120;
const boxWidth = Math.min(width - 2, 76);
const innerWidth = boxWidth - 4; // "│ " + " │"
const out = [];
out.push('');
out.push(pc.dim(` ┌${'─'.repeat(boxWidth - 2)}┐`));
out.push(pc.dim(' │') + ' ' + pc.bold('Sources') + ' '.repeat(Math.max(0, innerWidth - 7)) + pc.dim(' │'));
out.push(pc.dim(` ├${'─'.repeat(boxWidth - 2)}┤`));
for (let i = 0; i < sources.length; i++) {
const s = sources[i];
// Build label: "1. filename.md" or "1. filename.md (3 chunks)"
let labelText = `${i + 1}. ${s.source}`;
if (s.chunks && s.chunks > 1) {
labelText += pc.dim(` (${s.chunks} chunks)`);
}
const label = truncateAnsi(labelText, innerWidth - 22);
const bar = scoreBar(s.score);
const barVisibleLen = stripAnsi(bar).length;
const labelVisibleLen = stripAnsi(label).length;
const gap = Math.max(1, innerWidth - labelVisibleLen - barVisibleLen);
out.push(pc.dim(' │') + ' ' + label + ' '.repeat(gap) + bar + ' ' + pc.dim('│'));
if (showPreview && s.text) {
const preview = truncate(s.text.replace(/\n/g, ' ').trim(), Math.min(innerWidth, previewChars));
out.push(pc.dim(' │') + ' ' + pc.dim(preview) + ' '.repeat(Math.max(0, innerWidth - stripAnsi(preview).length)) + ' ' + pc.dim('│'));
}
if (i < sources.length - 1) {
out.push(pc.dim(` │${'·'.repeat(boxWidth - 2)}│`));
}
}
out.push(pc.dim(` └${'─'.repeat(boxWidth - 2)}┘`));
return out.join('\n');
}
/**
* Truncate a string that may contain ANSI codes to a visible maxLen.
* Falls back to plain truncate for strings without ANSI.
*/
function truncateAnsi(str, maxLen) {
const visible = stripAnsi(str);
if (visible.length <= maxLen) return str;
// Walk the original string, tracking visible char count
let visCount = 0;
let i = 0;
// eslint-disable-next-line no-control-regex
const ansiOpen = /\x1b\[[0-9;]*m/;
while (i < str.length && visCount < maxLen - 1) {
const match = str.slice(i).match(ansiOpen);
if (match && match.index === 0) {
i += match[0].length; // skip ANSI sequence, don't count
} else {
visCount++;
i++;
}
}
return str.substring(0, i) + '\x1b[0m…';
}
/**
* Truncate a string to maxLen, adding ellipsis if needed.
*/
function truncate(str, maxLen) {
if (str.length <= maxLen) return str;
return str.substring(0, maxLen - 1) + '…';
}
// ── Turn styling helpers ──────────────────────────────────────────────
/**
* Styled user prompt for the readline interface (TURN-01).
* Bold green chevron distinguishes user input from assistant output.
* @returns {string}
*/
function renderUserPrompt() {
return pc.bold(green('\u276F '));
}
/**
* Styled assistant label printed before streaming response (TURN-02).
* Compact `── vai ──` label in dim/cyan styling.
* @returns {string}
*/
function renderAssistantLabel() {
return pc.dim('\u2500\u2500 ') + pc.bold(pc.cyan('vai')) + pc.dim(' \u2500\u2500');
}
/**
* Visual divider between conversation turns (TURN-03).
* Dim horizontal line for clear turn separation.
* @returns {string}
*/
function renderTurnDivider() {
return pc.dim('\u2500'.repeat(40));
}
// ── Timed spinner (moved from chat.js) ───────────────────────────────
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
/**
* Create a spinner with auto-updating elapsed time display.
* Writes directly to stderr to avoid conflicts with readline on stdout.
* @param {string} baseText - Base spinner message
* @returns {{ stop: Function, succeed: Function, updateText: Function }}
*/
function createTimedSpinner(baseText) {
const isTTY = process.stderr.isTTY;
const startTime = Date.now();
let frameIndex = 0;
let stopped = false;
const render = () => {
if (stopped) return;
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
const frame = pc.cyan(SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length]);
const line = `${frame} ${baseText}... ${pc.dim(`${elapsed}s`)}`;
if (isTTY) {
process.stderr.write(`\r\x1b[K${line}`);
}
frameIndex++;
};
render();
const timer = setInterval(render, 80);
const clear = () => {
if (stopped) return;
stopped = true;
clearInterval(timer);
if (isTTY) {
process.stderr.write('\r\x1b[K');
}
};
return {
stop: clear,
succeed(msg) {
if (stopped) return;
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
clear();
if (isTTY) {
const text = msg || `${baseText} ${pc.dim(`(${elapsed}s)`)}`;
process.stderr.write(`${green('✓')} ${text}\n`);
}
},
updateText(newBase) {
baseText = newBase;
},
};
}
// ── Chat header ──────────────────────────────────────────────────────
/**
* Render the chat session header.
* @param {object} info
* @param {string} info.version
* @param {string} info.provider
* @param {string} info.model
* @param {string} info.mode - 'pipeline' or 'agent'
* @param {string} [info.db]
* @param {string} [info.collection]
* @param {string} info.sessionId
* @returns {string}
*/
function renderHeader(info) {
if (info.interactive) {
// ── Robot sideBySide layout (interactive TTY mode) ──
const modeLabel = info.mode === 'agent' ? 'agent (tool-calling)' : 'pipeline (fixed RAG)';
const knowledgeLine = info.mode === 'agent'
? `${dim('Database:')} ${info.db || 'auto'}`
: `${dim('Knowledge:')} ${info.db}.${info.collection}`;
// Embedding model badge
const embedName = info.embeddingModel || 'default';
const embedBadge = info.isLocalEmbed
? dim('[') + green('LOCAL') + dim(']')
: dim('[') + cyan('API') + dim(']');
const embedLine = info.embeddingModel
? `${dim('Embedding:')} ${embedName} ${embedBadge}`
: `${dim('Embedding:')} ${dim(embedName)}`;
const textLines = [
bold(teal('vai chat')) + dim(` v${info.version}`),
'',
`${dim('Provider:')} ${info.provider}` + dim(` (${info.model})`),
embedLine,
`${dim('Mode:')} ${modeLabel}`,
knowledgeLine,
`${dim('Session:')} ${dim(info.sessionId)}`,
'',
dim('Type ') + cyan('/help') + dim(' for commands, ') + cyan('/quit') + dim(' to exit.'),
];
return sideBySide('idle', textLines);
}
// ── Plain-text fallback (non-interactive / --json / --quiet) ──
const embedNamePlain = info.embeddingModel || 'default';
const embedBadgePlain = info.isLocalEmbed
? pc.dim('[') + green('LOCAL') + pc.dim(']')
: pc.dim('[') + cyan('API') + pc.dim(']');
const embedLinePlain = info.embeddingModel
? ` ${pc.dim('Embedding:')} ${embedNamePlain} ${embedBadgePlain}`
: ` ${pc.dim('Embedding:')} ${pc.dim(embedNamePlain)}`;
const lines = [];
lines.push('');
lines.push(`${pc.bold('vai chat')} v${info.version}`);
lines.push(` ${pc.dim('Provider:')} ${info.provider} (${info.model})`);
lines.push(embedLinePlain);
if (info.mode === 'agent') {
lines.push(` ${pc.dim('Mode:')} agent (tool-calling)`);
if (info.db) lines.push(` ${pc.dim('Default DB:')} ${info.db}`);
if (info.collection) lines.push(` ${pc.dim('Collection:')} ${info.collection}`);
} else {
lines.push(` ${pc.dim('Mode:')} pipeline (fixed RAG)`);
lines.push(` ${pc.dim('Knowledge:')} ${info.db}.${info.collection}`);
}
lines.push(` ${pc.dim('Session:')} ${pc.dim(info.sessionId)}`);
lines.push(pc.dim('Type /help for commands, /quit to exit.'));
lines.push('');
return lines.join('\n');
}
// ── Tool call rendering ──────────────────────────────────────────────
/**
* Render a tool call line for agent mode.
* @param {object} call
* @param {string} call.name
* @param {number} call.timeMs
* @param {string} [call.error]
* @param {*} [call.result]
* @param {boolean|string} [verbose] - true for name+time, 'verbose' for result preview
* @returns {string}
*/
function renderToolCall(call, verbose) {
const { name, timeMs, error, result } = call;
if (error) {
return pc.dim(` [tool] ${name} ${pc.red('failed')} (${timeMs}ms): ${error}`);
}
let line = pc.dim(` [tool] ${name} (${timeMs}ms)`);
if (verbose === 'verbose' && result) {
const preview = JSON.stringify(result).substring(0, 200);
line += '\n' + pc.dim(` ${preview}${JSON.stringify(result).length > 200 ? '...' : ''}`);
}
return line;
}
// ── Per-message latency line ──────────────────────────────────────────
/**
* Render a per-message latency line showing retrieval and generation times.
* @param {object} metadata
* @param {number} [metadata.retrievalTimeMs] - Embedding retrieval elapsed ms
* @param {number} [metadata.generationTimeMs] - LLM generation elapsed ms
* @param {number} [metadata.totalTimeMs] - Total agent elapsed ms (agent mode)
* @param {string} [metadata.mode] - 'agent' for agent mode
* @returns {string}
*/
function renderLatencyLine(metadata) {
if (!metadata) return '';
if (metadata.mode === 'agent' || metadata.totalTimeMs != null) {
return dim(' Latency: ') + cyan(`${metadata.totalTimeMs}ms`) + dim(' total');
}
const parts = [];
if (metadata.retrievalTimeMs != null) {
parts.push(dim('Retrieval: ') + cyan(`${metadata.retrievalTimeMs}ms`));
}
if (metadata.generationTimeMs != null) {
parts.push(dim('LLM: ') + cyan(`${metadata.generationTimeMs}ms`));
}
if (parts.length === 0) return '';
return dim(' ') + parts.join(dim(' | '));
}
module.exports = {
// Core rendering
renderMarkdown,
renderInline,
renderCodeBlock,
renderSources,
renderHeader,
renderToolCall,
renderLatencyLine,
// Streaming
createStreamRenderer,
// Turn styling
renderUserPrompt,
renderAssistantLabel,
renderTurnDivider,
// Spinner
createTimedSpinner,
// Utilities
wordWrap,
stripAnsi,
scoreBar,
truncate,
truncateAnsi,
termWidth,
};