Skip to content

Commit 3f5d8bc

Browse files
committed
feat(cli): prevent auto-scroll during user-initiated collapses and add
scroll indicator - Add isUserCollapsingRef to track user-initiated collapse actions - Prevent auto-scroll when user manually collapses agent sections - Add clickable scroll indicator (↓) when not at bottom of chat - Refactor diff-viewer to use single text element for better rendering - Add object validation in agent-branch-item and tool-call-item - Restructure status bar with three-section flexbox layout
1 parent 361afe2 commit 3f5d8bc

File tree

7 files changed

+647
-39
lines changed

7 files changed

+647
-39
lines changed

cli/src/chat.tsx

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ import { BORDER_CHARS } from './utils/ui-constants'
4040
import type { SendMessageTimerEvent } from './hooks/use-send-message'
4141
import type { ContentBlock } from './types/chat'
4242
import type { SendMessageFn } from './types/contracts/send-message'
43-
import type { ScrollBoxRenderable } from '@opentui/core'
43+
import type { KeyEvent, ScrollBoxRenderable } from '@opentui/core'
44+
import { TextAttributes } from '@opentui/core'
45+
46+
const MAX_VIRTUALIZED_TOP_LEVEL = 60
47+
const VIRTUAL_OVERSCAN = 12
4448

