Skip to content

Commit 8aadf1f

Browse files
committed
cli: show "small" agents in a row, but bigger agents fill their line
1 parent b921bc1 commit 8aadf1f

File tree

4 files changed

+185
-18
lines changed

4 files changed

+185
-18
lines changed

cli/src/components/blocks/agent-block-grid.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import React, { memo, useCallback } from 'react'
1+
import React, { memo, useCallback, useMemo } from 'react'
22

33
import { GridLayout } from '../grid-layout'
4+
import { splitAgentsBySize } from '../../utils/block-processor'
45

56
import type { AgentContentBlock } from '../../types/chat'
67

@@ -33,15 +34,25 @@ export const AgentBlockGrid = memo(
3334
[keyPrefix, renderAgentBranch],
3435
)
3536

37+
const subGroups = useMemo(
38+
() => splitAgentsBySize(agentBlocks),
39+
[agentBlocks],
40+
)
41+
3642
if (agentBlocks.length === 0) return null
3743

3844
return (
39-
<GridLayout
40-
items={agentBlocks}
41-
availableWidth={availableWidth}
42-
getItemKey={getItemKey}
43-
renderItem={renderItem}
44-
/>
45+
<box style={{ flexDirection: 'column', gap: 0, width: '100%' }}>
46+
{subGroups.map((group) => (
47+
<GridLayout
48+
key={getItemKey(group[0])}
49+
items={group}
50+
availableWidth={availableWidth}
51+
getItemKey={getItemKey}
52+
renderItem={renderItem}
53+
/>
54+
))}
55+
</box>
4556
)
4657
},
4758
)

cli/src/components/message-with-agents.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { MessageBlock } from './message-block'
1010
import { ModeDivider } from './mode-divider'
1111
import { useChatStore } from '../state/chat-store'
1212
import { useMessageBlockStore } from '../state/message-block-store'
13+
import { splitByAgentSize } from '../utils/block-processor'
1314
import { getCliEnv } from '../utils/env'
1415
import {
1516
AGENT_CONTENT_HORIZONTAL_PADDING,
@@ -69,14 +70,24 @@ const AgentChildrenGrid = memo(
6970
<text fg={theme?.error}>Error rendering agent children</text>
7071
)
7172

73+
const subGroups = useMemo(
74+
() => splitByAgentSize(agentChildren, (m) => m.agent?.agentType ?? ''),
75+
[agentChildren],
76+
)
77+
7278
return (
7379
<ErrorBoundary fallback={errorFallback} componentName="AgentChildrenGrid">
74-
<GridLayout
75-
items={agentChildren}
76-
availableWidth={availableWidth}
77-
getItemKey={getItemKey}
78-
renderItem={renderAgentChild}
79-
/>
80+
<box style={{ flexDirection: 'column', gap: 0, width: '100%' }}>
81+
{subGroups.map((group) => (
82+
<GridLayout
83+
key={getItemKey(group[0])}
84+
items={group}
85+
availableWidth={availableWidth}
86+
getItemKey={getItemKey}
87+
renderItem={renderAgentChild}
88+
/>
89+
))}
90+
</box>
8091
</ErrorBoundary>
8192
)
8293
},

cli/src/utils/__tests__/block-processor.test.ts

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'
22

33
import {
44
processBlocks,
5+
splitAgentsBySize,
56
isReasoningTextBlock,
67
type BlockProcessorHandlers,
78
} from '../block-processor'
@@ -447,23 +448,44 @@ describe('processBlocks', () => {
447448
expect(calls[0].handler).toBe('onAgentGroup')
448449
})
449450

