Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 1 addition & 49 deletions src/components/Markdown/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,63 +1,15 @@
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;
color?: string;
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<Marked['use']>[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,
Expand Down
51 changes: 51 additions & 0 deletions src/components/Markdown/render.ts
Original file line number Diff line number Diff line change
@@ -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<Marked['use']>[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;
}
}
171 changes: 170 additions & 1 deletion src/components/Messages/Messages.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import { Text } from 'ink';
import { Text, useStdout } from 'ink';
import { render } from 'ink-testing-library';

import { ROLE, UI } from '../../constants';
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 }) => <Text>{`⏳${label ?? ''}`}</Text>,
}));
Expand Down Expand Up @@ -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(
<Messages
Expand Down Expand Up @@ -197,6 +225,75 @@ describe('Messages', () => {
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(
<Messages
messages={[]}
isLoading={true}
sessionId=""
streamingMessage={streamingBold}
/>,
);
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 }) => (
<Messages
messages={[]}
isLoading={true}
sessionId=""
streamingMessage={streamingMessage}
/>
);

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 = () => (
<Messages
messages={[]}
isLoading={true}
sessionId=""
streamingMessage={streamingBold}
/>
);

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,
Expand All @@ -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(
<Messages
messages={[]}
isLoading={true}
sessionId=""
streamingMessage={streamingCode}
/>,
);
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(
<Messages
messages={[]}
isLoading={true}
sessionId=""
streamingMessage={streamingRaw}
/>,
);
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(
<Messages
messages={[]}
isLoading={true}
sessionId=""
streamingMessage={streamingRaw}
/>,
);
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,
Expand Down
Loading