Skip to content

Commit 6b86ba9

Browse files
committed
Enhance streaming scroll stability
1 parent cec0be4 commit 6b86ba9

File tree

11 files changed

+1022
-742
lines changed

11 files changed

+1022
-742
lines changed

cli/knowledge.md

Lines changed: 27 additions & 500 deletions
Large diffs are not rendered by default.

cli/src/chat.tsx

Lines changed: 406 additions & 125 deletions
Large diffs are not rendered by default.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React, { useCallback, useEffect, useRef } from 'react'
2+
import { Box, measureElement } from 'ink'
3+
4+
interface MeasuredMessageProps {
5+
messageId: string
6+
onHeightChange: (messageId: string, height: number) => void
7+
children: React.ReactNode
8+
}
9+
10+
/**
11+
* Wraps a chat message and reports its rendered height in terminal rows.
12+
* Measurement runs after every render, but only propagates when the height changes.
13+
*/
14+
export const MeasuredMessage: React.FC<MeasuredMessageProps> = ({
15+
messageId,
16+
onHeightChange,
17+
children,
18+
}) => {
19+
const containerRef = useRef<any>(null)
20+
const lastHeightRef = useRef<number | null>(null)
21+
22+
const measure = useCallback(() => {
23+
if (!containerRef.current) {
24+
return
25+
}
26+
27+
try {
28+
const { height } = measureElement(containerRef.current)
29+
const safeHeight =
30+
typeof height === 'number' && !Number.isNaN(height) ? height : 0
31+
32+
if (safeHeight > 0 && safeHeight !== lastHeightRef.current) {
33+
lastHeightRef.current = safeHeight
34+
onHeightChange(messageId, safeHeight)
35+
}
36+
} catch {
37+
// Ink can throw if measurement happens before layout completes; ignore and retry next render.
38+
}
39+
}, [messageId, onHeightChange])
40+
41+
useEffect(() => {
42+
const timer = setTimeout(measure, 0)
43+
return () => {
44+
clearTimeout(timer)
45+
}
46+
})
47+
48+
return (
49+
<Box ref={containerRef} flexDirection="column">
50+
{children}
51+
</Box>
52+
)
53+
}

cli/src/components/message-block.tsx

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { type ReactNode } from 'react'
2-
import { Box, Text } from 'ink'
2+
import { Box, Text, Static } from 'ink'
33

