@bytespell/shella 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundled-plugins/agent/AGENT_SPEC.md +611 -0
- package/bundled-plugins/agent/README.md +7 -0
- package/bundled-plugins/agent/components.json +24 -0
- package/bundled-plugins/agent/eslint.config.js +23 -0
- package/bundled-plugins/agent/index.html +13 -0
- package/bundled-plugins/agent/package-lock.json +12140 -0
- package/bundled-plugins/agent/package.json +62 -0
- package/bundled-plugins/agent/public/vite.svg +1 -0
- package/bundled-plugins/agent/server.js +631 -0
- package/bundled-plugins/agent/src/App.tsx +755 -0
- package/bundled-plugins/agent/src/assets/react.svg +1 -0
- package/bundled-plugins/agent/src/components/ui/alert-dialog.tsx +182 -0
- package/bundled-plugins/agent/src/components/ui/badge.tsx +45 -0
- package/bundled-plugins/agent/src/components/ui/button.tsx +60 -0
- package/bundled-plugins/agent/src/components/ui/card.tsx +94 -0
- package/bundled-plugins/agent/src/components/ui/combobox.tsx +294 -0
- package/bundled-plugins/agent/src/components/ui/dropdown-menu.tsx +253 -0
- package/bundled-plugins/agent/src/components/ui/field.tsx +225 -0
- package/bundled-plugins/agent/src/components/ui/input-group.tsx +147 -0
- package/bundled-plugins/agent/src/components/ui/input.tsx +19 -0
- package/bundled-plugins/agent/src/components/ui/label.tsx +24 -0
- package/bundled-plugins/agent/src/components/ui/select.tsx +185 -0
- package/bundled-plugins/agent/src/components/ui/separator.tsx +26 -0
- package/bundled-plugins/agent/src/components/ui/switch.tsx +31 -0
- package/bundled-plugins/agent/src/components/ui/textarea.tsx +18 -0
- package/bundled-plugins/agent/src/index.css +131 -0
- package/bundled-plugins/agent/src/lib/utils.ts +6 -0
- package/bundled-plugins/agent/src/main.tsx +11 -0
- package/bundled-plugins/agent/src/reducer.test.ts +359 -0
- package/bundled-plugins/agent/src/reducer.ts +255 -0
- package/bundled-plugins/agent/src/store.ts +379 -0
- package/bundled-plugins/agent/src/types.ts +98 -0
- package/bundled-plugins/agent/src/utils.test.ts +393 -0
- package/bundled-plugins/agent/src/utils.ts +158 -0
- package/bundled-plugins/agent/tsconfig.app.json +32 -0
- package/bundled-plugins/agent/tsconfig.json +13 -0
- package/bundled-plugins/agent/tsconfig.node.json +26 -0
- package/bundled-plugins/agent/vite.config.ts +14 -0
- package/bundled-plugins/agent/vitest.config.ts +17 -0
- package/bundled-plugins/terminal/README.md +7 -0
- package/bundled-plugins/terminal/index.html +24 -0
- package/bundled-plugins/terminal/package-lock.json +3346 -0
- package/bundled-plugins/terminal/package.json +38 -0
- package/bundled-plugins/terminal/server.ts +265 -0
- package/bundled-plugins/terminal/src/App.tsx +153 -0
- package/bundled-plugins/terminal/src/TERMINAL_SPEC.md +404 -0
- package/bundled-plugins/terminal/src/main.tsx +9 -0
- package/bundled-plugins/terminal/src/store.ts +114 -0
- package/bundled-plugins/terminal/tsconfig.json +22 -0
- package/bundled-plugins/terminal/vite.config.ts +10 -0
- package/dist/src/plugin-manager.js +1 -1
- package/dist/src/plugin-manager.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
splitAssistantContent,
|
|
4
|
+
getTextFromBlocks,
|
|
5
|
+
buildToolCallMap,
|
|
6
|
+
getCompletedToolCallIds,
|
|
7
|
+
buildRenderItems,
|
|
8
|
+
} from './utils'
|
|
9
|
+
import type { ContentBlock, AgentMessage } from './types'
|
|
10
|
+
|
|
11
|
+
describe('splitAssistantContent', () => {
|
|
12
|
+
it('returns empty array for empty content', () => {
|
|
13
|
+
const result = splitAssistantContent([], false)
|
|
14
|
+
expect(result).toEqual([])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('groups consecutive text blocks', () => {
|
|
18
|
+
const content: ContentBlock[] = [
|
|
19
|
+
{ type: 'text', text: 'Hello' },
|
|
20
|
+
{ type: 'text', text: 'World' },
|
|
21
|
+
]
|
|
22
|
+
const result = splitAssistantContent(content, false)
|
|
23
|
+
expect(result).toHaveLength(1)
|
|
24
|
+
expect(result[0]).toEqual({
|
|
25
|
+
type: 'text-chunk',
|
|
26
|
+
blocks: content,
|
|
27
|
+
isStreaming: false,
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('separates tool calls from text', () => {
|
|
32
|
+
const content: ContentBlock[] = [
|
|
33
|
+
{ type: 'text', text: 'Let me run a command' },
|
|
34
|
+
{ type: 'toolCall', id: 'call_1', name: 'Bash', arguments: { command: 'ls' } },
|
|
35
|
+
]
|
|
36
|
+
const result = splitAssistantContent(content, false)
|
|
37
|
+
expect(result).toHaveLength(2)
|
|
38
|
+
expect(result[0]).toEqual({
|
|
39
|
+
type: 'text-chunk',
|
|
40
|
+
blocks: [{ type: 'text', text: 'Let me run a command' }],
|
|
41
|
+
isStreaming: false,
|
|
42
|
+
})
|
|
43
|
+
expect(result[1]).toEqual({
|
|
44
|
+
type: 'tool-call',
|
|
45
|
+
id: 'call_1',
|
|
46
|
+
name: 'Bash',
|
|
47
|
+
args: { command: 'ls' },
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('handles text before and after tool call', () => {
|
|
52
|
+
const content: ContentBlock[] = [
|
|
53
|
+
{ type: 'text', text: 'Before' },
|
|
54
|
+
{ type: 'toolCall', id: 'call_1', name: 'Bash', arguments: { command: 'ls' } },
|
|
55
|
+
{ type: 'text', text: 'After' },
|
|
56
|
+
]
|
|
57
|
+
const result = splitAssistantContent(content, false)
|
|
58
|
+
expect(result).toHaveLength(3)
|
|
59
|
+
expect(result[0]?.type).toBe('text-chunk')
|
|
60
|
+
expect(result[1]?.type).toBe('tool-call')
|
|
61
|
+
expect(result[2]?.type).toBe('text-chunk')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('handles multiple tool calls', () => {
|
|
65
|
+
const content: ContentBlock[] = [
|
|
66
|
+
{ type: 'toolCall', id: 'call_1', name: 'Read', arguments: { path: 'a.txt' } },
|
|
67
|
+
{ type: 'toolCall', id: 'call_2', name: 'Read', arguments: { path: 'b.txt' } },
|
|
68
|
+
]
|
|
69
|
+
const result = splitAssistantContent(content, false)
|
|
70
|
+
expect(result).toHaveLength(2)
|
|
71
|
+
expect(result[0]?.type).toBe('tool-call')
|
|
72
|
+
expect(result[1]?.type).toBe('tool-call')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('marks last text chunk as streaming when isStreaming=true', () => {
|
|
76
|
+
const content: ContentBlock[] = [
|
|
77
|
+
{ type: 'text', text: 'Before tool' },
|
|
78
|
+
{ type: 'toolCall', id: 'call_1', name: 'Bash', arguments: { command: 'ls' } },
|
|
79
|
+
{ type: 'text', text: 'After tool, still typing...' },
|
|
80
|
+
]
|
|
81
|
+
const result = splitAssistantContent(content, true)
|
|
82
|
+
expect(result).toHaveLength(3)
|
|
83
|
+
// First text chunk should NOT be streaming
|
|
84
|
+
expect(result[0]).toEqual({
|
|
85
|
+
type: 'text-chunk',
|
|
86
|
+
blocks: [{ type: 'text', text: 'Before tool' }],
|
|
87
|
+
isStreaming: false,
|
|
88
|
+
})
|
|
89
|
+
// Last text chunk SHOULD be streaming
|
|
90
|
+
expect(result[2]).toEqual({
|
|
91
|
+
type: 'text-chunk',
|
|
92
|
+
blocks: [{ type: 'text', text: 'After tool, still typing...' }],
|
|
93
|
+
isStreaming: true,
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('includes thinking blocks in text chunks', () => {
|
|
98
|
+
const content: ContentBlock[] = [
|
|
99
|
+
{ type: 'thinking', thinking: 'Let me think about this...' },
|
|
100
|
+
{ type: 'text', text: 'Here is my answer' },
|
|
101
|
+
]
|
|
102
|
+
const result = splitAssistantContent(content, false)
|
|
103
|
+
expect(result).toHaveLength(1)
|
|
104
|
+
expect(result[0]).toEqual({
|
|
105
|
+
type: 'text-chunk',
|
|
106
|
+
blocks: content,
|
|
107
|
+
isStreaming: false,
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('getTextFromBlocks', () => {
|
|
113
|
+
it('returns empty string for empty array', () => {
|
|
114
|
+
expect(getTextFromBlocks([])).toBe('')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('returns first text block content', () => {
|
|
118
|
+
const blocks: ContentBlock[] = [
|
|
119
|
+
{ type: 'text', text: 'Hello world' },
|
|
120
|
+
]
|
|
121
|
+
expect(getTextFromBlocks(blocks)).toBe('Hello world')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('ignores non-text blocks', () => {
|
|
125
|
+
const blocks: ContentBlock[] = [
|
|
126
|
+
{ type: 'thinking', thinking: 'thinking...' },
|
|
127
|
+
{ type: 'text', text: 'The answer' },
|
|
128
|
+
]
|
|
129
|
+
expect(getTextFromBlocks(blocks)).toBe('The answer')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('returns empty string if no text blocks', () => {
|
|
133
|
+
const blocks: ContentBlock[] = [
|
|
134
|
+
{ type: 'thinking', thinking: 'thinking...' },
|
|
135
|
+
]
|
|
136
|
+
expect(getTextFromBlocks(blocks)).toBe('')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('buildToolCallMap', () => {
|
|
141
|
+
it('returns empty map for empty messages', () => {
|
|
142
|
+
const map = buildToolCallMap([])
|
|
143
|
+
expect(map.size).toBe(0)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('extracts tool calls from assistant messages', () => {
|
|
147
|
+
const messages: AgentMessage[] = [
|
|
148
|
+
{
|
|
149
|
+
role: 'assistant',
|
|
150
|
+
content: [
|
|
151
|
+
{ type: 'text', text: 'Let me run this' },
|
|
152
|
+
{ type: 'toolCall', id: 'call_1', name: 'Bash', arguments: { command: 'ls -la' } },
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
]
|
|
156
|
+
const map = buildToolCallMap(messages)
|
|
157
|
+
expect(map.size).toBe(1)
|
|
158
|
+
expect(map.get('call_1')).toEqual({ name: 'Bash', args: { command: 'ls -la' } })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('ignores user and toolResult messages', () => {
|
|
162
|
+
const messages: AgentMessage[] = [
|
|
163
|
+
{ role: 'user', content: [{ type: 'text', text: 'Hello' }] },
|
|
164
|
+
{ role: 'toolResult', toolCallId: 'call_1', toolName: 'Bash', content: [{ type: 'text', text: 'output' }] },
|
|
165
|
+
]
|
|
166
|
+
const map = buildToolCallMap(messages)
|
|
167
|
+
expect(map.size).toBe(0)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('handles multiple tool calls in one message', () => {
|
|
171
|
+
const messages: AgentMessage[] = [
|
|
172
|
+
{
|
|
173
|
+
role: 'assistant',
|
|
174
|
+
content: [
|
|
175
|
+
{ type: 'toolCall', id: 'call_1', name: 'Read', arguments: { path: 'a.txt' } },
|
|
176
|
+
{ type: 'toolCall', id: 'call_2', name: 'Read', arguments: { path: 'b.txt' } },
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
]
|
|
180
|
+
const map = buildToolCallMap(messages)
|
|
181
|
+
expect(map.size).toBe(2)
|
|
182
|
+
expect(map.get('call_1')).toEqual({ name: 'Read', args: { path: 'a.txt' } })
|
|
183
|
+
expect(map.get('call_2')).toEqual({ name: 'Read', args: { path: 'b.txt' } })
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('getCompletedToolCallIds', () => {
|
|
188
|
+
it('returns empty set for empty messages', () => {
|
|
189
|
+
const set = getCompletedToolCallIds([])
|
|
190
|
+
expect(set.size).toBe(0)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('collects toolCallIds from toolResult messages', () => {
|
|
194
|
+
const messages: AgentMessage[] = [
|
|
195
|
+
{ role: 'toolResult', toolCallId: 'call_1', toolName: 'Bash', content: [] },
|
|
196
|
+
{ role: 'toolResult', toolCallId: 'call_2', toolName: 'Read', content: [] },
|
|
197
|
+
]
|
|
198
|
+
const set = getCompletedToolCallIds(messages)
|
|
199
|
+
expect(set.size).toBe(2)
|
|
200
|
+
expect(set.has('call_1')).toBe(true)
|
|
201
|
+
expect(set.has('call_2')).toBe(true)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('ignores other message types', () => {
|
|
205
|
+
const messages: AgentMessage[] = [
|
|
206
|
+
{ role: 'user', content: [{ type: 'text', text: 'Hello' }] },
|
|
207
|
+
{ role: 'assistant', content: [{ type: 'text', text: 'Hi' }] },
|
|
208
|
+
]
|
|
209
|
+
const set = getCompletedToolCallIds(messages)
|
|
210
|
+
expect(set.size).toBe(0)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('buildRenderItems', () => {
|
|
215
|
+
it('returns empty array for empty messages', () => {
|
|
216
|
+
const items = buildRenderItems([], null, new Map(), new Set())
|
|
217
|
+
expect(items).toEqual([])
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('renders user message as user-message item', () => {
|
|
221
|
+
const messages: AgentMessage[] = [
|
|
222
|
+
{ role: 'user', content: [{ type: 'text', text: 'Hello' }] },
|
|
223
|
+
]
|
|
224
|
+
const items = buildRenderItems(messages, null, new Map(), new Set())
|
|
225
|
+
expect(items).toHaveLength(1)
|
|
226
|
+
expect(items[0]?.type).toBe('user-message')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('skips user messages with only whitespace', () => {
|
|
230
|
+
const messages: AgentMessage[] = [
|
|
231
|
+
{ role: 'user', content: [{ type: 'text', text: ' ' }] },
|
|
232
|
+
]
|
|
233
|
+
const items = buildRenderItems(messages, null, new Map(), new Set())
|
|
234
|
+
expect(items).toHaveLength(0)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('renders assistant text as text-chunk', () => {
|
|
238
|
+
const messages: AgentMessage[] = [
|
|
239
|
+
{ role: 'assistant', content: [{ type: 'text', text: 'Hello back' }] },
|
|
240
|
+
]
|
|
241
|
+
const items = buildRenderItems(messages, null, new Map(), new Set())
|
|
242
|
+
expect(items).toHaveLength(1)
|
|
243
|
+
expect(items[0]?.type).toBe('text-chunk')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('skips empty assistant text chunks', () => {
|
|
247
|
+
const messages: AgentMessage[] = [
|
|
248
|
+
{ role: 'assistant', content: [{ type: 'text', text: '' }] },
|
|
249
|
+
]
|
|
250
|
+
const items = buildRenderItems(messages, null, new Map(), new Set())
|
|
251
|
+
expect(items).toHaveLength(0)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('renders tool result with command for bash tools', () => {
|
|
255
|
+
const messages: AgentMessage[] = [
|
|
256
|
+
{ role: 'toolResult', toolCallId: 'call_1', toolName: 'Bash', content: [{ type: 'text', text: 'file1.txt\nfile2.txt' }] },
|
|
257
|
+
]
|
|
258
|
+
const toolCallMap = new Map([['call_1', { name: 'Bash', args: { command: 'ls' } }]])
|
|
259
|
+
const items = buildRenderItems(messages, null, toolCallMap, new Set(['call_1']))
|
|
260
|
+
expect(items).toHaveLength(1)
|
|
261
|
+
expect(items[0]).toEqual({
|
|
262
|
+
type: 'tool-result',
|
|
263
|
+
toolCallId: 'call_1',
|
|
264
|
+
toolName: 'Bash',
|
|
265
|
+
command: 'ls',
|
|
266
|
+
output: 'file1.txt\nfile2.txt',
|
|
267
|
+
isError: false,
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('renders tool result without command for non-bash tools', () => {
|
|
272
|
+
const messages: AgentMessage[] = [
|
|
273
|
+
{ role: 'toolResult', toolCallId: 'call_1', toolName: 'Read', content: [{ type: 'text', text: 'file content' }] },
|
|
274
|
+
]
|
|
275
|
+
const toolCallMap = new Map([['call_1', { name: 'Read', args: { path: 'test.txt' } }]])
|
|
276
|
+
const items = buildRenderItems(messages, null, toolCallMap, new Set(['call_1']))
|
|
277
|
+
expect(items).toHaveLength(1)
|
|
278
|
+
expect(items[0]).toEqual({
|
|
279
|
+
type: 'tool-result',
|
|
280
|
+
toolCallId: 'call_1',
|
|
281
|
+
toolName: 'Read',
|
|
282
|
+
command: undefined,
|
|
283
|
+
output: 'file content',
|
|
284
|
+
isError: false,
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('shows tool-call when result not yet received', () => {
|
|
289
|
+
const messages: AgentMessage[] = [
|
|
290
|
+
{
|
|
291
|
+
role: 'assistant',
|
|
292
|
+
content: [{ type: 'toolCall', id: 'call_1', name: 'Bash', arguments: { command: 'ls' } }],
|
|
293
|
+
},
|
|
294
|
+
]
|
|
295
|
+
const items = buildRenderItems(messages, null, new Map(), new Set())
|
|
296
|
+
expect(items).toHaveLength(1)
|
|
297
|
+
expect(items[0]?.type).toBe('tool-call')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('hides tool-call when result is received', () => {
|
|
301
|
+
const messages: AgentMessage[] = [
|
|
302
|
+
{
|
|
303
|
+
role: 'assistant',
|
|
304
|
+
content: [{ type: 'toolCall', id: 'call_1', name: 'Bash', arguments: { command: 'ls' } }],
|
|
305
|
+
},
|
|
306
|
+
{ role: 'toolResult', toolCallId: 'call_1', toolName: 'Bash', content: [{ type: 'text', text: 'output' }] },
|
|
307
|
+
]
|
|
308
|
+
const toolCallMap = new Map([['call_1', { name: 'Bash', args: { command: 'ls' } }]])
|
|
309
|
+
const completedIds = new Set(['call_1'])
|
|
310
|
+
const items = buildRenderItems(messages, null, toolCallMap, completedIds)
|
|
311
|
+
// Should only have the tool-result, not the tool-call
|
|
312
|
+
expect(items).toHaveLength(1)
|
|
313
|
+
expect(items[0]?.type).toBe('tool-result')
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('marks streaming message text as streaming', () => {
|
|
317
|
+
const messages: AgentMessage[] = [
|
|
318
|
+
{ role: 'user', content: [{ type: 'text', text: 'Hello' }] },
|
|
319
|
+
{ role: 'assistant', content: [{ type: 'text', text: 'I am typing...' }] },
|
|
320
|
+
]
|
|
321
|
+
const items = buildRenderItems(messages, 1, new Map(), new Set())
|
|
322
|
+
expect(items).toHaveLength(2)
|
|
323
|
+
expect(items[1]).toEqual({
|
|
324
|
+
type: 'text-chunk',
|
|
325
|
+
blocks: [{ type: 'text', text: 'I am typing...' }],
|
|
326
|
+
isStreaming: true,
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('handles error tool results', () => {
|
|
331
|
+
const messages: AgentMessage[] = [
|
|
332
|
+
{ role: 'toolResult', toolCallId: 'call_1', toolName: 'Bash', content: [{ type: 'text', text: 'command not found' }], isError: true },
|
|
333
|
+
]
|
|
334
|
+
const items = buildRenderItems(messages, null, new Map(), new Set())
|
|
335
|
+
expect(items).toHaveLength(1)
|
|
336
|
+
expect(items[0]).toMatchObject({
|
|
337
|
+
type: 'tool-result',
|
|
338
|
+
isError: true,
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('extracts diff for Edit tool results', () => {
|
|
343
|
+
const messages: AgentMessage[] = [
|
|
344
|
+
{
|
|
345
|
+
role: 'assistant',
|
|
346
|
+
content: [{ type: 'toolCall', id: 'call_1', name: 'Edit', arguments: { file_path: 'src/App.tsx', old_string: 'foo', new_string: 'bar' } }],
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
role: 'toolResult',
|
|
350
|
+
toolCallId: 'call_1',
|
|
351
|
+
toolName: 'Edit',
|
|
352
|
+
content: [{ type: 'text', text: 'Successfully edited src/App.tsx' }],
|
|
353
|
+
details: { diff: '--- a/src/App.tsx\n+++ b/src/App.tsx\n@@ -1,1 +1,1 @@\n-foo\n+bar' },
|
|
354
|
+
},
|
|
355
|
+
]
|
|
356
|
+
const toolCallMap = new Map([['call_1', { name: 'Edit', args: { file_path: 'src/App.tsx' } }]])
|
|
357
|
+
const completedIds = new Set(['call_1'])
|
|
358
|
+
const items = buildRenderItems(messages, null, toolCallMap, completedIds)
|
|
359
|
+
|
|
360
|
+
expect(items).toHaveLength(1)
|
|
361
|
+
expect(items[0]?.type).toBe('tool-result')
|
|
362
|
+
const result = items[0] as { type: 'tool-result'; diff?: string; filePath?: string }
|
|
363
|
+
expect(result.diff).toBe('--- a/src/App.tsx\n+++ b/src/App.tsx\n@@ -1,1 +1,1 @@\n-foo\n+bar')
|
|
364
|
+
expect(result.filePath).toBe('src/App.tsx')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('handles full conversation flow', () => {
|
|
368
|
+
const messages: AgentMessage[] = [
|
|
369
|
+
{ role: 'user', content: [{ type: 'text', text: 'List files' }] },
|
|
370
|
+
{
|
|
371
|
+
role: 'assistant',
|
|
372
|
+
content: [
|
|
373
|
+
{ type: 'text', text: 'Sure, let me list the files.' },
|
|
374
|
+
{ type: 'toolCall', id: 'call_1', name: 'Bash', arguments: { command: 'ls' } },
|
|
375
|
+
],
|
|
376
|
+
},
|
|
377
|
+
{ role: 'toolResult', toolCallId: 'call_1', toolName: 'Bash', content: [{ type: 'text', text: 'file1.txt\nfile2.txt' }] },
|
|
378
|
+
{
|
|
379
|
+
role: 'assistant',
|
|
380
|
+
content: [{ type: 'text', text: 'There are 2 files in the directory.' }],
|
|
381
|
+
},
|
|
382
|
+
]
|
|
383
|
+
const toolCallMap = new Map([['call_1', { name: 'Bash', args: { command: 'ls' } }]])
|
|
384
|
+
const completedIds = new Set(['call_1'])
|
|
385
|
+
const items = buildRenderItems(messages, null, toolCallMap, completedIds)
|
|
386
|
+
|
|
387
|
+
expect(items).toHaveLength(4)
|
|
388
|
+
expect(items[0]?.type).toBe('user-message')
|
|
389
|
+
expect(items[1]?.type).toBe('text-chunk') // "Sure, let me list the files."
|
|
390
|
+
expect(items[2]?.type).toBe('tool-result') // ls result
|
|
391
|
+
expect(items[3]?.type).toBe('text-chunk') // "There are 2 files..."
|
|
392
|
+
})
|
|
393
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { ContentBlock, RenderItem, AgentMessage, EditToolDetails } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Split an assistant message's content into render items.
|
|
5
|
+
* Text/thinking blocks are grouped, toolCall blocks are separated.
|
|
6
|
+
*/
|
|
7
|
+
export function splitAssistantContent(content: ContentBlock[], isStreaming: boolean): RenderItem[] {
|
|
8
|
+
const items: RenderItem[] = []
|
|
9
|
+
let textBuffer: ContentBlock[] = []
|
|
10
|
+
|
|
11
|
+
for (const block of content) {
|
|
12
|
+
if (block.type === 'toolCall') {
|
|
13
|
+
// Flush text buffer
|
|
14
|
+
if (textBuffer.length > 0) {
|
|
15
|
+
items.push({ type: 'text-chunk', blocks: textBuffer, isStreaming: false })
|
|
16
|
+
textBuffer = []
|
|
17
|
+
}
|
|
18
|
+
items.push({ type: 'tool-call', id: block.id, name: block.name, args: block.arguments })
|
|
19
|
+
} else {
|
|
20
|
+
textBuffer.push(block)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Flush remaining text (this is the streaming portion if isStreaming)
|
|
25
|
+
if (textBuffer.length > 0) {
|
|
26
|
+
items.push({ type: 'text-chunk', blocks: textBuffer, isStreaming })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return items
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get text content from content blocks
|
|
34
|
+
*/
|
|
35
|
+
export function getTextFromBlocks(blocks: ContentBlock[]): string {
|
|
36
|
+
for (const block of blocks) {
|
|
37
|
+
if (block.type === 'text') return block.text
|
|
38
|
+
}
|
|
39
|
+
return ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a map of toolCallId -> toolCall info for matching results to their calls
|
|
44
|
+
*/
|
|
45
|
+
export function buildToolCallMap(messages: AgentMessage[]): Map<string, { name: string; args: Record<string, unknown> }> {
|
|
46
|
+
const map = new Map<string, { name: string; args: Record<string, unknown> }>()
|
|
47
|
+
for (const msg of messages) {
|
|
48
|
+
if (msg.role === 'assistant') {
|
|
49
|
+
for (const block of msg.content) {
|
|
50
|
+
if (block.type === 'toolCall') {
|
|
51
|
+
map.set(block.id, { name: block.name, args: block.arguments })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return map
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get set of toolCallIds that have results in messages
|
|
61
|
+
*/
|
|
62
|
+
export function getCompletedToolCallIds(messages: AgentMessage[]): Set<string> {
|
|
63
|
+
const set = new Set<string>()
|
|
64
|
+
for (const msg of messages) {
|
|
65
|
+
if (msg.role === 'toolResult') {
|
|
66
|
+
set.add(msg.toolCallId)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return set
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build render items from messages for display
|
|
74
|
+
*/
|
|
75
|
+
export function buildRenderItems(
|
|
76
|
+
messages: AgentMessage[],
|
|
77
|
+
streamingMessageIndex: number | null,
|
|
78
|
+
toolCallMap: Map<string, { name: string; args: Record<string, unknown> }>,
|
|
79
|
+
completedToolCallIds: Set<string>
|
|
80
|
+
): RenderItem[] {
|
|
81
|
+
const items: RenderItem[] = []
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < messages.length; i++) {
|
|
84
|
+
const msg = messages[i]!
|
|
85
|
+
const isStreamingMsg = streamingMessageIndex === i
|
|
86
|
+
|
|
87
|
+
if (msg.role === 'user') {
|
|
88
|
+
// User message
|
|
89
|
+
const hasText = msg.content.some(b => b.type === 'text' && (b as { type: 'text'; text: string }).text.trim())
|
|
90
|
+
if (hasText) {
|
|
91
|
+
items.push({ type: 'user-message', content: msg.content })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (msg.role === 'toolResult') {
|
|
95
|
+
// Tool result - render as mini shell or diff view
|
|
96
|
+
const toolCall = toolCallMap.get(msg.toolCallId)
|
|
97
|
+
const output = getTextFromBlocks(msg.content)
|
|
98
|
+
const toolName = toolCall?.name || msg.toolName
|
|
99
|
+
|
|
100
|
+
let command: string | undefined
|
|
101
|
+
let diff: string | undefined
|
|
102
|
+
let filePath: string | undefined
|
|
103
|
+
|
|
104
|
+
if (toolCall && (toolName === 'bash' || toolName === 'Bash')) {
|
|
105
|
+
command = (toolCall.args as { command?: string }).command
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Extract diff for Edit/Write tools
|
|
109
|
+
if (toolName === 'Edit' || toolName === 'edit' || toolName === 'Write' || toolName === 'write') {
|
|
110
|
+
const details = msg.details as EditToolDetails | undefined
|
|
111
|
+
if (details?.diff) {
|
|
112
|
+
diff = details.diff
|
|
113
|
+
}
|
|
114
|
+
// Get file path from tool call args
|
|
115
|
+
if (toolCall) {
|
|
116
|
+
filePath = (toolCall.args as { file_path?: string; path?: string }).file_path ||
|
|
117
|
+
(toolCall.args as { file_path?: string; path?: string }).path
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
items.push({
|
|
122
|
+
type: 'tool-result',
|
|
123
|
+
toolCallId: msg.toolCallId,
|
|
124
|
+
toolName,
|
|
125
|
+
command,
|
|
126
|
+
output,
|
|
127
|
+
isError: msg.isError || false,
|
|
128
|
+
diff,
|
|
129
|
+
filePath,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
else if (msg.role === 'assistant') {
|
|
133
|
+
// Split assistant message into text chunks and tool calls
|
|
134
|
+
const splitItems = splitAssistantContent(msg.content, isStreamingMsg)
|
|
135
|
+
|
|
136
|
+
for (const item of splitItems) {
|
|
137
|
+
if (item.type === 'text-chunk') {
|
|
138
|
+
// Only add if has displayable content
|
|
139
|
+
const hasText = item.blocks.some(b =>
|
|
140
|
+
(b.type === 'text' && b.text.trim()) ||
|
|
141
|
+
(b.type === 'thinking' && (b as { type: 'thinking'; thinking: string }).thinking.trim())
|
|
142
|
+
)
|
|
143
|
+
if (hasText) {
|
|
144
|
+
items.push(item)
|
|
145
|
+
}
|
|
146
|
+
} else if (item.type === 'tool-call') {
|
|
147
|
+
// Only show tool call if we don't have a result for it yet
|
|
148
|
+
// (the result will be shown instead)
|
|
149
|
+
if (!completedToolCallIds.has(item.id)) {
|
|
150
|
+
items.push(item)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return items
|
|
158
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"useDefineForClassFields": true,
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"types": ["vite/client"],
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
|
|
11
|
+
/* Bundler mode */
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"moduleDetection": "force",
|
|
16
|
+
"noEmit": true,
|
|
17
|
+
"jsx": "react-jsx",
|
|
18
|
+
|
|
19
|
+
/* Linting */
|
|
20
|
+
"strict": true,
|
|
21
|
+
"noUnusedLocals": true,
|
|
22
|
+
"noUnusedParameters": true,
|
|
23
|
+
"erasableSyntaxOnly": true,
|
|
24
|
+
"noFallthroughCasesInSwitch": true,
|
|
25
|
+
"noUncheckedSideEffectImports": true,
|
|
26
|
+
"baseUrl": ".",
|
|
27
|
+
"paths": {
|
|
28
|
+
"@/*": ["./src/*"]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"include": ["src"]
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
4
|
+
"target": "ES2023",
|
|
5
|
+
"lib": ["ES2023"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
|
|
10
|
+
/* Bundler mode */
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"allowImportingTsExtensions": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
14
|
+
"moduleDetection": "force",
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
/* Linting */
|
|
18
|
+
"strict": true,
|
|
19
|
+
"noUnusedLocals": true,
|
|
20
|
+
"noUnusedParameters": true,
|
|
21
|
+
"erasableSyntaxOnly": true,
|
|
22
|
+
"noFallthroughCasesInSwitch": true,
|
|
23
|
+
"noUncheckedSideEffectImports": true
|
|
24
|
+
},
|
|
25
|
+
"include": ["vite.config.ts"]
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
import tailwindcss from "@tailwindcss/vite"
|
|
3
|
+
import react from "@vitejs/plugin-react"
|
|
4
|
+
import { defineConfig } from "vite"
|
|
5
|
+
|
|
6
|
+
// https://vite.dev/config/
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
plugins: [react(), tailwindcss()],
|
|
9
|
+
resolve: {
|
|
10
|
+
alias: {
|
|
11
|
+
"@": path.resolve(__dirname, "./src"),
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [react()],
|
|
7
|
+
test: {
|
|
8
|
+
environment: 'happy-dom',
|
|
9
|
+
globals: true,
|
|
10
|
+
include: ['src/**/*.test.{ts,tsx}'],
|
|
11
|
+
},
|
|
12
|
+
resolve: {
|
|
13
|
+
alias: {
|
|
14
|
+
'@': path.resolve(__dirname, './src'),
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
})
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Terminal Plugin
|
|
2
|
+
|
|
3
|
+
A full-featured terminal emulator in your browser. Spawns a real PTY running your shell (zsh, bash, etc.) with proper job control, colors, and scrollback. Multiple clients can view the same terminal session, and scrollback persists across daemon restarts.
|
|
4
|
+
|
|
5
|
+
**Usage:** Split a panel and select "Terminal". You get a fresh shell in your home directory. Resize works automatically—the PTY adapts to your panel size.
|
|
6
|
+
|
|
7
|
+
**Vision:** Terminals that live alongside your AI agents in a unified workspace. Split horizontally, run your agent on top, terminal on bottom, and watch them work together.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" />
|
|
6
|
+
<title>Terminal</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
}
|
|
13
|
+
html, body, #root {
|
|
14
|
+
width: 100%;
|
|
15
|
+
height: 100%;
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
}
|
|
18
|
+
</style>
|
|
19
|
+
</head>
|
|
20
|
+
<body>
|
|
21
|
+
<div id="root"></div>
|
|
22
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|