Skip to content

Commit 9b85376

Browse files
committed
feat(cli): show diff preview in implementor agent rows
- Add ImplementorRow and ImplementorGroup components for compact agent display - Show expand/collapse toggle with status indicator, model name, edit count - Display activity timeline with diffs when expanded - Construct diffs from str_replace replacements for proposed edits - Store outputRaw in tool results for actual diff extraction
1 parent 20246e5 commit 9b85376

File tree

5 files changed

+586
-54
lines changed

5 files changed

+586
-54
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import { TextAttributes } from '@opentui/core'
2+
import React, { memo } from 'react'
3+
4+
import { useTheme } from '../hooks/use-theme'
5+
import { Button } from './button'
6+
import { DiffViewer } from './tools/diff-viewer'
7+
import {
8+
buildActivityTimeline,
9+
countEdits,
10+
getImplementorDisplayName,
11+
getImplementorIndex,
12+
getLatestCommentary,
13+
type TimelineItem,
14+
} from '../utils/implementor-helpers'
15+
import { BORDER_CHARS } from '../utils/ui-constants'
16+
import type { AgentContentBlock, ContentBlock } from '../types/chat'
17+
18+
interface ImplementorGroupProps {
19+
implementors: AgentContentBlock[]
20+
siblingBlocks: ContentBlock[]
21+
onToggleCollapsed: (id: string) => void
22+
availableWidth: number
23+
}
24+
25+
/**
26+
* Wraps multiple implementor agents in a bordered box with a header
27+
*/
28+
export const ImplementorGroup = memo(
29+
({
30+
implementors,
31+
siblingBlocks,
32+
onToggleCollapsed,
33+
availableWidth,
34+
}: ImplementorGroupProps) => {
35+
const theme = useTheme()
36+
const count = implementors.length
37+
const headerText = `${count} agent${count !== 1 ? 's' : ''} implementing`
38+
39+
return (
40+
<box
41+
style={{
42+
flexDirection: 'column',
43+
gap: 0,
44+
width: '100%',
45+
marginTop: 1,
46+
}}
47+
>
48+
<text
49+
fg={theme.muted}
50+
attributes={TextAttributes.DIM}
51+
style={{ marginBottom: 0 }}
52+
>
53+
{headerText}
54+
</text>
55+
<box
56+
border
57+
borderStyle="single"
58+
borderColor={theme.muted}
59+
customBorderChars={BORDER_CHARS}
60+
style={{
61+
flexDirection: 'column',
62+
gap: 0,
63+
width: '100%',
64+
paddingLeft: 1,
65+
paddingRight: 1,
66+
paddingTop: 0,
67+
paddingBottom: 0,
68+
}}
69+
>
70+
{implementors.map((agentBlock, idx) => {
71+
const implementorIndex = getImplementorIndex(
72+
agentBlock.agentId,
73+
agentBlock.agentType,
74+
siblingBlocks,
75+
)
76+
const isExpanded = agentBlock.isCollapsed === false
77+
78+
return (
79+
<ImplementorRow
80+
key={agentBlock.agentId}
81+
agentBlock={agentBlock}
82+
implementorIndex={implementorIndex}
83+
isExpanded={isExpanded}
84+
onToggleExpand={() => onToggleCollapsed(agentBlock.agentId)}
85+
availableWidth={availableWidth - 4}
86+
/>
87+
)
88+
})}
89+
</box>
90+
</box>
91+
)
92+
},
93+
)
94+
95+
interface ImplementorRowProps {
96+
agentBlock: AgentContentBlock
97+
implementorIndex?: number
98+
isExpanded: boolean
99+
onToggleExpand: () => void
100+
availableWidth: number
101+
}
102+
103+
/**
104+
* Compact row display for a single implementor agent
105+
* Shows: ▸ ● Model N edits "commentary..."
106+
* Expands to show full activity timeline with diffs
107+
*/
108+
export const ImplementorRow = memo(
109+
({
110+
agentBlock,
111+
implementorIndex,
112+
isExpanded,
113+
onToggleExpand,
114+
availableWidth,
115+
}: ImplementorRowProps) => {
116+
const theme = useTheme()
117+
118+
const isStreaming = agentBlock.status === 'running'
119+
const isComplete = agentBlock.status === 'complete'
120+
const isFailed = agentBlock.status === 'failed'
121+
122+
const displayName = getImplementorDisplayName(
123+
agentBlock.agentType,
124+
implementorIndex,
125+
)
126+
const editCount = countEdits(agentBlock.blocks)
127+
const latestCommentary = getLatestCommentary(agentBlock.blocks)
128+
const timeline = isExpanded
129+
? buildActivityTimeline(agentBlock.blocks)
130+
: []
131+
132+
// Status indicator
133+
const statusIndicator = isStreaming
134+
? '●'
135+
: isFailed
136+
? '✗'
137+
: isComplete
138+
? '✓'
139+
: '○'
140+
const statusColor = isStreaming
141+
? theme.primary
142+
: isFailed
143+
? 'red'
144+
: isComplete
145+
? theme.foreground
146+
: theme.muted
147+
148+
// Expand toggle
149+
const toggleIndicator = isExpanded ? '▾' : '▸'
150+
151+
// Calculate available width for commentary
152+
// Format: "▸ ● Model N edits "commentary..."
153+
// Fixed parts: toggle(2) + status(2) + model(~12) + count(~10) + padding(4) ≈ 30
154+
const fixedWidth = 30 + displayName.length
155+
const maxCommentaryWidth = Math.max(10, availableWidth - fixedWidth)
156+
157+
// Truncate commentary to fit
158+
const truncatedCommentary = latestCommentary
159+
? latestCommentary.length > maxCommentaryWidth
160+
? latestCommentary.slice(0, maxCommentaryWidth - 3) + '...'
161+
: latestCommentary
162+
: undefined
163+
164+
// Edit count text
165+
const editText = editCount === 1 ? '1 edit' : `${editCount} edits`
166+
167+
return (
168+
<box
169+
style={{
170+
flexDirection: 'column',
171+
gap: 0,
172+
width: '100%',
173+
}}
174+
>
175+
{/* Collapsed row header */}
176+
<Button
177+
onClick={onToggleExpand}
178+
style={{
179+
flexDirection: 'row',
180+
alignItems: 'center',
181+
width: '100%',
182+
paddingLeft: 0,
183+
}}
184+
>
185+
{/* Toggle + Status column */}
186+
<text style={{ wrapMode: 'none', width: 4 }}>
187+
<span fg={theme.foreground}>{toggleIndicator} </span>
188+
<span fg={statusColor}>{statusIndicator}</span>
189+
</text>
190+
{/* Model name column - fixed width for alignment */}
191+
<text
192+
fg={theme.foreground}
193+
attributes={TextAttributes.BOLD}
194+
style={{ wrapMode: 'none', width: 12 }}
195+
>
196+
{displayName}
197+
</text>
198+
{/* Edit count column - fixed width */}
199+
<text fg={theme.muted} style={{ wrapMode: 'none', width: 10 }}>
200+
{editText}
201+
</text>
202+
{/* Commentary column - fills remaining space */}
203+
{!isExpanded && truncatedCommentary && (
204+
<text
205+
fg={theme.foreground}
206+
attributes={TextAttributes.ITALIC}
207+
style={{ wrapMode: 'none', flexGrow: 1 }}
208+
>
209+
"{truncatedCommentary}"
210+
</text>
211+
)}
212+
{!isExpanded && isComplete && !truncatedCommentary && (
213+
<text fg={theme.muted} style={{ wrapMode: 'none' }}>
214+
Complete
215+
</text>
216+
)}
217+
{!isExpanded && isFailed && !truncatedCommentary && (
218+
<text fg="red" style={{ wrapMode: 'none' }}>
219+
Failed
220+
</text>
221+
)}
222+
</Button>
223+
224+
{/* Expanded content */}
225+
{isExpanded && (
226+
<box
227+
style={{
228+
flexDirection: 'column',
229+
gap: 0,
230+
paddingLeft: 4,
231+
paddingTop: 0,
232+
paddingBottom: 1,
233+
width: '100%',
234+
}}
235+
>
236+
{timeline.map((item, idx) => (
237+
<TimelineItemView
238+
key={`timeline-${idx}`}
239+
item={item}
240+
availableWidth={availableWidth - 4}
241+
/>
242+
))}
243+
{timeline.length === 0 && (
244+
<text fg={theme.muted} attributes={TextAttributes.ITALIC}>
245+
No activity yet...
246+
</text>
247+
)}
248+
<Button
249+
onClick={onToggleExpand}
250+
style={{
251+
alignSelf: 'flex-end',
252+
marginTop: 1,
253+
}}
254+
>
255+
<text fg={theme.secondary} style={{ wrapMode: 'none' }}>
256+
▴ collapse
257+
</text>
258+
</Button>
259+
</box>
260+
)}
261+
</box>
262+
)
263+
},
264+
)
265+
266+
interface TimelineItemViewProps {
267+
item: TimelineItem
268+
availableWidth: number
269+
}
270+
271+
const TimelineItemView = memo(({ item, availableWidth }: TimelineItemViewProps) => {
272+
const theme = useTheme()
273+
274+
if (item.type === 'commentary') {
275+
return (
276+
<box style={{ marginTop: 1, marginBottom: 1, width: '100%' }}>
277+
<text
278+
fg={theme.foreground}
279+
attributes={TextAttributes.ITALIC}
280+
style={{ wrapMode: 'word' }}
281+
>
282+
"{item.content}"
283+
</text>
284+
</box>
285+
)
286+
}
287+
288+
// Edit item - show file path and diff
289+
const editIcon = item.isCreate ? '+ ' : '✎ '
290+
291+
return (
292+
<box style={{ flexDirection: 'column', gap: 0, marginTop: 1, width: '100%' }}>
293+
<text style={{ wrapMode: 'none' }}>
294+
<span fg={theme.foreground}>{editIcon}</span>
295+
<span fg={theme.foreground} attributes={TextAttributes.BOLD}>
296+
{item.content}
297+
</span>
298+
</text>
299+
{item.diff && !item.isCreate && (
300+
<box
301+
style={{
302+
marginTop: 0,
303+
marginLeft: 2,
304+
width: '100%',
305+
}}
306+
>
307+
<DiffViewer diffText={item.diff} />
308+
</box>
309+
)}
310+
</box>
311+
)
312+
})

0 commit comments

Comments
 (0)