diff --git a/docs/screenshots/ai-markup.png b/docs/screenshots/ai-markup.png new file mode 100644 index 0000000..6795d51 Binary files /dev/null and b/docs/screenshots/ai-markup.png differ diff --git a/experiments/ai-markup-shot.ts b/experiments/ai-markup-shot.ts new file mode 100644 index 0000000..20cbb02 --- /dev/null +++ b/experiments/ai-markup-shot.ts @@ -0,0 +1,70 @@ +/** + * Renders a representative AI response with markdown-like markup and signed + * value coloring, then writes docs/screenshots/ai-markup.png for PR review. + * + * Run with: npx tsx experiments/ai-markup-shot.ts + */ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +import { parseAiMarkup, type AiMarkupSegment } from '../src/cli/ai-markup.js'; +import { getTheme, type CliTheme } from '../src/cli/theme.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const outPath = join(here, '..', 'docs', 'screenshots', 'ai-markup.png'); +const theme = getTheme('violet'); + +const sample = [ + 'Актуальный курс **BYN/USD** на сегодня:', + '', + '- **1 BYN ≈ 0.3618–0.3623 USD**', + '- **1 USD ≈ 2.76 BYN**', + '', + '`Динамика`: курс белорусского рубля немного укрепился (+0.24%).', + 'Сценарии: +890.05 при росте, -116 и -187.09 при снижении.', + '', + '```', + 'Источник: Rambler Finance, Kursbot, Investing.com.', + '```', +].join('\n'); + +function esc(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +function segmentStyle(segment: AiMarkupSegment, cliTheme: CliTheme): string { + const styles: string[] = []; + if (segment.bold) styles.push('font-weight:700'); + if (segment.code) styles.push(`color:${cliTheme.colors.info}`); + if (segment.tone === 'positive') styles.push(`color:${cliTheme.colors.long}`); + if (segment.tone === 'negative') styles.push(`color:${cliTheme.colors.short}`); + return styles.length > 0 ? ` style="${styles.join(';')}"` : ''; +} + +const body = parseAiMarkup(sample) + .map((segment) => `${esc(segment.text)}`) + .join(''); + +const html = `
> Какой курс у byn/usd + +▌AI +${body} + +> type a command, e.g. /start (/help for all)
`; + +const browser = await chromium.launch(); +const page = await browser.newPage({ deviceScaleFactor: 2 }); +await page.setContent(html, { waitUntil: 'load' }); +const el = await page.$('.term'); +await el!.screenshot({ path: outPath }); +await browser.close(); +process.stdout.write(`screenshot written: ${outPath}\n`); diff --git a/src/cli/ai-markup.tsx b/src/cli/ai-markup.tsx new file mode 100644 index 0000000..4191d6b --- /dev/null +++ b/src/cli/ai-markup.tsx @@ -0,0 +1,143 @@ +import { Text } from 'ink'; +import type React from 'react'; + +import type { CliTheme } from './theme.js'; + +export type AiMarkupTone = 'positive' | 'negative'; + +export interface AiMarkupSegment { + text: string; + bold?: boolean; + code?: boolean; + tone?: AiMarkupTone; +} + +type SegmentStyle = Omit; + +const SIGNED_NUMBER = /(^|[^\w.+-])([+-](?:\d+(?:\.\d+)?|\.\d+)%?)(?=$|[^\w.])/g; + +function sameStyle(a: AiMarkupSegment, b: SegmentStyle): boolean { + return a.bold === b.bold && a.code === b.code && a.tone === b.tone; +} + +function pushSegment(segments: AiMarkupSegment[], text: string, style: SegmentStyle = {}): void { + if (text.length === 0) return; + const last = segments[segments.length - 1]; + if (last && sameStyle(last, style)) { + last.text += text; + return; + } + segments.push({ text, ...style }); +} + +function pushTextWithSignedNumbers(segments: AiMarkupSegment[], text: string, style: SegmentStyle): void { + SIGNED_NUMBER.lastIndex = 0; + let cursor = 0; + let match: RegExpExecArray | null; + + while ((match = SIGNED_NUMBER.exec(text)) !== null) { + const prefix = match[1] ?? ''; + const value = match[2] ?? ''; + const valueStart = match.index + prefix.length; + const valueEnd = valueStart + value.length; + + pushSegment(segments, text.slice(cursor, valueStart), style); + pushSegment(segments, value, { + ...style, + tone: value.startsWith('+') ? 'positive' : 'negative', + }); + cursor = valueEnd; + } + + pushSegment(segments, text.slice(cursor), style); +} + +function parseInline(text: string, style: SegmentStyle = {}): AiMarkupSegment[] { + const segments: AiMarkupSegment[] = []; + let cursor = 0; + + const pushPlain = (end: number) => { + pushTextWithSignedNumbers(segments, text.slice(cursor, end), style); + cursor = end; + }; + + while (cursor < text.length) { + const boldStart = text.indexOf('**', cursor); + const codeStart = text.indexOf('`', cursor); + const candidates = [boldStart, codeStart].filter((index) => index >= 0); + const next = candidates.length === 0 ? -1 : Math.min(...candidates); + + if (next < 0) { + pushPlain(text.length); + break; + } + + if (next > cursor) pushPlain(next); + + if (text.startsWith('**', cursor)) { + const end = text.indexOf('**', cursor + 2); + if (end < 0) { + pushPlain(text.length); + break; + } + pushTextWithSignedNumbers(segments, text.slice(cursor + 2, end), { ...style, bold: true }); + cursor = end + 2; + continue; + } + + const end = text.indexOf('`', cursor + 1); + if (end < 0) { + pushPlain(text.length); + break; + } + pushSegment(segments, text.slice(cursor + 1, end), { ...style, code: true }); + cursor = end + 1; + } + + return segments; +} + +export function parseAiMarkup(text: string): AiMarkupSegment[] { + const segments: AiMarkupSegment[] = []; + const lines = text.split('\n'); + let inCodeFence = false; + + lines.forEach((line, index) => { + const newline = index < lines.length - 1 ? '\n' : ''; + if (/^\s*```/.test(line)) { + inCodeFence = !inCodeFence; + return; + } + + if (inCodeFence) { + pushSegment(segments, `${line}${newline}`, { code: true }); + return; + } + + for (const segment of parseInline(`${line}${newline}`)) { + const { text: segmentText, ...segmentStyle } = segment; + pushSegment(segments, segmentText, segmentStyle); + } + }); + + return segments; +} + +function segmentColor(segment: AiMarkupSegment, theme: CliTheme): string | undefined { + if (segment.tone === 'positive') return theme.colors.long; + if (segment.tone === 'negative') return theme.colors.short; + if (segment.code) return theme.colors.info; + return undefined; +} + +export function AiFormattedText({ text, theme }: { text: string; theme: CliTheme }): React.ReactElement { + return ( + + {parseAiMarkup(text).map((segment, index) => ( + + {segment.text} + + ))} + + ); +} diff --git a/src/cli/output.tsx b/src/cli/output.tsx index 2ac4d0d..721b784 100644 --- a/src/cli/output.tsx +++ b/src/cli/output.tsx @@ -5,6 +5,7 @@ import type { PersistedNewsCrawlReport, StatusReport } from '../app/tradefast.js import type { RunReport } from '../pipeline/collector.js'; import type { BacktestReport } from '../services/backtest.js'; import type { SourceRating } from '../services/source-ratings.js'; +import { AiFormattedText } from './ai-markup.js'; import { Banner } from './Banner.js'; import { renderBacktestParts } from './backtest-log.js'; import type { ChartData } from './chart.js'; @@ -304,7 +305,7 @@ export function OutputLine({ return ( ▌AI - {item.text} + ); case 'ratings': diff --git a/tests/cli.test.tsx b/tests/cli.test.tsx index 893da06..e05bc88 100644 --- a/tests/cli.test.tsx +++ b/tests/cli.test.tsx @@ -4,6 +4,7 @@ import { render } from 'ink-testing-library'; import type { Tradefast } from '../src/app/tradefast.js'; import { App } from '../src/cli/App.js'; +import { parseAiMarkup } from '../src/cli/ai-markup.js'; import { completeCommand, parseCommand, suggestCommands } from '../src/cli/commands.js'; import { OutputLine } from '../src/cli/output.js'; import { getTheme, themeNames } from '../src/cli/theme.js'; @@ -492,6 +493,26 @@ describe('run output', () => { unmount(); }); + it('renders AI markdown markers as formatting instead of literal text', () => { + const tallStdout = { rows: 80, columns: 120, write: () => {}, on: () => {}, removeListener: () => {} } as any; + const text = [ + 'Актуальный курс **BYN/USD** на сегодня:', + '- **1 BYN ≈ 0.3618–0.3623 USD**', + 'Динамика: курс укрепился (+0.24%).', + ].join('\n'); + const { lastFrame, unmount } = render( + , + { stdout: tallStdout }, + ); + + const frame = lastFrame(); + expect(frame).toContain('Актуальный курс BYN/USD на сегодня:'); + expect(frame).toContain('- 1 BYN ≈ 0.3618–0.3623 USD'); + expect(frame).toContain('(+0.24%)'); + expect(frame).not.toContain('**'); + unmount(); + }); + it('keeps blank cells inside the bordered table when no actionable signal is available', () => { const noActionReport: RunReport = { ...report, @@ -533,6 +554,30 @@ describe('run output', () => { }); }); +describe('AI message markup', () => { + it('parses bold, inline code, and fenced code markers without rendering delimiters', () => { + const segments = parseAiMarkup('Rate **BYN/USD** uses `mid`\n```json\n{"pair":"BYN/USD"}\n```\nDone'); + + expect(segments.map((segment) => segment.text).join('')).toBe('Rate BYN/USD uses mid\n{"pair":"BYN/USD"}\nDone'); + expect(segments).toContainEqual({ text: 'BYN/USD', bold: true }); + expect(segments).toContainEqual({ text: 'mid', code: true }); + expect(segments).toContainEqual({ text: '{"pair":"BYN/USD"}\n', code: true }); + }); + + it('marks signed numeric values while leaving bullets and price ranges alone', () => { + const segments = parseAiMarkup('- result range 0.3618-0.3623, changes (+0.24%), -116, +890.05, and - item'); + + expect(segments.filter((segment) => segment.tone).map((segment) => ({ + text: segment.text, + tone: segment.tone, + }))).toEqual([ + { text: '+0.24%', tone: 'positive' }, + { text: '-116', tone: 'negative' }, + { text: '+890.05', tone: 'positive' }, + ]); + }); +}); + describe('backtest output', () => { const report: BacktestReport = { results: [