1- import { Text } from 'ink' ;
1+ import { Text , useStdout } from 'ink' ;
22import { render } from 'ink-testing-library' ;
33
44import { ROLE , UI } from '../../constants' ;
55import type { Role } from '../../types' ;
66import { TURN_ABORTED_MESSAGE } from './constants' ;
77import { 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+
924vi . 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+
3760describe ( '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