@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.
Files changed (53) hide show
  1. package/bundled-plugins/agent/AGENT_SPEC.md +611 -0
  2. package/bundled-plugins/agent/README.md +7 -0
  3. package/bundled-plugins/agent/components.json +24 -0
  4. package/bundled-plugins/agent/eslint.config.js +23 -0
  5. package/bundled-plugins/agent/index.html +13 -0
  6. package/bundled-plugins/agent/package-lock.json +12140 -0
  7. package/bundled-plugins/agent/package.json +62 -0
  8. package/bundled-plugins/agent/public/vite.svg +1 -0
  9. package/bundled-plugins/agent/server.js +631 -0
  10. package/bundled-plugins/agent/src/App.tsx +755 -0
  11. package/bundled-plugins/agent/src/assets/react.svg +1 -0
  12. package/bundled-plugins/agent/src/components/ui/alert-dialog.tsx +182 -0
  13. package/bundled-plugins/agent/src/components/ui/badge.tsx +45 -0
  14. package/bundled-plugins/agent/src/components/ui/button.tsx +60 -0
  15. package/bundled-plugins/agent/src/components/ui/card.tsx +94 -0
  16. package/bundled-plugins/agent/src/components/ui/combobox.tsx +294 -0
  17. package/bundled-plugins/agent/src/components/ui/dropdown-menu.tsx +253 -0
  18. package/bundled-plugins/agent/src/components/ui/field.tsx +225 -0
  19. package/bundled-plugins/agent/src/components/ui/input-group.tsx +147 -0
  20. package/bundled-plugins/agent/src/components/ui/input.tsx +19 -0
  21. package/bundled-plugins/agent/src/components/ui/label.tsx +24 -0
  22. package/bundled-plugins/agent/src/components/ui/select.tsx +185 -0
  23. package/bundled-plugins/agent/src/components/ui/separator.tsx +26 -0
  24. package/bundled-plugins/agent/src/components/ui/switch.tsx +31 -0
  25. package/bundled-plugins/agent/src/components/ui/textarea.tsx +18 -0
  26. package/bundled-plugins/agent/src/index.css +131 -0
  27. package/bundled-plugins/agent/src/lib/utils.ts +6 -0
  28. package/bundled-plugins/agent/src/main.tsx +11 -0
  29. package/bundled-plugins/agent/src/reducer.test.ts +359 -0
  30. package/bundled-plugins/agent/src/reducer.ts +255 -0
  31. package/bundled-plugins/agent/src/store.ts +379 -0
  32. package/bundled-plugins/agent/src/types.ts +98 -0
  33. package/bundled-plugins/agent/src/utils.test.ts +393 -0
  34. package/bundled-plugins/agent/src/utils.ts +158 -0
  35. package/bundled-plugins/agent/tsconfig.app.json +32 -0
  36. package/bundled-plugins/agent/tsconfig.json +13 -0
  37. package/bundled-plugins/agent/tsconfig.node.json +26 -0
  38. package/bundled-plugins/agent/vite.config.ts +14 -0
  39. package/bundled-plugins/agent/vitest.config.ts +17 -0
  40. package/bundled-plugins/terminal/README.md +7 -0
  41. package/bundled-plugins/terminal/index.html +24 -0
  42. package/bundled-plugins/terminal/package-lock.json +3346 -0
  43. package/bundled-plugins/terminal/package.json +38 -0
  44. package/bundled-plugins/terminal/server.ts +265 -0
  45. package/bundled-plugins/terminal/src/App.tsx +153 -0
  46. package/bundled-plugins/terminal/src/TERMINAL_SPEC.md +404 -0
  47. package/bundled-plugins/terminal/src/main.tsx +9 -0
  48. package/bundled-plugins/terminal/src/store.ts +114 -0
  49. package/bundled-plugins/terminal/tsconfig.json +22 -0
  50. package/bundled-plugins/terminal/vite.config.ts +10 -0
  51. package/dist/src/plugin-manager.js +1 -1
  52. package/dist/src/plugin-manager.js.map +1 -1
  53. 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,13 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ],
7
+ "compilerOptions": {
8
+ "baseUrl": ".",
9
+ "paths": {
10
+ "@/*": ["./src/*"]
11
+ }
12
+ }
13
+ }
@@ -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>