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: [