Skip to content

Commit bb13281

Browse files
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.
1 parent cd33738 commit bb13281

6 files changed

Lines changed: 327 additions & 4 deletions

File tree

src/components/Markdown/Markdown.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ interface MarkdownProps {
1414

1515
const HR_PLACEHOLDER = '__CODE_OLLAMA_HR_PLACEHOLDER__';
1616

17-
function renderMarkdown(content: string, hrWidth: number): string {
17+
export function renderMarkdown(content: string, hrWidth: number): string {
1818
const hr = UI.MARKDOWN_HR_CHARACTER.repeat(Math.max(1, hrWidth));
1919
const markdown = new Marked();
2020
const rendererExtension = {

src/components/Markdown/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { Markdown } from './Markdown';
1+
export { Markdown, renderMarkdown } from './Markdown';

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,

src/components/Messages/Messages.tsx

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { Spinner } from '@inkjs/ui';
2-
import { Box, Static, Text } from 'ink';
2+
import { Box, Static, Text, useStdout } from 'ink';
3+
import { useRef } from 'react';
34

45
import { ROLE, UI } from '../../constants';
56
import type { Message as OllamaMessage } from '../../utils/ollama';
67
import { CodeBlock } from '../CodeBlock';
78
import { Markdown } from '../Markdown';
89
import { TURN_ABORTED_MESSAGE } from './constants';
10+
import {
11+
getAssistantContentWidth,
12+
getCodeBlockHeight,
13+
getStreamingTextHeight,
14+
} from './layout';
915
import {
1016
getMessageColor,
1117
parseContent,
@@ -26,9 +32,18 @@ interface MessageProps {
2632
}
2733

2834
export function Message({ message, isStreaming = false }: MessageProps) {
35+
const { stdout } = useStdout();
2936
const messageColor = getMessageColor(message.role);
3037
const isSystem = message.role === ROLE.SYSTEM;
3138
const isUser = message.role === ROLE.USER;
39+
const isStreamingAssistant = isStreaming && !isUser && !isSystem;
40+
const stickyHeightRef = useRef<{
41+
columns: number;
42+
maxHeight: number;
43+
}>({
44+
columns: stdout.columns,
45+
maxHeight: 0,
46+
});
3247

3348
// System messages: render raw content (preserves backticks, no parsing)
3449
if (isSystem) {
@@ -42,6 +57,46 @@ export function Message({ message, isStreaming = false }: MessageProps) {
4257
}
4358

4459
const segments = parseContent(message.content);
60+
const availableWidth = getAssistantContentWidth(stdout.columns);
61+
62+
if (stickyHeightRef.current.columns !== stdout.columns) {
63+
stickyHeightRef.current = {
64+
columns: stdout.columns,
65+
maxHeight: 0,
66+
};
67+
}
68+
69+
const streamingHeight = isStreamingAssistant
70+
? segments.reduce((height, segment) => {
71+
if (segment.type === 'code') {
72+
return height + getCodeBlockHeight(segment.content, availableWidth);
73+
}
74+
75+
if (segment.type === 'raw') {
76+
const markdownSource = unwrapRawMarkdownFence(segment.content);
77+
return (
78+
height +
79+
getCodeBlockHeight(
80+
markdownSource ?? segment.content,
81+
availableWidth,
82+
)
83+
);
84+
}
85+
86+
const textParts = splitStreamingInlineContent(segment.content);
87+
return height + getStreamingTextHeight(textParts, availableWidth);
88+
}, 0)
89+
: 0;
90+
91+
if (isStreamingAssistant) {
92+
stickyHeightRef.current.maxHeight = Math.max(
93+
stickyHeightRef.current.maxHeight,
94+
streamingHeight,
95+
);
96+
}
97+
const stickyPaddingLines = isStreamingAssistant
98+
? stickyHeightRef.current.maxHeight - streamingHeight
99+
: 0;
45100

46101
return (
47102
<Box flexDirection="column" marginBottom={1}>
@@ -107,6 +162,10 @@ export function Message({ message, isStreaming = false }: MessageProps) {
107162
</Box>
108163
);
109164
})}
165+
166+
{Array.from({ length: stickyPaddingLines }, (_, index) => (
167+
<Text key={'padding-' + String(index)}> </Text>
168+
))}
110169
</Box>
111170
);
112171
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {
2+
countWrappedLines,
3+
getAssistantContentWidth,
4+
getCodeBlockHeight,
5+
getStreamingTextHeight,
6+
stripAnsi,
7+
} from './layout';
8+
9+
describe('message layout utilities', () => {
10+
it('strips ANSI escape sequences', () => {
11+
expect(stripAnsi('\u001B[36mhello\u001B[39m')).toBe('hello');
12+
});
13+
14+
it('counts wrapped lines for plain text', () => {
15+
expect(countWrappedLines('abcdefghij', 4)).toBe(3);
16+
});
17+
18+
it('counts explicit blank lines', () => {
19+
expect(countWrappedLines('top\n\nbottom', 10)).toBe(3);
20+
});
21+
22+
it('counts wrapped lines after ANSI is removed', () => {
23+
expect(countWrappedLines('\u001B[36mabcdef\u001B[39m', 3)).toBe(2);
24+
});
25+
26+
it('counts streaming text height across markdown and plain parts', () => {
27+
expect(
28+
getStreamingTextHeight(
29+
[
30+
{ type: 'markdown', content: 'Use **bold**' },
31+
{ type: 'plain', content: 'tail' },
32+
],
33+
20,
34+
),
35+
).toBe(2);
36+
});
37+
38+
it('accounts for code block chrome in height', () => {
39+
expect(getCodeBlockHeight('const x = 1;', 20)).toBe(5);
40+
});
41+
42+
it('derives assistant content width from terminal columns', () => {
43+
expect(getAssistantContentWidth(40)).toBe(36);
44+
});
45+
});

0 commit comments

Comments
 (0)