44
import { BranchItem } from './branch-item'
55
import { getToolDisplayInfo } from '../utils/codebuff-client'
@@ -28,8 +28,6 @@ interface MessageBlockProps {
2828
isLoading: boolean
2929
timestamp: string
3030
isComplete?: boolean
31-
completionTime?: string
32-
credits?: number
3331
theme: ChatTheme
3432
textColor: string
3533
timestampColor: string
@@ -40,6 +38,7 @@ interface MessageBlockProps {
4038
streamingAgents: Set<string>
4139
onToggleCollapsed: (id: string) => void
4240
registerAgentRef: (id: string, element: any) => void
41+
committedSegments?: Array<{ id: string; content: string }>
4342
}
4443

4544
export const MessageBlock = ({
@@ -51,8 +50,6 @@ export const MessageBlock = ({
5150
isLoading,
5251
timestamp,
5352
isComplete,
54-
completionTime,
55-
credits,
5653
theme,
5754
textColor,
5855
timestampColor,
@@ -63,6 +60,7 @@ export const MessageBlock = ({
6360
streamingAgents,
6461
onToggleCollapsed,
6562
registerAgentRef,
63+
committedSegments = [],
6664
}: MessageBlockProps): ReactNode => {
6765
const computeBranchChar = (indentLevel: number, isLastBranch: boolean) =>
6866
`${' '.repeat(indentLevel)}${isLastBranch ? '└─ ' : '├─ '}`
@@ -312,14 +310,31 @@ export const MessageBlock = ({
312310
}
313311

314312
return (
315-
<>
316-
{isUser && (
317-
<Text
318-
color={timestampColor}
319-
dimColor
313+
<Box flexDirection="column" width="100%">
314+
{committedSegments.length > 0 && (
315+
<Static items={committedSegments}>
316+
{(segment) => (
317+
<Text key={segment.id} color={textColor}>
318+
{segment.content}
319+
</Text>
320+
)}
321+
</Static>
322+
)}
323+
{isUser && timestamp && (
324+
<Static
325+
items={[
326+
{
327+
id: `${messageId}-timestamp`,
328+
content: `\n[${timestamp}]`,
329+
},
330+
]}
320331
>
321-
{`[${timestamp}]`}
322-
</Text>
332+
{(segment) => (
333+
<Text key={segment.id} color={timestampColor} dimColor>
334+
{segment.content}
335+
</Text>
336+
)}
337+
</Static>
323338
)}
324339
{blocks ? (
325340
<Box flexDirection="column" width="100%">
@@ -365,7 +380,8 @@ export const MessageBlock = ({
365380
return null
366381
})}
367382
</Box>
368-
) : (
383+
) :
384+
committedSegments.length > 0 ? null : (
369385
(() => {
370386
const isStreamingMessage = isLoading || !isComplete
371387
const normalizedContent = isStreamingMessage
@@ -388,20 +404,6 @@ export const MessageBlock = ({
388404
)
389405
})()
390406
)}
391-
{isAi && isComplete && (completionTime || credits) && (
392-
<Box flexDirection="column">
393-
{completionTime && (
394-
<Text color={theme.statusSecondary} dimColor>
395-
{completionTime}
396-
</Text>
397-
)}
398-
{credits && (
399-
<Text color={theme.statusSecondary} dimColor>
400-
{credits} credits
401-
</Text>
402-
)}
403-
</Box>
404-
)}
405-
</>
407+
</Box>
406408
)
407409
}

cli/src/hooks/use-keyboard-handlers.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export const useKeyboardHandlers = ({
3535
useInput(
3636
useCallback(
3737
(input, key) => {
38+
const extendedKey = key as Record<string, boolean | undefined>
39+
3840
// Ctrl+C or Escape to cancel streaming
3941
const isEscape = key.escape
4042
const isCtrlC = key.ctrl && input === 'c'
@@ -103,12 +105,12 @@ export const useKeyboardHandlers = ({
103105
}
104106
}
105107

106-
if (scrollToLatest && key.end) {
108+
if (scrollToLatest && extendedKey.end) {
107109
scrollToLatest()
108110
return
109111
}
110112

111-
if (scrollToTop && key.home) {
113+
if (scrollToTop && extendedKey.home) {
112114
scrollToTop()
113115
return
114116
}

cli/src/hooks/use-message-renderer.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface UseMessageRendererProps {
2828
setFocusedAgentId: React.Dispatch<React.SetStateAction<string | null>>
2929
registerAgentRef: (agentId: string, element: any) => void
3030
scrollToAgent: (agentId: string, retries?: number) => void
31+
wrapTopLevelMessage?: (message: ChatMessage, node: ReactNode) => ReactNode
3132
}
3233

3334
export const useMessageRenderer = (props: UseMessageRendererProps): ReactNode[] => {
@@ -45,6 +46,7 @@ export const useMessageRenderer = (props: UseMessageRendererProps): ReactNode[]
4546
setFocusedAgentId,
4647
registerAgentRef,
4748
scrollToAgent,
49+
wrapTopLevelMessage,
4850
} = props
4951

5052
return useMemo(() => {
@@ -143,7 +145,7 @@ export const useMessageRenderer = (props: UseMessageRendererProps): ReactNode[]
143145

144146
return (
145147
<Box
146-
key={message.id}
148+
key={depth === 0 && wrapTopLevelMessage ? undefined : message.id}
147149
ref={(el: any) => registerAgentRef(message.id, el)}
148150
flexDirection="column"
149151
flexShrink={0}
@@ -272,7 +274,7 @@ export const useMessageRenderer = (props: UseMessageRendererProps): ReactNode[]
272274

273275
return (
274276
<Box
275-
key={message.id}
277+
key={depth === 0 && wrapTopLevelMessage ? undefined : message.id}
276278

277279
flexDirection="column"
278280
marginBottom={isLastMessage ? 0 : 1}
@@ -307,8 +309,6 @@ export const useMessageRenderer = (props: UseMessageRendererProps): ReactNode[]
307309
isLoading={isLoading}
308310
timestamp={message.timestamp}
309311
isComplete={message.isComplete}
310-
completionTime={message.completionTime}
311-
credits={message.credits}
312312
theme={theme}
313313
textColor={textColor}
314314
timestampColor={timestampColor}
@@ -330,6 +330,7 @@ export const useMessageRenderer = (props: UseMessageRendererProps): ReactNode[]
330330
scrollToAgent(id)
331331
}}
332332
registerAgentRef={registerAgentRef}
333+
committedSegments={message.committedSegments}
333334
/>
334335
</Box>
335336
</Box>
@@ -347,8 +348,6 @@ export const useMessageRenderer = (props: UseMessageRendererProps): ReactNode[]
347348
isLoading={isLoading}
348349
timestamp={message.timestamp}
349350
isComplete={message.isComplete}
350-
completionTime={message.completionTime}
351-
credits={message.credits}
352351
theme={theme}
353352
textColor={textColor}
354353
timestampColor={timestampColor}
@@ -370,6 +369,7 @@ export const useMessageRenderer = (props: UseMessageRendererProps): ReactNode[]
370369
scrollToAgent(id)
371370
}}
372371
registerAgentRef={registerAgentRef}
372+
committedSegments={message.committedSegments}
373373
/>
374374
</Box>
375375
)}
@@ -394,7 +394,11 @@ export const useMessageRenderer = (props: UseMessageRendererProps): ReactNode[]
394394

395395
return topLevelMessages.map((message, idx) => {
396396
const isLast = idx === topLevelMessages.length - 1
397-
return renderMessageWithAgents(message, 0, false, [], isLast)
397+
const node = renderMessageWithAgents(message, 0, false, [], isLast)
398+
if (wrapTopLevelMessage) {
399+
return wrapTopLevelMessage(message, node)
400+
}
401+
return node
398402
})
399403
}, [
400404
messages,
@@ -410,5 +414,6 @@ export const useMessageRenderer = (props: UseMessageRendererProps): ReactNode[]
410414
setFocusedAgentId,
411415
registerAgentRef,
412416
scrollToAgent,
417+
wrapTopLevelMessage,
413418
])
414419
}

0 commit comments

Comments
 (0)