Skip to content

Commit 1603941

Browse files
Merge pull request #75 from ai-action/fix/layout
2 parents cd33738 + dd15023 commit 1603941

11 files changed

Lines changed: 723 additions & 364 deletions

File tree

src/components/Markdown/Markdown.tsx

Lines changed: 1 addition & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,15 @@
11
import { Text, useStdout } from 'ink';
2-
import { Marked, type Token } from 'marked';
3-
import { markedTerminal } from 'marked-terminal';
42
import { memo, useMemo } from 'react';
53

64
import { UI } from '../../constants';
7-
import { inlineMathExtension } from './extensions';
5+
import { renderMarkdown } from './render';
86

97
interface MarkdownProps {
108
content: string;
119
color?: string;
1210
dimColor?: boolean;
1311
}
1412

15-
const HR_PLACEHOLDER = '__CODE_OLLAMA_HR_PLACEHOLDER__';
16-
17-
function renderMarkdown(content: string, hrWidth: number): string {
18-
const hr = UI.MARKDOWN_HR_CHARACTER.repeat(Math.max(1, hrWidth));
19-
const markdown = new Marked();
20-
const rendererExtension = {
21-
extensions: [inlineMathExtension],
22-
useNewRenderer: true,
23-
renderer: {
24-
hr: () => `${HR_PLACEHOLDER}\n`,
25-
text(token: Token) {
26-
const textToken = token as Token & {
27-
text?: string;
28-
tokens?: Token[];
29-
};
30-
31-
if (typeof token === 'object' && Array.isArray(textToken.tokens)) {
32-
return this.parser.parseInline(textToken.tokens);
33-
}
34-
35-
return String(textToken.text);
36-
},
37-
},
38-
} as Parameters<Marked['use']>[0];
39-
40-
markdown.use(
41-
markedTerminal({
42-
theme: 'gitHub',
43-
reflowText: true,
44-
width: Math.max(1, hrWidth),
45-
}),
46-
);
47-
48-
markdown.use(rendererExtension);
49-
50-
try {
51-
const result = markdown.parse(content);
52-
// v8 ignore start
53-
const text = typeof result === 'string' ? result.trim() : content;
54-
return text.replaceAll(HR_PLACEHOLDER, hr);
55-
} catch {
56-
return content;
57-
}
58-
// v8 ignore stop
59-
}
60-
6113
export const Markdown = memo(function Markdown({
6214
content,
6315
color,

src/components/Markdown/render.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Marked, type Token } from 'marked';
2+
import { markedTerminal } from 'marked-terminal';
3+
4+
import { UI } from '../../constants';
5+
import { inlineMathExtension } from './extensions';
6+
7+
const HR_PLACEHOLDER = '__CODE_OLLAMA_HR_PLACEHOLDER__';
8+
9+
export function renderMarkdown(content: string, hrWidth: number): string {
10+
const hr = UI.MARKDOWN_HR_CHARACTER.repeat(Math.max(1, hrWidth));
11+
const markdown = new Marked();
12+
const rendererExtension = {
13+
extensions: [inlineMathExtension],
14+
useNewRenderer: true,
15+
renderer: {
16+
hr: () => `${HR_PLACEHOLDER}\n`,
17+
text(token: Token) {
18+
const textToken = token as Token & {
19+
text?: string;
20+
tokens?: Token[];
21+
};
22+
23+
if (typeof token === 'object' && Array.isArray(textToken.tokens)) {
24+
return this.parser.parseInline(textToken.tokens);
25+
}
26+
27+
return String(textToken.text);
28+
},
29+
},
30+
} as Parameters<Marked['use']>[0];
31+
32+
markdown.use(
33+
markedTerminal({
34+
theme: 'gitHub',
35+
reflowText: true,
36+
width: Math.max(1, hrWidth),
37+
}),
38+
);
39+
40+
markdown.use(rendererExtension);
41+
42+
try {
43+
const result = markdown.parse(content);
44+
// v8 ignore next
45+
const text = typeof result === 'string' ? result.trim() : content;
46+
return text.replaceAll(HR_PLACEHOLDER, hr);
47+
} catch {
48+
// v8 ignore next
49+
return content;
50+
}
51+
}

src/components/Messages/Messages.test.tsx

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
1-
import { Text } from 'ink';
1+
import { Text, useStdout } from 'ink';
22
import { render } from 'ink-testing-library';
33

44
import { ROLE, UI } from '../../constants';
55
import type { Role } from '../../types';
66
import { TURN_ABORTED_MESSAGE } from './constants';
77
import { Messages } from './Messages';
88

9+
const { mockColumns } = vi.hoisted(() => ({
10+
mockColumns: {
11+
value: 100,
12+
},
13+
}));
14+
15+
vi.mock('ink', async () => ({
16+
...(await vi.importActual('ink')),
17+
useStdout: vi.fn(() => ({
18+
stdout: {
19+
columns: mockColumns.value,
20+
},
21+
})),
22+
}));
23+
924
vi.mock('@inkjs/ui', () => ({
1025
Spinner: ({ label }: { label?: string }) => <Text>{`⏳${label ?? ''}`}</Text>,
1126
}));
@@ -34,7 +49,20 @@ const systemMessage: { role: Role; content: string } = {
3449
content: 'system info',
3550
};
3651

52+
function setTerminalWidth(columns: number) {
53+
mockColumns.value = columns;
54+
}
55+
56+
function lineCount(frame: string | undefined) {
57+
return (frame ?? '').split('\n').length;
58+
}
59+
3760
describe('Messages', () => {
61+
beforeEach(() => {
62+
setTerminalWidth(100);
63+
vi.mocked(useStdout).mockClear();
64+
});
65+
3866
it('renders committed transcript items through static output', () => {
3967
const { lastFrame } = render(
4068
<Messages
@@ -197,6 +225,75 @@ describe('Messages', () => {
197225
expect(lastFrame()).toContain('Use important');
198226
});
199227

228+
it('keeps live markdown formatting during streaming', () => {
229+
const streamingBold: { role: Role; content: string } = {
230+
role: ROLE.ASSISTANT,
231+
content: 'Use **important** text',
232+
};
233+
const { lastFrame } = render(
234+
<Messages
235+
messages={[]}
236+
isLoading={true}
237+
sessionId=""
238+
streamingMessage={streamingBold}
239+
/>,
240+
);
241+
const frame = lastFrame() ?? '';
242+
expect(frame).toContain('Use important text');
243+
expect(frame).not.toContain('**important**');
244+
});
245+
246+
it('keeps the streaming frame height stable when markdown reflows upward', () => {
247+
const incompleteBold: { role: Role; content: string } = {
248+
role: ROLE.ASSISTANT,
249+
content: 'Use **important',
250+
};
251+
const completeBold: { role: Role; content: string } = {
252+
role: ROLE.ASSISTANT,
253+
content: 'Use **important**',
254+
};
255+
const tree = (streamingMessage: { role: Role; content: string }) => (
256+
<Messages
257+
messages={[]}
258+
isLoading={true}
259+
sessionId=""
260+
streamingMessage={streamingMessage}
261+
/>
262+
);
263+
264+
const { lastFrame, rerender } = render(tree(incompleteBold));
265+
const initialHeight = lineCount(lastFrame());
266+
267+
rerender(tree(completeBold));
268+
269+
expect(lineCount(lastFrame())).toBe(initialHeight);
270+
expect(lastFrame()).toContain('Use important');
271+
});
272+
273+
it('recomputes sticky streaming height when the terminal width changes', () => {
274+
const streamingBold: { role: Role; content: string } = {
275+
role: ROLE.ASSISTANT,
276+
content: 'Use **important** text',
277+
};
278+
const tree = () => (
279+
<Messages
280+
messages={[]}
281+
isLoading={true}
282+
sessionId=""
283+
streamingMessage={streamingBold}
284+
/>
285+
);
286+
287+
setTerminalWidth(100);
288+
const { lastFrame, rerender } = render(tree());
289+
290+
setTerminalWidth(10);
291+
rerender(tree());
292+
293+
expect(lastFrame()).toContain('Use');
294+
expect(lastFrame()).toContain('important');
295+
});
296+
200297
it('renders code blocks with syntax highlighting', () => {
201298
const messageWithCode: { role: Role; content: string } = {
202299
role: ROLE.ASSISTANT,
@@ -221,6 +318,78 @@ describe('Messages', () => {
221318
expect(lastFrame()).toContain('plain code');
222319
});
223320

321+
it('renders completed code blocks live while streaming', () => {
322+
const streamingCode: { role: Role; content: string } = {
323+
role: ROLE.ASSISTANT,
324+
content: '```typescript\nconst x = 1;\n```',
325+
};
326+
const { lastFrame } = render(
327+
<Messages
328+
messages={[]}
329+
isLoading={true}
330+
sessionId=""
331+
streamingMessage={streamingCode}
332+
/>,
333+
);
334+
expect(lastFrame()).toContain('const x = 1;');
335+
});
336+
337+
it('renders ambiguous raw fenced blocks while streaming', () => {
338+
const streamingRaw: { role: Role; content: string } = {
339+
role: ROLE.ASSISTANT,
340+
content: [
341+
'Example:',
342+
'```markdown',
343+
'## Title',
344+
'```ts',
345+
'const x = 1;',
346+
'```',
347+
'```',
348+
'Done.',
349+
].join('\n'),
350+
};
351+
const { lastFrame } = render(
352+
<Messages
353+
messages={[]}
354+
isLoading={true}
355+
sessionId=""
356+
streamingMessage={streamingRaw}
357+
/>,
358+
);
359+
const frame = lastFrame() ?? '';
360+
expect(frame).toContain('Example:');
361+
expect(frame).toContain('## Title');
362+
expect(frame).toContain('```ts');
363+
expect(frame).toContain('Done.');
364+
});
365+
366+
it('renders non-markdown raw fenced blocks while streaming', () => {
367+
const streamingRaw: { role: Role; content: string } = {
368+
role: ROLE.ASSISTANT,
369+
content: [
370+
'Shell example:',
371+
'```sh',
372+
'echo start',
373+
'```ts',
374+
'const x = 1;',
375+
'```',
376+
'```',
377+
].join('\n'),
378+
};
379+
const { lastFrame } = render(
380+
<Messages
381+
messages={[]}
382+
isLoading={true}
383+
sessionId=""
384+
streamingMessage={streamingRaw}
385+
/>,
386+
);
387+
const frame = lastFrame() ?? '';
388+
expect(frame).toContain('Shell example:');
389+
expect(frame).toContain('```sh');
390+
expect(frame).toContain('```ts');
391+
});
392+
224393
it('renders multiple code blocks in one message', () => {
225394
const messageWithMultipleCode: { role: Role; content: string } = {
226395
role: ROLE.ASSISTANT,

0 commit comments

Comments
 (0)