From bb1328143dee8f045a5960f728c0077568f34009 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 15 May 2026 01:14:28 -0400 Subject: [PATCH 1/3] fix(Markdown): implement sticky-height streaming to fix layout jump Streaming assistant messages now keep a maximum observed rendered height and add temporary blank rows when live markdown causes content to reflow upward, so later transcript content no longer jumps. Factored the line/height math into `src/components/Messages/layout.ts` and exported the markdown terminal renderer from `src/components/Markdown/Markdown.tsx` via `src/components/Markdown/index.ts`. --- Preserve Live Markdown and Prevent Transcript Jump Summary: Keep live markdown rendering for streaming assistant messages and prevent surrounding layout from jumping upward by giving the active streaming message a sticky minimum height. The fix should preserve the current live-rendered markdown/code behavior, but when a rerender would make the streaming message shorter because content reflowed upward, the component should pad the message so it keeps its previous on-screen height until the stream finishes. Implementation Changes: - Add a dedicated wrapper around the active streaming assistant message in `src/components/Messages/Messages.tsx`. - Track a sticky height for the current streaming message instance: - compute the rendered line count of the streaming message after markdown/code rendering - store the maximum line count seen so far for that in-flight message - if the newest rendered output is shorter than the stored maximum, append blank lines so the wrapper still occupies the stored maximum height - Apply sticky-height behavior only to `streamingMessage`. - Do not change committed transcript items rendered through `Static` - Do not change user/system message behavior - Reset sticky height when any of these happen: - the streaming message is committed and removed - a different streaming message starts - `sessionId` changes - terminal width changes - Keep the existing live markdown/code rendering path intact: - assistant prose still renders through `Markdown` - completed code fences still render through `CodeBlock` - existing streaming inline markdown behavior stays unchanged Measurement Strategy: - Measure rendered height from the final display string that Ink will wrap, not from raw markdown source. - Add a shared line-count utility for terminal output that: - strips ANSI escape sequences before width measurement - splits on explicit newlines - counts wrapped rows using the current available content width - treats empty lines as occupying one row - Use the same available width assumptions already used by `Markdown`: - terminal columns from `useStdout()` - minus horizontal margins used for assistant message containers - For code blocks: - count visible rows from the rendered code block content plus its surrounding border/padding rows - use the same width calculation the `CodeBlock` container effectively occupies - Keep the measurement utility local to the message-rendering subsystem unless another component clearly needs it. Public Interfaces / Behavior: - No CLI/API/type changes. - User-visible behavior: - live markdown rendering remains enabled during streaming - when inline markdown completion causes content to reflow upward, later transcript content and the input area should no longer jump upward during that stream - temporary blank space may appear below the active streaming assistant message until the stream completes - once committed, the final message renders normally with no sticky padding retained Assumptions: - Live markdown during streaming is a hard requirement and should not be reduced. - The priority is preventing upward movement of surrounding layout, not preventing the streamed text itself from reflowing internally. - Temporary blank space under the active streaming message is acceptable during streaming if it removes transcript/input jump. - Terminal-width changes should invalidate prior sticky height rather than trying to preserve it across different wrap widths. --- src/components/Markdown/Markdown.tsx | 2 +- src/components/Markdown/index.ts | 2 +- src/components/Messages/Messages.test.tsx | 171 +++++++++++++++++++++- src/components/Messages/Messages.tsx | 61 +++++++- src/components/Messages/layout.test.ts | 45 ++++++ src/components/Messages/layout.ts | 50 +++++++ 6 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 src/components/Messages/layout.test.ts create mode 100644 src/components/Messages/layout.ts diff --git a/src/components/Markdown/Markdown.tsx b/src/components/Markdown/Markdown.tsx index 588622cd..5ad382ff 100644 --- a/src/components/Markdown/Markdown.tsx +++ b/src/components/Markdown/Markdown.tsx @@ -14,7 +14,7 @@ interface MarkdownProps { const HR_PLACEHOLDER = '__CODE_OLLAMA_HR_PLACEHOLDER__'; -function renderMarkdown(content: string, hrWidth: number): string { +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 = { diff --git a/src/components/Markdown/index.ts b/src/components/Markdown/index.ts index d2d6e45c..994eff2f 100644 --- a/src/components/Markdown/index.ts +++ b/src/components/Markdown/index.ts @@ -1 +1 @@ -export { Markdown } from './Markdown'; +export { Markdown, renderMarkdown } from './Markdown'; 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..0b7711f3 100644 --- a/src/components/Messages/Messages.tsx +++ b/src/components/Messages/Messages.tsx @@ -1,11 +1,17 @@ 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'; import { CodeBlock } from '../CodeBlock'; import { Markdown } from '../Markdown'; import { TURN_ABORTED_MESSAGE } from './constants'; +import { + getAssistantContentWidth, + getCodeBlockHeight, + getStreamingTextHeight, +} from './layout'; import { getMessageColor, parseContent, @@ -26,9 +32,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 +57,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 +162,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..e6c29c06 --- /dev/null +++ b/src/components/Messages/layout.ts @@ -0,0 +1,50 @@ +import { UI } from '../../constants'; +import { renderMarkdown } from '../Markdown'; + +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; +} + +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); +} + +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) + ); +} + +export function getStreamingTextHeight( + textParts: readonly { content: string; type: 'markdown' | 'plain' }[], + width: number, +): number { + return textParts.reduce((height, part) => { + const rendered = + part.type === 'markdown' + ? renderMarkdown(part.content, width) + : part.content; + return height + countWrappedLines(rendered, width); + }, 0); +} + +export function getAssistantContentWidth(columns: number): number { + return Math.max(1, columns - UI.AGENT_MARGIN_X * 2); +} From 4cecaa9aa20f54859014f70fd496e3647a07c521 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 15 May 2026 01:23:40 -0400 Subject: [PATCH 2/3] refactor(Messages): split utils into focused modules - src/components/Messages/parsing.ts - src/components/Messages/streaming.ts - src/components/Messages/styles.ts --- src/components/Messages/Messages.tsx | 9 +- src/components/Messages/parsing.ts | 137 ++++++++ .../{utils.test.ts => streaming.test.ts} | 2 +- src/components/Messages/streaming.ts | 155 +++++++++ src/components/Messages/styles.ts | 14 + src/components/Messages/utils.ts | 307 ------------------ 6 files changed, 310 insertions(+), 314 deletions(-) create mode 100644 src/components/Messages/parsing.ts rename src/components/Messages/{utils.test.ts => streaming.test.ts} (98%) create mode 100644 src/components/Messages/streaming.ts create mode 100644 src/components/Messages/styles.ts delete mode 100644 src/components/Messages/utils.ts diff --git a/src/components/Messages/Messages.tsx b/src/components/Messages/Messages.tsx index 0b7711f3..2701576c 100644 --- a/src/components/Messages/Messages.tsx +++ b/src/components/Messages/Messages.tsx @@ -12,12 +12,9 @@ import { getCodeBlockHeight, getStreamingTextHeight, } from './layout'; -import { - getMessageColor, - parseContent, - splitStreamingInlineContent, - unwrapRawMarkdownFence, -} from './utils'; +import { parseContent, unwrapRawMarkdownFence } from './parsing'; +import { splitStreamingInlineContent } from './streaming'; +import { getMessageColor } from './styles'; interface Props { messages: OllamaMessage[]; 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; -} From dd150236c9b50bfd6d13b2d9ec69e8dbcc904e58 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 15 May 2026 01:44:28 -0400 Subject: [PATCH 3/3] refactor(markdown): extract renderMarkdown to render.ts Refactored the markdown renderer into `src/components/Markdown/render.ts`, leaving `src/components/Markdown/Markdown.tsx` as the thin Ink component wrapper. --- src/components/Markdown/Markdown.tsx | 50 +-------------------------- src/components/Markdown/index.ts | 2 +- src/components/Markdown/render.ts | 51 ++++++++++++++++++++++++++++ src/components/Messages/layout.ts | 39 ++++++++++++++++++++- 4 files changed, 91 insertions(+), 51 deletions(-) create mode 100644 src/components/Markdown/render.ts diff --git a/src/components/Markdown/Markdown.tsx b/src/components/Markdown/Markdown.tsx index 5ad382ff..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__'; - -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 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/index.ts b/src/components/Markdown/index.ts index 994eff2f..d2d6e45c 100644 --- a/src/components/Markdown/index.ts +++ b/src/components/Markdown/index.ts @@ -1 +1 @@ -export { Markdown, renderMarkdown } from './Markdown'; +export { Markdown } from './Markdown'; 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/layout.ts b/src/components/Messages/layout.ts index e6c29c06..a81ce8cb 100644 --- a/src/components/Messages/layout.ts +++ b/src/components/Messages/layout.ts @@ -1,5 +1,5 @@ import { UI } from '../../constants'; -import { renderMarkdown } from '../Markdown'; +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; @@ -14,6 +14,16 @@ 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); @@ -23,6 +33,16 @@ export function countWrappedLines(content: string, width: number): number { }, 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 ( @@ -32,19 +52,36 @@ export function getCodeBlockHeight(content: string, width: number): number { ); } +/** + * 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); }