4549
const DEFAULT_AGENT_IDS = {
4650
DEFAULT: 'base2',
@@ -493,7 +497,8 @@ export const Chat = ({
493497
) : null
494498

495499
const shouldShowQueuePreview = queuedMessages.length > 0
496-
const shouldShowStatusLine = Boolean(hasStatus || shouldShowQueuePreview)
500+
const shouldShowStatusLine =
501+
hasStatus || shouldShowQueuePreview || !isAtBottom
497502

498503
const statusIndicatorNode = (
499504
<StatusIndicator
@@ -603,18 +608,45 @@ export const Chat = ({
603608
width: '100%',
604609
}}
605610
>
606-
<text style={{ wrapMode: 'none' }}>
607-
{hasStatus && statusIndicatorNode}
611+
{/* Left section - queue preview */}
612+
<box style={{ flexGrow: 1, flexShrink: 1, flexBasis: 0 }}>
608613
{shouldShowQueuePreview && (
609-
<span fg={theme.secondary} bg={theme.inputFocusedBg}>
610-
{' '}
611-
{formatQueuedPreview(
612-
queuedMessages,
613-
Math.max(30, terminalWidth - 25),
614-
)}{' '}
615-
</span>
614+
<text style={{ wrapMode: 'none' }}>
615+
<span fg={theme.secondary} bg={theme.inputFocusedBg}>
616+
{` ${formatQueuedPreview(
617+
queuedMessages,
618+
Math.max(30, terminalWidth - 25),
619+
)} `}
620+
</span>
621+
</text>
616622
)}
617-
</text>
623+
</box>
624+
625+
{/* Center section - scroll indicator (always centered) */}
626+
<box style={{ flexShrink: 0 }}>
627+
{!isAtBottom && (
628+
<text onMouseDown={scrollToLatest}>
629+
<span fg={theme.info} attributes={TextAttributes.BOLD}>
630+
631+
</span>
632+
</text>
633+
)}
634+
</box>
635+
636+
{/* Right section - status indicator */}
637+
<box
638+
style={{
639+
flexGrow: 1,
640+
flexShrink: 1,
641+
flexBasis: 0,
642+
flexDirection: 'row',
643+
justifyContent: 'flex-end',
644+
}}
645+
>
646+
{hasStatus && (
647+
<text style={{ wrapMode: 'none' }}>{statusIndicatorNode}</text>
648+
)}
649+
</box>
618650
</box>
619651
)}
620652
<box

cli/src/components/agent-branch-item.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ interface AgentBranchItemProps {
1818
statusIndicator?: string
1919
onToggle?: () => void
2020
titleSuffix?: string
21+
isUserCollapsingRef?: React.MutableRefObject<boolean>
2122
}
2223

2324
export const AgentBranchItem = ({
@@ -34,6 +35,7 @@ export const AgentBranchItem = ({
3435
statusIndicator = '●',
3536
onToggle,
3637
titleSuffix,
38+
isUserCollapsingRef,
3739
}: AgentBranchItemProps) => {
3840
const theme = useTheme()
3941

@@ -142,6 +144,12 @@ export const AgentBranchItem = ({
142144
)
143145
}
144146

147+
// Check if value is a plain object (not a React element)
148+
if (typeof value === 'object' && value !== null && !React.isValidElement(value)) {
149+
console.warn('Attempted to render plain object in agent content:', value)
150+
return null
151+
}
152+
145153
return (
146154
<box key="expanded-unknown" style={{ flexDirection: 'column', gap: 0 }}>
147155
{value}
@@ -281,7 +289,13 @@ export const AgentBranchItem = ({
281289
alignSelf: 'flex-end',
282290
marginTop: 1,
283291
}}
284-
onMouseDown={onToggle}
292+
onMouseDown={() => {
293+
// Set flag to prevent auto-scroll during user-initiated collapse
294+
if (isUserCollapsingRef) {
295+
isUserCollapsingRef.current = true
296+
}
297+
onToggle()
298+
}}
285299
>
286300
<text
287301
fg={theme.secondary}

cli/src/components/tools/diff-viewer.tsx

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -41,25 +41,21 @@ const lineColor = (line: string): { fg: string; attrs?: number } => {
4141
export const DiffViewer = ({ diffText }: DiffViewerProps) => {
4242
const theme = useTheme()
4343
const lines = diffText.split('\n')
44+
const filteredLines = lines.filter((rawLine) => !rawLine.startsWith('@@'))
4445

4546
return (
46-
<box
47-
style={{ flexDirection: 'column', gap: 0, width: '100%', flexGrow: 1 }}
48-
>
49-
{lines
50-
.filter((rawLine) => !rawLine.startsWith('@@'))
51-
.map((rawLine, idx) => {
52-
const line = rawLine.length === 0 ? ' ' : rawLine
53-
const { fg, attrs } = lineColor(line)
54-
const resolvedFg = fg || theme.foreground
55-
return (
56-
<text key={`diff-line-${idx}`} style={{ wrapMode: 'none' }}>
57-
<span fg={resolvedFg} attributes={attrs}>
58-
{line}
59-
</span>
60-
</text>
61-
)
62-
})}
63-
</box>
47+
<text style={{ wrapMode: 'none', marginTop: 0, marginBottom: 0 }}>
48+
{filteredLines.map((rawLine, idx) => {
49+
const line = rawLine.length === 0 ? ' ' : rawLine
50+
const { fg, attrs } = lineColor(line)
51+
const resolvedFg = fg || theme.foreground
52+
return (
53+
<span key={`diff-line-${idx}`} fg={resolvedFg} attributes={attrs}>
54+
{line}
55+
{idx < filteredLines.length - 1 ? '\n' : ''}
56+
</span>
57+
)
58+
})}
59+
</text>
6460
)
6561
}

cli/src/components/tools/str-replace.tsx

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,16 @@ const EditHeader = ({ name, filePath }: EditHeaderProps) => {
5050
const bulletChar = '• '
5151

5252
return (
53-
<box style={{ flexDirection: 'row', alignItems: 'center', width: '100%' }}>
54-
<text style={{ wrapMode: 'word' }}>
53+
<box
54+
style={{
55+
flexDirection: 'row',
56+
alignItems: 'center',
57+
width: '100%',
58+
marginTop: 0,
59+
marginBottom: 0,
60+
}}
61+
>
62+
<text style={{ wrapMode: 'word', marginTop: 0, marginBottom: 0 }}>
5563
<span fg={theme.foreground}>{bulletChar}</span>
5664
<span fg={theme.foreground} attributes={TextAttributes.BOLD}>
5765
{name}
@@ -69,12 +77,34 @@ interface EditBodyProps {
6977
}
7078

7179
const EditBody = ({ name, filePath, diffText }: EditBodyProps) => {
80+
const hasDiff = diffText && diffText.trim().length > 0
81+
7282
return (
73-
<box style={{ flexDirection: 'column', gap: 0, width: '100%' }}>
83+
<box
84+
style={{
85+
flexDirection: 'column',
86+
gap: 0,
87+
width: '100%',
88+
marginTop: 0,
89+
marginBottom: 0,
90+
}}
91+
>
7492
<EditHeader name={name} filePath={filePath} />
75-
<box style={{ paddingLeft: 2, width: '100%' }}>
76-
<DiffViewer diffText={diffText} />
77-
</box>
93+
{hasDiff && (
94+
<box
95+
style={{
96+
paddingLeft: 2,
97+
paddingRight: 0,
98+
paddingTop: 0,
99+
paddingBottom: 0,
100+
width: '100%',
101+
marginTop: 0,
102+
marginBottom: 0,
103+
}}
104+
>
105+
<DiffViewer diffText={diffText} />
106+
</box>
107+
)}
78108
</box>
79109
)
80110
}

cli/src/components/tools/tool-call-item.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ const renderExpandedContent = (
110110
)
111111
}
112112

113+
// Check if value is a plain object (not a React element)
114+
if (typeof value === 'object' && value !== null && !React.isValidElement(value)) {
115+
console.warn('Attempted to render plain object in tool content:', value)
116+
return null
117+
}
118+
113119
return (
114120
<box
115121
key="tool-expanded-unknown"

0 commit comments

Comments
 (0)