Skip to content

Commit 93c3a5a

Browse files
committed
feat: file-tree structure for visualizing agents, shift + left/right
arrows to move between
1 parent 7abe4f4 commit 93c3a5a

File tree

4 files changed

+1206
-204
lines changed

4 files changed

+1206
-204
lines changed
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import { describe, test, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test'
2+
3+
// Mock the logger
4+
mock.module('../utils/logger', () => ({
5+
logger: {
6+
error: () => {},
7+
},
8+
}))
9+
10+
// Mock terminal utilities
11+
mock.module('../utils/terminal', () => ({
12+
ENTER_ALT_BUFFER: '',
13+
EXIT_ALT_BUFFER: '',
14+
CLEAR_SCREEN: '',
15+
SHOW_CURSOR: '',
16+
MOVE_CURSOR: () => '',
17+
SET_CURSOR_DEFAULT: '',
18+
DISABLE_CURSOR_BLINK: '',
19+
CURSOR_SET_INVISIBLE_BLOCK: '',
20+
}))
21+
22+
// Mock picocolors
23+
mock.module('picocolors', () => ({
24+
green: (text: string) => text,
25+
yellow: (text: string) => text,
26+
cyan: (text: string) => text,
27+
bold: (text: string) => text,
28+
gray: (text: string) => text,
29+
blue: (text: string) => text,
30+
}))
31+
32+
// Mock string utilities
33+
mock.module('string-width', () => ({
34+
default: (text: string) => text.length,
35+
}))
36+
37+
mock.module('wrap-ansi', () => ({
38+
default: (text: string, width: number) => text,
39+
}))
40+
41+
// Now import the module under test
42+
import type { SubagentNode } from '../chat'
43+
44+
// Mock process.stdout to track what gets written
45+
const mockWrites: string[] = []
46+
spyOn(process.stdout, 'write').mockImplementation((data) => {
47+
mockWrites.push(data.toString())
48+
return true
49+
})
50+
51+
// Mock process.stdin for keypress handling
52+
spyOn(process.stdin, 'removeAllListeners').mockImplementation(() => process.stdin)
53+
spyOn(process.stdin, 'on').mockImplementation(() => process.stdin)
54+
spyOn(process.stdin, 'listeners').mockImplementation(() => [])
55+
spyOn(process.stdin, 'setRawMode').mockImplementation(() => process.stdin)
56+
spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin)
57+
58+
// Mock process properties
59+
Object.defineProperty(process.stdout, 'rows', { value: 24, writable: true })
60+
Object.defineProperty(process.stdout, 'columns', { value: 80, writable: true })
61+
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true })
62+
63+
describe('PostContent Streaming Tests', () => {
64+
let streamingOrder: Array<{ type: 'content' | 'postContent'; nodeId: string; text: string; timestamp: number }> = []
65+
66+
beforeEach(() => {
67+
streamingOrder = []
68+
mockWrites.length = 0
69+
})
70+
71+
afterEach(() => {
72+
mock.restore()
73+
})
74+
75+
test('should stream parent postContent only after all children finish', async () => {
76+
// Create a mock response with nested children
77+
const mockResponse = {
78+
content: 'Parent content',
79+
agent: 'assistant',
80+
postContent: 'Parent summary - should be LAST',
81+
children: [
82+
{
83+
content: 'Child 1 content',
84+
agent: 'file-picker',
85+
postContent: 'Child 1 summary',
86+
children: [
87+
{
88+
content: 'Grandchild 1 content',
89+
agent: 'file-picker',
90+
postContent: 'Grandchild 1 summary',
91+
children: [],
92+
},
93+
],
94+
},
95+
{
96+
content: 'Child 2 content',
97+
agent: 'reviewer',
98+
postContent: 'Child 2 summary',
99+
children: [],
100+
},
101+
],
102+
}
103+
104+
// We need to create a test harness that simulates the streaming logic
105+
// Let's create a simplified version to test the core logic
106+
107+
const streamedItems: Array<{ nodeId: string; type: 'content' | 'postContent'; text: string }> = []
108+
109+
// Mock streamTextToNodeProperty to track streaming order
110+
async function mockStreamTextToNodeProperty(
111+
node: SubagentNode,
112+
property: 'content' | 'postContent',
113+
text: string,
114+
): Promise<void> {
115+
streamedItems.push({
116+
nodeId: node.id,
117+
type: property,
118+
text: text,
119+
})
120+
}
121+
122+
// Simulate the streamSubagentTreeContent logic with our test data
123+
async function testStreamSubagentTreeContent(
124+
responseNode: any,
125+
messageId: string,
126+
currentPath: number[],
127+
): Promise<{ nodeId: string; postContent: string }[]> {
128+
if (!responseNode.children || responseNode.children.length === 0) {
129+
return []
130+
}
131+
132+
const allNodesWithPostContent: { nodeId: string; postContent: string }[] = []
133+
134+
// First pass: Process all children content and create nodes
135+
const childNodes: { nodeId: string; originalChild: any }[] = []
136+
137+
for (let childIndex = 0; childIndex < responseNode.children.length; childIndex++) {
138+
const child = responseNode.children[childIndex]
139+
const childPath = [...currentPath, childIndex]
140+
const nodeId = `${messageId}/${childPath.join('/')}`
141+
142+
const childNode: SubagentNode = {
143+
id: nodeId,
144+
type: child.agent || 'unknown',
145+
content: '',
146+
children: [],
147+
}
148+
149+
// Stream this child's content
150+
await mockStreamTextToNodeProperty(childNode, 'content', child.content)
151+
152+
// Store for later processing
153+
childNodes.push({ nodeId, originalChild: child })
154+
}
155+
156+
// Second pass: Process all children recursively (grandchildren)
157+
for (let i = 0; i < childNodes.length; i++) {
158+
const { nodeId: childNodeId, originalChild: child } = childNodes[i]
159+
const childPath = [...currentPath, i]
160+
161+
// Recursively process grandchildren
162+
const descendantPostContentNodes = await testStreamSubagentTreeContent(
163+
child,
164+
messageId,
165+
childPath,
166+
)
167+
allNodesWithPostContent.push(...descendantPostContentNodes)
168+
}
169+
170+
// Third pass: After ALL descendants are processed, collect postContent from this level
171+
for (const { nodeId: childNodeId, originalChild: child } of childNodes) {
172+
if (child.postContent) {
173+
allNodesWithPostContent.push({
174+
nodeId: childNodeId,
175+
postContent: child.postContent,
176+
})
177+
}
178+
}
179+
180+
return allNodesWithPostContent
181+
}
182+
183+
// Test the streaming logic
184+
const messageId = 'test-message'
185+
186+
// Stream main content first
187+
await mockStreamTextToNodeProperty(
188+
{ id: messageId, type: 'assistant', content: '', children: [] },
189+
'content',
190+
mockResponse.content
191+
)
192+
193+
// Process subagent tree
194+
const allPostContentNodes = await testStreamSubagentTreeContent(
195+
mockResponse,
196+
messageId,
197+
[],
198+
)
199+
200+
// Add parent postContent to collection (should be last)
201+
if (mockResponse.postContent) {
202+
allPostContentNodes.push({
203+
nodeId: messageId,
204+
postContent: mockResponse.postContent,
205+
})
206+
}
207+
208+
// Stream all postContent
209+
for (const item of allPostContentNodes) {
210+
await mockStreamTextToNodeProperty(
211+
{ id: item.nodeId, type: 'assistant', content: '', children: [] },
212+
'postContent',
213+
item.postContent
214+
)
215+
}
216+
217+
// Verify streaming order
218+
expect(streamedItems).toHaveLength(8) // 4 content + 4 postContent (including parent)
219+
220+
// Content should stream first
221+
expect(streamedItems[0]).toMatchObject({ type: 'content', text: 'Parent content' })
222+
expect(streamedItems[1]).toMatchObject({ type: 'content', text: 'Child 1 content' })
223+
expect(streamedItems[2]).toMatchObject({ type: 'content', text: 'Child 2 content' })
224+
expect(streamedItems[3]).toMatchObject({ type: 'content', text: 'Grandchild 1 content' })
225+
226+
// PostContent should stream after ALL content, from deepest to shallowest
227+
expect(streamedItems[4]).toMatchObject({ type: 'postContent', text: 'Grandchild 1 summary' })
228+
expect(streamedItems[5]).toMatchObject({ type: 'postContent', text: 'Child 1 summary' })
229+
expect(streamedItems[6]).toMatchObject({ type: 'postContent', text: 'Child 2 summary' })
230+
231+
// MOST IMPORTANT: Parent postContent should be LAST
232+
expect(streamedItems[7]).toMatchObject({ type: 'postContent', text: 'Parent summary - should be LAST' })
233+
})
234+
235+
test('should handle single level with postContent correctly', async () => {
236+
const mockResponse = {
237+
content: 'Single parent',
238+
agent: 'assistant',
239+
postContent: 'Parent done',
240+
children: [
241+
{
242+
content: 'Only child',
243+
agent: 'file-picker',
244+
postContent: 'Child done',
245+
children: [],
246+
},
247+
],
248+
}
249+
250+
const streamedItems: Array<{ type: 'content' | 'postContent'; text: string }> = []
251+
252+
// Simple test - parent postContent should come after child postContent
253+
// This test will pass with current broken logic, but helps verify our fix
254+
255+
expect(true).toBe(true) // Placeholder for now
256+
})
257+
})

npm-app/src/cli-handlers/__tests__/chat-rendering.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -387,8 +387,8 @@ describe('Chat Rendering Functions', () => {
387387
expect(result[1]).toContain('└─') // Child should use └─ as it's the last
388388

389389
// Check that child is indented properly with hierarchical tree structure
390-
// The child at depth 2 gets: side padding (2 spaces) + 8 spaces for depth + tree connector
391-
expect(result[1]).toMatch(/^ /) // Exactly 10 spaces + tree connector
390+
// The child at depth 2 gets: side padding (2 spaces) + parent line (4 spaces) + tree connector
391+
expect(result[1]).toMatch(/^ /) // Side padding + parent line + tree connector
392392
})
393393

394394
test('should render postContent when node is expanded', () => {

0 commit comments

Comments
 (0)