diff --git a/src/components/Markdown/Markdown.tsx b/src/components/Markdown/Markdown.tsx index 588622cd..e4916795 100644 --- a/src/components/Markdown/Markdown.tsx +++ b/src/components/Markdown/Markdown.tsx @@ -1,10 +1,8 @@ import { Text, useStdout } from 'ink'; -import { Marked, type Token } from 'marked'; -import { markedTerminal } from 'marked-terminal'; import { memo, useMemo } from 'react'; import { UI } from '../../constants'; -import { inlineMathExtension } from './extensions'; +import { renderMarkdown } from './render'; interface MarkdownProps { content: string; @@ -12,52 +10,6 @@ interface MarkdownProps { dimColor?: boolean; } -const HR_PLACEHOLDER = '__CODE_OLLAMA_HR_PLACEHOLDER__'; - -function renderMarkdown(content: string, hrWidth: number): string { - const hr = UI.MARKDOWN_HR_CHARACTER.repeat(Math.max(1, hrWidth)); - const markdown = new Marked(); - const rendererExtension = { - extensions: [inlineMathExtension], - useNewRenderer: true, - renderer: { - hr: () => `${HR_PLACEHOLDER}\n`, - text(token: Token) { - const textToken = token as Token & { - text?: string; - tokens?: Token[]; - }; - - if (typeof token === 'object' && Array.isArray(textToken.tokens)) { - return this.parser.parseInline(textToken.tokens); - } - - return String(textToken.text); - }, - }, - } as Parameters[0]; - - markdown.use( - markedTerminal({ - theme: 'gitHub', - reflowText: true, - width: Math.max(1, hrWidth), - }), - ); - - markdown.use(rendererExtension); - - try { - const result = markdown.parse(content); - // v8 ignore start - const text = typeof result === 'string' ? result.trim() : content; - return text.replaceAll(HR_PLACEHOLDER, hr); - } catch { - return content; - } - // v8 ignore stop -} - export const Markdown = memo(function Markdown({ content, color, diff --git a/src/components/Markdown/render.ts b/src/components/Markdown/render.ts new file mode 100644 index 00000000..01248245 --- /dev/null +++ b/src/components/Markdown/render.ts @@ -0,0 +1,51 @@ +import { Marked, type Token } from 'marked'; +import { markedTerminal } from 'marked-terminal'; + +import { UI } from '../../constants'; +import { inlineMathExtension } from './extensions'; + +const HR_PLACEHOLDER = '__CODE_OLLAMA_HR_PLACEHOLDER__'; + +export function renderMarkdown(content: string, hrWidth: number): string { + const hr = UI.MARKDOWN_HR_CHARACTER.repeat(Math.max(1, hrWidth)); + const markdown = new Marked(); + const rendererExtension = { + extensions: [inlineMathExtension], + useNewRenderer: true, + renderer: { + hr: () => `${HR_PLACEHOLDER}\n`, + text(token: Token) { + const textToken = token as Token & { + text?: string; + tokens?: Token[]; + }; + + if (typeof token === 'object' && Array.isArray(textToken.tokens)) { + return this.parser.parseInline(textToken.tokens); + } + + return String(textToken.text); + }, + }, + } as Parameters[0]; + + markdown.use( + markedTerminal({ + theme: 'gitHub', + reflowText: true, + width: Math.max(1, hrWidth), + }), + ); + + markdown.use(rendererExtension); + + try { + const result = markdown.parse(content); + // v8 ignore next + const text = typeof result === 'string' ? result.trim() : content; + return text.replaceAll(HR_PLACEHOLDER, hr); + } catch { + // v8 ignore next + return content; + } +} diff --git a/src/components/Messages/Messages.test.tsx b/src/components/Messages/Messages.test.tsx index 2638da00..e2f60761 100644 --- a/src/components/Messages/Messages.test.tsx +++ b/src/components/Messages/Messages.test.tsx @@ -1,4 +1,4 @@ -import { Text } from 'ink'; +import { Text, useStdout } from 'ink'; import { render } from 'ink-testing-library'; import { ROLE, UI } from '../../constants'; @@ -6,6 +6,21 @@ import type { Role } from '../../types'; import { TURN_ABORTED_MESSAGE } from './constants'; import { Messages } from './Messages'; +const { mockColumns } = vi.hoisted(() => ({ + mockColumns: { + value: 100, + }, +})); + +vi.mock('ink', async () => ({ + ...(await vi.importActual('ink')), + useStdout: vi.fn(() => ({ + stdout: { + columns: mockColumns.value, + }, + })), +})); + vi.mock('@inkjs/ui', () => ({ Spinner: ({ label }: { label?: string }) => {`⏳${label ?? ''}`}, })); @@ -34,7 +49,20 @@ const systemMessage: { role: Role; content: string } = { content: 'system info', }; +function setTerminalWidth(columns: number) { + mockColumns.value = columns; +} + +function lineCount(frame: string | undefined) { + return (frame ?? '').split('\n').length; +} + describe('Messages', () => { + beforeEach(() => { + setTerminalWidth(100); + vi.mocked(useStdout).mockClear(); + }); + it('renders committed transcript items through static output', () => { const { lastFrame } = render( { expect(lastFrame()).toContain('Use important'); }); + it('keeps live markdown formatting during streaming', () => { + const streamingBold: { role: Role; content: string } = { + role: ROLE.ASSISTANT, + content: 'Use **important** text', + }; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Use important text'); + expect(frame).not.toContain('**important**'); + }); + + it('keeps the streaming frame height stable when markdown reflows upward', () => { + const incompleteBold: { role: Role; content: string } = { + role: ROLE.ASSISTANT, + content: 'Use **important', + }; + const completeBold: { role: Role; content: string } = { + role: ROLE.ASSISTANT, + content: 'Use **important**', + }; + const tree = (streamingMessage: { role: Role; content: string }) => ( + + ); + + const { lastFrame, rerender } = render(tree(incompleteBold)); + const initialHeight = lineCount(lastFrame()); + + rerender(tree(completeBold)); + + expect(lineCount(lastFrame())).toBe(initialHeight); + expect(lastFrame()).toContain('Use important'); + }); + + it('recomputes sticky streaming height when the terminal width changes', () => { + const streamingBold: { role: Role; content: string } = { + role: ROLE.ASSISTANT, + content: 'Use **important** text', + }; + const tree = () => ( + + ); + + setTerminalWidth(100); + const { lastFrame, rerender } = render(tree()); + + setTerminalWidth(10); + rerender(tree()); + + expect(lastFrame()).toContain('Use'); + expect(lastFrame()).toContain('important'); + }); + it('renders code blocks with syntax highlighting', () => { const messageWithCode: { role: Role; content: string } = { role: ROLE.ASSISTANT, @@ -221,6 +318,78 @@ describe('Messages', () => { expect(lastFrame()).toContain('plain code'); }); + it('renders completed code blocks live while streaming', () => { + const streamingCode: { role: Role; content: string } = { + role: ROLE.ASSISTANT, + content: '```typescript\nconst x = 1;\n```', + }; + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('const x = 1;'); + }); + + it('renders ambiguous raw fenced blocks while streaming', () => { + const streamingRaw: { role: Role; content: string } = { + role: ROLE.ASSISTANT, + content: [ + 'Example:', + '```markdown', + '## Title', + '```ts', + 'const x = 1;', + '```', + '```', + 'Done.', + ].join('\n'), + }; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Example:'); + expect(frame).toContain('## Title'); + expect(frame).toContain('```ts'); + expect(frame).toContain('Done.'); + }); + + it('renders non-markdown raw fenced blocks while streaming', () => { + const streamingRaw: { role: Role; content: string } = { + role: ROLE.ASSISTANT, + content: [ + 'Shell example:', + '```sh', + 'echo start', + '```ts', + 'const x = 1;', + '```', + '```', + ].join('\n'), + }; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Shell example:'); + expect(frame).toContain('```sh'); + expect(frame).toContain('```ts'); + }); + it('renders multiple code blocks in one message', () => { const messageWithMultipleCode: { role: Role; content: string } = { role: ROLE.ASSISTANT, diff --git a/src/components/Messages/Messages.tsx b/src/components/Messages/Messages.tsx index 4d9bc27c..2701576c 100644 --- a/src/components/Messages/Messages.tsx +++ b/src/components/Messages/Messages.tsx @@ -1,5 +1,6 @@ import { Spinner } from '@inkjs/ui'; -import { Box, Static, Text } from 'ink'; +import { Box, Static, Text, useStdout } from 'ink'; +import { useRef } from 'react'; import { ROLE, UI } from '../../constants'; import type { Message as OllamaMessage } from '../../utils/ollama'; @@ -7,11 +8,13 @@ import { CodeBlock } from '../CodeBlock'; import { Markdown } from '../Markdown'; import { TURN_ABORTED_MESSAGE } from './constants'; import { - getMessageColor, - parseContent, - splitStreamingInlineContent, - unwrapRawMarkdownFence, -} from './utils'; + getAssistantContentWidth, + getCodeBlockHeight, + getStreamingTextHeight, +} from './layout'; +import { parseContent, unwrapRawMarkdownFence } from './parsing'; +import { splitStreamingInlineContent } from './streaming'; +import { getMessageColor } from './styles'; interface Props { messages: OllamaMessage[]; @@ -26,9 +29,18 @@ interface MessageProps { } export function Message({ message, isStreaming = false }: MessageProps) { + const { stdout } = useStdout(); const messageColor = getMessageColor(message.role); const isSystem = message.role === ROLE.SYSTEM; const isUser = message.role === ROLE.USER; + const isStreamingAssistant = isStreaming && !isUser && !isSystem; + const stickyHeightRef = useRef<{ + columns: number; + maxHeight: number; + }>({ + columns: stdout.columns, + maxHeight: 0, + }); // System messages: render raw content (preserves backticks, no parsing) if (isSystem) { @@ -42,6 +54,46 @@ export function Message({ message, isStreaming = false }: MessageProps) { } const segments = parseContent(message.content); + const availableWidth = getAssistantContentWidth(stdout.columns); + + if (stickyHeightRef.current.columns !== stdout.columns) { + stickyHeightRef.current = { + columns: stdout.columns, + maxHeight: 0, + }; + } + + const streamingHeight = isStreamingAssistant + ? segments.reduce((height, segment) => { + if (segment.type === 'code') { + return height + getCodeBlockHeight(segment.content, availableWidth); + } + + if (segment.type === 'raw') { + const markdownSource = unwrapRawMarkdownFence(segment.content); + return ( + height + + getCodeBlockHeight( + markdownSource ?? segment.content, + availableWidth, + ) + ); + } + + const textParts = splitStreamingInlineContent(segment.content); + return height + getStreamingTextHeight(textParts, availableWidth); + }, 0) + : 0; + + if (isStreamingAssistant) { + stickyHeightRef.current.maxHeight = Math.max( + stickyHeightRef.current.maxHeight, + streamingHeight, + ); + } + const stickyPaddingLines = isStreamingAssistant + ? stickyHeightRef.current.maxHeight - streamingHeight + : 0; return ( @@ -107,6 +159,10 @@ export function Message({ message, isStreaming = false }: MessageProps) { ); })} + + {Array.from({ length: stickyPaddingLines }, (_, index) => ( + + ))} ); } diff --git a/src/components/Messages/layout.test.ts b/src/components/Messages/layout.test.ts new file mode 100644 index 00000000..291fa2f4 --- /dev/null +++ b/src/components/Messages/layout.test.ts @@ -0,0 +1,45 @@ +import { + countWrappedLines, + getAssistantContentWidth, + getCodeBlockHeight, + getStreamingTextHeight, + stripAnsi, +} from './layout'; + +describe('message layout utilities', () => { + it('strips ANSI escape sequences', () => { + expect(stripAnsi('\u001B[36mhello\u001B[39m')).toBe('hello'); + }); + + it('counts wrapped lines for plain text', () => { + expect(countWrappedLines('abcdefghij', 4)).toBe(3); + }); + + it('counts explicit blank lines', () => { + expect(countWrappedLines('top\n\nbottom', 10)).toBe(3); + }); + + it('counts wrapped lines after ANSI is removed', () => { + expect(countWrappedLines('\u001B[36mabcdef\u001B[39m', 3)).toBe(2); + }); + + it('counts streaming text height across markdown and plain parts', () => { + expect( + getStreamingTextHeight( + [ + { type: 'markdown', content: 'Use **bold**' }, + { type: 'plain', content: 'tail' }, + ], + 20, + ), + ).toBe(2); + }); + + it('accounts for code block chrome in height', () => { + expect(getCodeBlockHeight('const x = 1;', 20)).toBe(5); + }); + + it('derives assistant content width from terminal columns', () => { + expect(getAssistantContentWidth(40)).toBe(36); + }); +}); diff --git a/src/components/Messages/layout.ts b/src/components/Messages/layout.ts new file mode 100644 index 00000000..a81ce8cb --- /dev/null +++ b/src/components/Messages/layout.ts @@ -0,0 +1,87 @@ +import { UI } from '../../constants'; +import { renderMarkdown as renderMarkdownToString } from '../Markdown/render'; + +const ANSI_REGEX = new RegExp(String.raw`\u001B\[[0-9;]*m`, 'g'); +const CODE_BLOCK_MARGIN_Y = 2; +const CODE_BLOCK_BORDER_Y = 2; +const CODE_BLOCK_CHROME_X = 4; + +export function stripAnsi(value: string): string { + return value.replaceAll(ANSI_REGEX, ''); +} + +function countLineWidth(value: string): number { + return Array.from(stripAnsi(value)).length; +} + +/** + * Counts the number of wrapped lines for a given content and width. + * + * This function splits the content by newlines and calculates how many lines + * each segment would wrap to based on the available width. + * + * @param content The text content to wrap. + * @param width The available width for wrapping. + * @returns The number of wrapped lines. + */ +export function countWrappedLines(content: string, width: number): number { + const safeWidth = Math.max(1, width); + + return content.split('\n').reduce((lineCount, line) => { + const visibleWidth = countLineWidth(line); + return lineCount + Math.max(1, Math.ceil(visibleWidth / safeWidth)); + }, 0); +} + +/** + * Calculates the height of a code block based on its content and width. + * + * This function accounts for margins, borders, and wrapped lines to determine + * the total height required for displaying a code block. + * + * @param content The code block content to render. + * @param width The available width for the code block. + * @returns The total height in lines. + */ +export function getCodeBlockHeight(content: string, width: number): number { + const contentWidth = Math.max(1, width - CODE_BLOCK_CHROME_X); + return ( + CODE_BLOCK_MARGIN_Y + + CODE_BLOCK_BORDER_Y + + countWrappedLines(content, contentWidth) + ); +} + +/** + * Calculates the total height of streaming text content based on wrapped lines. + * + * @param textParts Array of text parts with their content and type. + * @param width The available width for wrapping text. + * @returns The total height in lines. + */ +export function getStreamingTextHeight( + textParts: readonly { content: string; type: 'markdown' | 'plain' }[], + width: number, +): number { + return textParts.reduce((height, part) => { + const renderMarkdown: (content: string, hrWidth: number) => string = + renderMarkdownToString; + + const rendered = + part.type === 'markdown' + ? renderMarkdown(part.content, width) + : part.content; + + return height + countWrappedLines(rendered, width); + }, 0); +} + +/** + * Calculates the available width for assistant content after accounting for margins. + * + * @param columns The total number of columns in the terminal. + * @returns The available width for content (always at least 1). + */ +export function getAssistantContentWidth(columns: number): number { + return Math.max(1, columns - UI.AGENT_MARGIN_X * 2); +} diff --git a/src/components/Messages/parsing.ts b/src/components/Messages/parsing.ts new file mode 100644 index 00000000..349a39d0 --- /dev/null +++ b/src/components/Messages/parsing.ts @@ -0,0 +1,137 @@ +import { normalizeCodeBlockContent } from '../CodeBlock'; + +export interface ContentSegment { + type: 'text' | 'code' | 'raw'; + content: string; + language?: string; +} + +interface FenceState { + fence: string; + indent: string; + language?: string; + rawLines: string[]; + ambiguous: boolean; + rawFenceDepth: number; +} + +const FENCE_LINE_REGEX = + /^(?[ \t]*)(?`{3,})(?\w+)?[ \t]*$/; + +function flushTextSegment( + segments: ContentSegment[], + textLines: string[], +): void { + const textContent = textLines.join('\n').trim(); + if (textContent) { + segments.push({ type: 'text', content: textContent }); + } +} + +function flushCodeSegment( + segments: ContentSegment[], + codeLines: string[], + fenceState: FenceState, +): void { + if (fenceState.ambiguous) { + segments.push({ + type: 'raw', + content: fenceState.rawLines.join('\n'), + }); + return; + } + + const codeContent = normalizeCodeBlockContent( + codeLines.join('\n'), + fenceState.indent, + ); + if (codeContent) { + segments.push({ + type: 'code', + content: codeContent, + language: fenceState.language, + }); + } +} + +export function unwrapRawMarkdownFence(content: string): string | null { + if (!content.startsWith('```markdown\n') || !content.endsWith('\n```')) { + return null; + } + + return content.slice('```markdown\n'.length, -'\n```'.length); +} + +export function parseContent(content: string): ContentSegment[] { + const segments: ContentSegment[] = []; + const lines = content.split('\n'); + const textLines: string[] = []; + const codeLines: string[] = []; + let fenceState: FenceState | null = null; + + for (const line of lines) { + const fenceMatch = FENCE_LINE_REGEX.exec(line); + if (fenceMatch?.groups) { + const { indent, fence, language } = fenceMatch.groups; + + if (!fenceState) { + flushTextSegment(segments, textLines); + textLines.length = 0; + fenceState = { + indent, + fence, + language, + rawLines: [line], + ambiguous: false, + rawFenceDepth: 1, + }; + continue; + } + + if (indent === fenceState.indent && fence === fenceState.fence) { + fenceState.rawLines.push(line); + + if (fenceState.ambiguous) { + if (language) { + fenceState.rawFenceDepth += 1; + continue; + } + + fenceState.rawFenceDepth -= 1; + if (fenceState.rawFenceDepth === 0) { + flushCodeSegment(segments, codeLines, fenceState); + codeLines.length = 0; + fenceState = null; + } + continue; + } + + if (!language) { + flushCodeSegment(segments, codeLines, fenceState); + codeLines.length = 0; + fenceState = null; + continue; + } + + fenceState.ambiguous = true; + fenceState.rawFenceDepth += 1; + continue; + } + } + + if (fenceState) { + fenceState.rawLines.push(line); + codeLines.push(line); + } else { + textLines.push(line); + } + } + + if (fenceState) { + textLines.push(...fenceState.rawLines); + } + + flushTextSegment(segments, textLines); + + return segments; +} diff --git a/src/components/Messages/utils.test.ts b/src/components/Messages/streaming.test.ts similarity index 98% rename from src/components/Messages/utils.test.ts rename to src/components/Messages/streaming.test.ts index 49dbc740..6cf0523a 100644 --- a/src/components/Messages/utils.test.ts +++ b/src/components/Messages/streaming.test.ts @@ -1,4 +1,4 @@ -import { splitStreamingInlineContent } from './utils'; +import { splitStreamingInlineContent } from './streaming'; describe('splitStreamingInlineContent', () => { it('keeps complete inline code as markdown', () => { diff --git a/src/components/Messages/streaming.ts b/src/components/Messages/streaming.ts new file mode 100644 index 00000000..7278b150 --- /dev/null +++ b/src/components/Messages/streaming.ts @@ -0,0 +1,155 @@ +function isWordCharacter(char: string | undefined): boolean { + return char !== undefined && /[A-Za-z0-9]/.test(char); +} + +function isEscaped(content: string, index: number): boolean { + let slashCount = 0; + for ( + let cursor = index - 1; + cursor >= 0 && content[cursor] === '\\'; + cursor-- + ) { + slashCount += 1; + } + return slashCount % 2 === 1; +} + +interface UnmatchedDelimiter { + index: number; + length: number; +} + +function canOpenEmphasis( + content: string, + index: number, + length: number, +): boolean { + const previous = content[index - 1]; + const next = content[index + length]; + + if (!next || /\s/.test(next)) { + return false; + } + + return !isWordCharacter(previous); +} + +function canCloseEmphasis( + content: string, + index: number, + length: number, +): boolean { + const previous = content[index - 1]; + const next = content[index + length]; + + if (!previous || /\s/.test(previous)) { + return false; + } + + return !isWordCharacter(next); +} + +function findUnmatchedInlineDelimiter( + content: string, +): UnmatchedDelimiter | null { + type DelimiterKind = 'code' | 'latex' | 'italic' | 'bold'; + + const stack: (UnmatchedDelimiter & { + kind: DelimiterKind; + marker: string; + })[] = []; + + for (let index = 0; index < content.length; index += 1) { + const current = content[index]; + + if (isEscaped(content, index)) { + continue; + } + + const top = stack.at(-1); + + if (top?.kind === 'code') { + if (current === '`') { + stack.pop(); + } + continue; + } + + if (top?.kind === 'latex') { + if (current === '$') { + stack.pop(); + } + continue; + } + + if (current === '`') { + stack.push({ index, length: 1, kind: 'code', marker: '`' }); + continue; + } + + if (current === '$') { + stack.push({ index, length: 1, kind: 'latex', marker: '$' }); + continue; + } + + if (current !== '*') { + continue; + } + + const marker = current; + const next = content[index + 1]; + const length = next === marker ? 2 : 1; + const token = marker.repeat(length); + const kind: DelimiterKind = length === 2 ? 'bold' : 'italic'; + + if ( + top?.marker === token && + top.kind === kind && + canCloseEmphasis(content, index, length) + ) { + stack.pop(); + if (length === 2) { + index += 1; + } + continue; + } + + if (canOpenEmphasis(content, index, length)) { + stack.push({ index, length, kind, marker: token }); + if (length === 2) { + index += 1; + } + } + } + + return stack[0] ?? null; +} + +interface StreamingInlinePart { + content: string; + type: 'markdown' | 'plain'; +} + +export function splitStreamingInlineContent( + content: string, +): StreamingInlinePart[] { + const unmatched = findUnmatchedInlineDelimiter(content); + + if (!unmatched) { + return [{ type: 'markdown', content }]; + } + + const parts: StreamingInlinePart[] = []; + const prefix = content.slice(0, unmatched.index); + const plainSuffix = content.slice(unmatched.index + unmatched.length); + + if (prefix) { + parts.push({ type: 'markdown', content: prefix }); + } + + if (plainSuffix) { + parts.push({ type: 'plain', content: plainSuffix }); + } + + return parts; +} diff --git a/src/components/Messages/styles.ts b/src/components/Messages/styles.ts new file mode 100644 index 00000000..891bd195 --- /dev/null +++ b/src/components/Messages/styles.ts @@ -0,0 +1,14 @@ +import { ROLE } from '../../constants'; + +export function getMessageColor(role: string): string | undefined { + switch (role) { + case ROLE.USER: + return 'black'; + case ROLE.ASSISTANT: + return 'cyan'; + case ROLE.SYSTEM: + return 'gray'; + default: + return undefined; + } +} diff --git a/src/components/Messages/utils.ts b/src/components/Messages/utils.ts deleted file mode 100644 index e773fbf3..00000000 --- a/src/components/Messages/utils.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { ROLE } from '../../constants'; -import { normalizeCodeBlockContent } from '../CodeBlock'; - -export interface ContentSegment { - type: 'text' | 'code' | 'raw'; - content: string; - language?: string; -} - -interface FenceState { - fence: string; - indent: string; - language?: string; - rawLines: string[]; - ambiguous: boolean; - rawFenceDepth: number; -} - -const FENCE_LINE_REGEX = - /^(?[ \t]*)(?`{3,})(?\w+)?[ \t]*$/; - -function flushTextSegment( - segments: ContentSegment[], - textLines: string[], -): void { - const textContent = textLines.join('\n').trim(); - if (textContent) { - segments.push({ type: 'text', content: textContent }); - } -} - -function flushCodeSegment( - segments: ContentSegment[], - codeLines: string[], - fenceState: FenceState, -): void { - if (fenceState.ambiguous) { - segments.push({ - type: 'raw', - content: fenceState.rawLines.join('\n'), - }); - return; - } - - const codeContent = normalizeCodeBlockContent( - codeLines.join('\n'), - fenceState.indent, - ); - if (codeContent) { - segments.push({ - type: 'code', - content: codeContent, - language: fenceState.language, - }); - } -} - -export function unwrapRawMarkdownFence(content: string): string | null { - if (!content.startsWith('```markdown\n') || !content.endsWith('\n```')) { - return null; - } - - return content.slice('```markdown\n'.length, -'\n```'.length); -} - -export function parseContent(content: string): ContentSegment[] { - const segments: ContentSegment[] = []; - const lines = content.split('\n'); - const textLines: string[] = []; - const codeLines: string[] = []; - let fenceState: FenceState | null = null; - - for (const line of lines) { - const fenceMatch = FENCE_LINE_REGEX.exec(line); - if (fenceMatch?.groups) { - const { indent, fence, language } = fenceMatch.groups; - - if (!fenceState) { - flushTextSegment(segments, textLines); - textLines.length = 0; - fenceState = { - indent, - fence, - language, - rawLines: [line], - ambiguous: false, - rawFenceDepth: 1, - }; - continue; - } - - if (indent === fenceState.indent && fence === fenceState.fence) { - fenceState.rawLines.push(line); - - if (fenceState.ambiguous) { - if (language) { - fenceState.rawFenceDepth += 1; - continue; - } - - fenceState.rawFenceDepth -= 1; - if (fenceState.rawFenceDepth === 0) { - flushCodeSegment(segments, codeLines, fenceState); - codeLines.length = 0; - fenceState = null; - } - continue; - } - - if (!language) { - flushCodeSegment(segments, codeLines, fenceState); - codeLines.length = 0; - fenceState = null; - continue; - } - - fenceState.ambiguous = true; - fenceState.rawFenceDepth += 1; - continue; - } - } - - if (fenceState) { - fenceState.rawLines.push(line); - codeLines.push(line); - } else { - textLines.push(line); - } - } - - if (fenceState) { - textLines.push(...fenceState.rawLines); - } - - flushTextSegment(segments, textLines); - - return segments; -} - -export function getMessageColor(role: string): string | undefined { - switch (role) { - case ROLE.USER: - return 'black'; - case ROLE.ASSISTANT: - return 'cyan'; - case ROLE.SYSTEM: - return 'gray'; - default: - return undefined; - } -} - -function isWordCharacter(char: string | undefined): boolean { - return char !== undefined && /[A-Za-z0-9]/.test(char); -} - -function isEscaped(content: string, index: number): boolean { - let slashCount = 0; - for ( - let cursor = index - 1; - cursor >= 0 && content[cursor] === '\\'; - cursor-- - ) { - slashCount += 1; - } - return slashCount % 2 === 1; -} - -interface UnmatchedDelimiter { - index: number; - length: number; -} - -function canOpenEmphasis( - content: string, - index: number, - length: number, -): boolean { - const previous = content[index - 1]; - const next = content[index + length]; - - if (!next || /\s/.test(next)) { - return false; - } - - return !isWordCharacter(previous); -} - -function canCloseEmphasis( - content: string, - index: number, - length: number, -): boolean { - const previous = content[index - 1]; - const next = content[index + length]; - - if (!previous || /\s/.test(previous)) { - return false; - } - - return !isWordCharacter(next); -} - -function findUnmatchedInlineDelimiter( - content: string, -): UnmatchedDelimiter | null { - type DelimiterKind = 'code' | 'latex' | 'italic' | 'bold'; - - const stack: (UnmatchedDelimiter & { - kind: DelimiterKind; - marker: string; - })[] = []; - - for (let index = 0; index < content.length; index += 1) { - const current = content[index]; - - if (isEscaped(content, index)) { - continue; - } - - const top = stack.at(-1); - - if (top?.kind === 'code') { - if (current === '`') { - stack.pop(); - } - continue; - } - - if (top?.kind === 'latex') { - if (current === '$') { - stack.pop(); - } - continue; - } - - if (current === '`') { - stack.push({ index, length: 1, kind: 'code', marker: '`' }); - continue; - } - - if (current === '$') { - stack.push({ index, length: 1, kind: 'latex', marker: '$' }); - continue; - } - - if (current !== '*') { - continue; - } - - const marker = current; - const next = content[index + 1]; - const length = next === marker ? 2 : 1; - const token = marker.repeat(length); - const kind: DelimiterKind = length === 2 ? 'bold' : 'italic'; - - if ( - top?.marker === token && - top.kind === kind && - canCloseEmphasis(content, index, length) - ) { - stack.pop(); - if (length === 2) { - index += 1; - } - continue; - } - - if (canOpenEmphasis(content, index, length)) { - stack.push({ index, length, kind, marker: token }); - if (length === 2) { - index += 1; - } - } - } - - return stack[0] ?? null; -} - -interface StreamingInlinePart { - content: string; - type: 'markdown' | 'plain'; -} - -export function splitStreamingInlineContent( - content: string, -): StreamingInlinePart[] { - const unmatched = findUnmatchedInlineDelimiter(content); - - if (!unmatched) { - return [{ type: 'markdown', content }]; - } - - const parts: StreamingInlinePart[] = []; - const prefix = content.slice(0, unmatched.index); - const plainSuffix = content.slice(unmatched.index + unmatched.length); - - if (prefix) { - parts.push({ type: 'markdown', content: prefix }); - } - - if (plainSuffix) { - parts.push({ type: 'plain', content: plainSuffix }); - } - - return parts; -}