450-
test('groups consecutive non-implementor agents', () => {
451+
test('groups consecutive small (collapsed-by-default) agents together', () => {
451452
const { handlers, calls } = createMockHandlers()
452453
const blocks: ContentBlock[] = [
453454
createNonImplementorAgent('fp-1', 'file-picker'),
454-
createNonImplementorAgent('cmd-1', 'commander'),
455+
createNonImplementorAgent('b-1', 'basher'),
456+
createNonImplementorAgent('cs-1', 'code-searcher'),
457+
]
458+
459+
const result = processBlocks(blocks, handlers)
460+
461+
expect(result).toEqual(['agents-0-3'])
462+
expect(calls).toHaveLength(1)
463+
expect(calls[0].handler).toBe('onAgentGroup')
464+
const agentBlocks = calls[0].args[0] as AgentContentBlock[]
465+
expect(agentBlocks).toHaveLength(3)
466+
expect(agentBlocks[0].agentType).toBe('file-picker')
467+
expect(agentBlocks[1].agentType).toBe('basher')
468+
expect(agentBlocks[2].agentType).toBe('code-searcher')
469+
})
470+
471+
test('groups consecutive non-implementor agents including mixed sizes', () => {
472+
const { handlers, calls } = createMockHandlers()
473+
const blocks: ContentBlock[] = [
474+
createNonImplementorAgent('fp-1', 'file-picker'),
475+
createNonImplementorAgent('cr-1', 'code-reviewer'),
455476
createNonImplementorAgent('cs-1', 'code-searcher'),
456477
]
457478

458479
const result = processBlocks(blocks, handlers)
459480

481+
// All consecutive non-implementor agents go into a single onAgentGroup call
460482
expect(result).toEqual(['agents-0-3'])
461483
expect(calls).toHaveLength(1)
462484
expect(calls[0].handler).toBe('onAgentGroup')
463485
const agentBlocks = calls[0].args[0] as AgentContentBlock[]
464486
expect(agentBlocks).toHaveLength(3)
465487
expect(agentBlocks[0].agentType).toBe('file-picker')
466-
expect(agentBlocks[1].agentType).toBe('commander')
488+
expect(agentBlocks[1].agentType).toBe('code-reviewer')
467489
expect(agentBlocks[2].agentType).toBe('code-searcher')
468490
})
469491

@@ -687,8 +709,8 @@ describe('processBlocks', () => {
687709
createToolBlock('tool-2', 't2'),
688710
createToolBlock('tool-3', 't3'), // group ends, nextIndex = 4
689711
createTextBlock('text at 4'),
690-
createNonImplementorAgent('a1'), // group starts at 5
691-
createNonImplementorAgent('a2'), // group ends, nextIndex = 7
712+
createNonImplementorAgent('a1'), // group starts at 5 (file-picker = small)
713+
createNonImplementorAgent('a2'), // group ends, nextIndex = 7 (file-picker = small)
692714
createTextBlock('text at 7'),
693715
]
694716

@@ -703,5 +725,86 @@ describe('processBlocks', () => {
703725
expect(calls[3].args[2]).toBe(7) // agents next at 7
704726
expect(calls[4].args[1]).toBe(7) // single text at 7
705727
})
728+
729+
test('maintains correct indices for mixed-size agent groups', () => {
730+
const { handlers, calls } = createMockHandlers()
731+
const blocks: ContentBlock[] = [
732+
createTextBlock('text at 0'),
733+
createNonImplementorAgent('fp-1', 'file-picker'), // index 1
734+
createNonImplementorAgent('b-1', 'basher'), // index 2
735+
createNonImplementorAgent('cr-1', 'code-reviewer'), // index 3
736+
createNonImplementorAgent('cs-1', 'code-searcher'), // index 4
737+
createTextBlock('text at 5'),
738+
]
739+
740+
processBlocks(blocks, handlers)
741+
742+
// text at 0
743+
expect(calls[0].handler).toBe('onSingleBlock')
744+
expect(calls[0].args[1]).toBe(0)
745+
// All non-implementor agents grouped together
746+
expect(calls[1].handler).toBe('onAgentGroup')
747+
expect(calls[1].args[1]).toBe(1)
748+
expect(calls[1].args[2]).toBe(5)
749+
expect((calls[1].args[0] as AgentContentBlock[]).length).toBe(4)
750+
// text at 5
751+
expect(calls[2].handler).toBe('onSingleBlock')
752+
expect(calls[2].args[1]).toBe(5)
753+
})
754+
})
755+
})
756+
757+
// ============================================================================
758+
// Tests: splitAgentsBySize
759+
// ============================================================================
760+
761+
describe('splitAgentsBySize', () => {
762+
test('returns single group for empty array', () => {
763+
const result = splitAgentsBySize([])
764+
expect(result).toEqual([[]])
765+
})
766+
767+
test('returns single group for one agent', () => {
768+
const agent = createNonImplementorAgent('cr-1', 'code-reviewer')
769+
const result = splitAgentsBySize([agent])
770+
expect(result).toEqual([[agent]])
771+
})
772+
773+
test('groups all small agents together', () => {
774+
const agents = [
775+
createNonImplementorAgent('fp-1', 'file-picker'),
776+
createNonImplementorAgent('b-1', 'basher'),
777+
createNonImplementorAgent('cs-1', 'code-searcher'),
778+
]
779+
const result = splitAgentsBySize(agents)
780+
expect(result).toEqual([agents])
781+
})
782+
783+
test('gives each large agent its own group', () => {
784+
const agents = [
785+
createNonImplementorAgent('cr-1', 'code-reviewer'),
786+
createNonImplementorAgent('ed-1', 'editor'),
787+
]
788+
const result = splitAgentsBySize(agents)
789+
expect(result).toEqual([[agents[0]], [agents[1]]])
790+
})
791+
792+
test('splits small and large agents correctly', () => {
793+
const agents = [
794+
createNonImplementorAgent('fp-1', 'file-picker'),
795+
createNonImplementorAgent('cr-1', 'code-reviewer'),
796+
createNonImplementorAgent('b-1', 'basher'),
797+
createNonImplementorAgent('b-2', 'basher'),
798+
createNonImplementorAgent('ed-1', 'editor'),
799+
createNonImplementorAgent('rw-1', 'researcher-web'),
800+
]
801+
const result = splitAgentsBySize(agents)
802+
expect(result).toEqual([
803+
[agents[0]], // file-picker (small)
804+
[agents[1]], // code-reviewer (large)
805+
[agents[2], agents[3]], // basher + basher (small)
806+
[agents[4]], // editor (large)
807+
[agents[5]], // researcher-web (small)
808+
])
706809
})
707810
})

cli/src/utils/block-processor.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11

2+
import { shouldCollapseByDefault } from './constants'
23
import {
34
isImplementorAgent,
45
groupConsecutiveImplementors,
@@ -64,6 +65,47 @@ export interface BlockProcessorHandlers {
6465
onSingleBlock: (block: ContentBlock, index: number) => ReactNode
6566
}
6667

68+
/**
69+
* Split an array of items into sub-groups based on agent size.
70+
* Consecutive "small" agents (collapsed by default) are grouped together
71+
* so they can share a grid row. Each "large" agent gets its own sub-group
72+
* so it renders at full width.
73+
*/
74+
export function splitByAgentSize<T>(
75+
items: T[],
76+
getAgentType: (item: T) => string,
77+
): T[][] {
78+
if (items.length <= 1) return [items]
79+
80+
const subGroups: T[][] = []
81+
let currentSmallGroup: T[] = []
82+
83+
for (const item of items) {
84+
if (shouldCollapseByDefault(getAgentType(item))) {
85+
currentSmallGroup.push(item)
86+
} else {
87+
if (currentSmallGroup.length > 0) {
88+
subGroups.push(currentSmallGroup)
89+
currentSmallGroup = []
90+
}
91+
subGroups.push([item])
92+
}
93+
}
94+
95+
if (currentSmallGroup.length > 0) {
96+
subGroups.push(currentSmallGroup)
97+
}
98+
99+
return subGroups
100+
}
101+
102+
/** Convenience wrapper for splitting AgentContentBlock arrays by size. */
103+
export function splitAgentsBySize(
104+
agents: AgentContentBlock[],
105+
): AgentContentBlock[][] {
106+
return splitByAgentSize(agents, (a) => a.agentType)
107+
}
108+
67109
/**
68110
* Process a list of content blocks, grouping consecutive blocks of the same type
69111
* and calling the appropriate handler for each group or single block.

0 commit comments

Comments
 (0)