@bytespell/shella 0.2.4 → 0.2.5

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 (51) 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/package.json +1 -1
@@ -0,0 +1,755 @@
1
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
2
+ import { Button } from "@/components/ui/button"
3
+ import { Textarea } from "@/components/ui/textarea"
4
+ import { Badge } from "@/components/ui/badge"
5
+ import { Card } from "@/components/ui/card"
6
+ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"
7
+ import { Switch } from "@/components/ui/switch"
8
+ import { Streamdown } from 'streamdown'
9
+ import { code } from '@streamdown/code'
10
+ import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
11
+
12
+ import type { ContentBlock, Session } from './types'
13
+ import {
14
+ buildRenderItems,
15
+ buildToolCallMap,
16
+ getCompletedToolCallIds,
17
+ } from './utils'
18
+ import { useAgentStore } from './store'
19
+
20
+ const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const
21
+
22
+ // Format timestamp to relative or absolute time
23
+ function formatTime(timestamp: number | null | undefined): string {
24
+ if (!timestamp) return ''
25
+ const date = new Date(timestamp)
26
+ const now = new Date()
27
+ const diffMs = now.getTime() - date.getTime()
28
+ const diffMins = Math.floor(diffMs / 60000)
29
+ const diffHours = Math.floor(diffMs / 3600000)
30
+ const diffDays = Math.floor(diffMs / 86400000)
31
+
32
+ if (diffMins < 1) return 'just now'
33
+ if (diffMins < 60) return `${diffMins}m ago`
34
+ if (diffHours < 24) return `${diffHours}h ago`
35
+ if (diffDays < 7) return `${diffDays}d ago`
36
+
37
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
38
+ }
39
+
40
+ // =============================================================================
41
+ // Scroll to Bottom Button
42
+ // =============================================================================
43
+
44
+ function ScrollToBottomButton() {
45
+ const { isAtBottom, scrollToBottom } = useStickToBottomContext()
46
+
47
+ if (isAtBottom) return null
48
+
49
+ return (
50
+ <button
51
+ className="absolute left-1/2 -translate-x-1/2 bottom-4 bg-muted hover:bg-muted/80 text-muted-foreground px-3 py-1 rounded-full text-xs shadow-lg z-10"
52
+ onClick={() => scrollToBottom()}
53
+ >
54
+ Scroll to bottom
55
+ </button>
56
+ )
57
+ }
58
+
59
+ // =============================================================================
60
+ // Diff View - simple colorized diff display
61
+ // =============================================================================
62
+
63
+ function DiffView({ patch }: { patch: string }) {
64
+ return (
65
+ <pre className="p-3 text-xs bg-background font-mono whitespace-pre-wrap break-words">
66
+ {patch.split('\n').map((line, idx) => {
67
+ let className = 'text-muted-foreground'
68
+ if (line.startsWith('+') && !line.startsWith('+++')) {
69
+ className = 'text-green-600 dark:text-green-400 bg-green-500/10'
70
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
71
+ className = 'text-red-600 dark:text-red-400 bg-red-500/10'
72
+ } else if (line.startsWith('@@')) {
73
+ className = 'text-blue-600 dark:text-blue-400'
74
+ }
75
+ return (
76
+ <div key={idx} className={className}>
77
+ {line || ' '}
78
+ </div>
79
+ )
80
+ })}
81
+ </pre>
82
+ )
83
+ }
84
+
85
+ // =============================================================================
86
+ // Session Picker
87
+ // =============================================================================
88
+
89
+ interface SessionPickerProps {
90
+ sessions: Session[]
91
+ onSelect: (sessionPath: string) => void
92
+ onNewSession: () => void
93
+ onClose: () => void
94
+ }
95
+
96
+ function SessionPicker({ sessions, onSelect, onNewSession, onClose }: SessionPickerProps) {
97
+ // Close on escape key
98
+ useEffect(() => {
99
+ const handleKeyDown = (e: KeyboardEvent) => {
100
+ if (e.key === 'Escape') onClose()
101
+ }
102
+ document.addEventListener('keydown', handleKeyDown)
103
+ return () => document.removeEventListener('keydown', handleKeyDown)
104
+ }, [onClose])
105
+
106
+ return (
107
+ <div
108
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
109
+ onClick={(e) => {
110
+ if (e.target === e.currentTarget) onClose()
111
+ }}
112
+ >
113
+ <div className="bg-popover border rounded-lg shadow-lg w-full max-w-md mx-4 max-h-[80vh] flex flex-col">
114
+ <div className="px-4 py-3 border-b flex items-center justify-between flex-shrink-0">
115
+ <span className="text-base font-medium">Sessions</span>
116
+ <div className="flex items-center gap-2">
117
+ <Button
118
+ size="sm"
119
+ variant="outline"
120
+ className="h-7 px-3 text-xs"
121
+ onClick={() => {
122
+ onNewSession()
123
+ onClose()
124
+ }}
125
+ >
126
+ + New
127
+ </Button>
128
+ <button
129
+ onClick={onClose}
130
+ className="text-muted-foreground hover:text-foreground p-1"
131
+ >
132
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
133
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
134
+ </svg>
135
+ </button>
136
+ </div>
137
+ </div>
138
+ <div className="flex-1 overflow-y-auto">
139
+ {sessions.length === 0 ? (
140
+ <div className="px-4 py-12 text-center text-sm text-muted-foreground">
141
+ No sessions yet
142
+ </div>
143
+ ) : (
144
+ sessions.map((session) => (
145
+ <button
146
+ key={session.id}
147
+ onClick={() => {
148
+ if (!session.isCurrent && session.file) {
149
+ onSelect(session.file)
150
+ onClose()
151
+ }
152
+ }}
153
+ className={`w-full px-4 py-3 text-left hover:bg-muted transition-colors border-b last:border-b-0 ${
154
+ session.isCurrent ? 'bg-muted/50' : ''
155
+ }`}
156
+ >
157
+ <div className="flex items-start justify-between gap-3">
158
+ <div className="flex-1 min-w-0">
159
+ <div className={`text-sm ${session.isCurrent ? 'font-medium' : ''}`}>
160
+ {session.name || 'Untitled session'}
161
+ </div>
162
+ <div className="flex items-center gap-2 mt-1">
163
+ <span className="text-xs text-muted-foreground">
164
+ {session.messageCount || 0} messages
165
+ </span>
166
+ <span className="text-xs text-muted-foreground">
167
+ {formatTime(session.lastModified)}
168
+ </span>
169
+ </div>
170
+ </div>
171
+ {session.isCurrent && (
172
+ <span className="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded flex-shrink-0">
173
+ current
174
+ </span>
175
+ )}
176
+ </div>
177
+ </button>
178
+ ))
179
+ )}
180
+ </div>
181
+ </div>
182
+ </div>
183
+ )
184
+ }
185
+
186
+ // =============================================================================
187
+ // Main Component
188
+ // =============================================================================
189
+
190
+ export function App() {
191
+ // Store state
192
+ const status = useAgentStore(s => s.status)
193
+ const error = useAgentStore(s => s.error)
194
+ const currentCwd = useAgentStore(s => s.currentCwd)
195
+ const sessionState = useAgentStore(s => s.sessionState)
196
+ const availableModels = useAgentStore(s => s.availableModels)
197
+ const messages = useAgentStore(s => s.messages)
198
+ const streamingMessageIndex = useAgentStore(s => s.streamingMessageIndex)
199
+ const activeToolCalls = useAgentStore(s => s.activeToolCalls)
200
+
201
+ const sessions = useAgentStore(s => s.sessions)
202
+
203
+ // Store actions
204
+ const connect = useAgentStore(s => s.connect)
205
+ const sendPrompt = useAgentStore(s => s.sendPrompt)
206
+ const abort = useAgentStore(s => s.abort)
207
+ const changeCwd = useAgentStore(s => s.changeCwd)
208
+ const setError = useAgentStore(s => s.setError)
209
+ const getAvailableModels = useAgentStore(s => s.getAvailableModels)
210
+ const getSessions = useAgentStore(s => s.getSessions)
211
+ const setModel = useAgentStore(s => s.setModel)
212
+ const setThinkingLevel = useAgentStore(s => s.setThinkingLevel)
213
+ const setAutoCompaction = useAgentStore(s => s.setAutoCompaction)
214
+ const compact = useAgentStore(s => s.compact)
215
+ const newSession = useAgentStore(s => s.newSession)
216
+ const switchSession = useAgentStore(s => s.switchSession)
217
+
218
+ // Auth state (local - not part of WebSocket)
219
+ const [authStatus, setAuthStatus] = useState<{ authenticated: boolean; providers: string[] } | null>(null)
220
+
221
+ // UI state (local)
222
+ const [input, setInput] = useState('')
223
+ const [showDirPicker, setShowDirPicker] = useState(false)
224
+ const [showSessionPicker, setShowSessionPicker] = useState(false)
225
+ const [pickerPath, setPickerPath] = useState<string | null>(null)
226
+ const [pickerDirs, setPickerDirs] = useState<string[]>([])
227
+ const [pickerParent, setPickerParent] = useState<string | null>(null)
228
+ const [pickerIsRoot, setPickerIsRoot] = useState(false)
229
+ const [pickerLoading, setPickerLoading] = useState(false)
230
+
231
+ // Refs
232
+ const pickerRef = useRef<HTMLDivElement | null>(null)
233
+
234
+ // ==========================================================================
235
+ // Connect on mount
236
+ // ==========================================================================
237
+
238
+ useEffect(() => {
239
+ connect()
240
+ }, [connect])
241
+
242
+ // ==========================================================================
243
+ // Auth check
244
+ // ==========================================================================
245
+
246
+ useEffect(() => {
247
+ fetch('/api/auth/status')
248
+ .then(r => r.json())
249
+ .then(setAuthStatus)
250
+ .catch(() => setAuthStatus({ authenticated: false, providers: [] }))
251
+ }, [])
252
+
253
+ // ==========================================================================
254
+ // Directory Picker
255
+ // ==========================================================================
256
+
257
+ useEffect(() => {
258
+ if (!showDirPicker) return
259
+ const handleClickOutside = (e: MouseEvent) => {
260
+ if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
261
+ setShowDirPicker(false)
262
+ }
263
+ }
264
+ document.addEventListener('mousedown', handleClickOutside)
265
+ return () => document.removeEventListener('mousedown', handleClickOutside)
266
+ }, [showDirPicker])
267
+
268
+ const fetchDirectories = useCallback(async (dirPath: string) => {
269
+ setPickerLoading(true)
270
+ try {
271
+ const res = await fetch(`/api/directories?path=${encodeURIComponent(dirPath)}`)
272
+ if (res.ok) {
273
+ const data = await res.json()
274
+ setPickerPath(data.path)
275
+ setPickerDirs(data.directories)
276
+ setPickerParent(data.parent)
277
+ setPickerIsRoot(data.isRoot)
278
+ }
279
+ } catch (e) {
280
+ console.error('Failed to fetch directories:', e)
281
+ } finally {
282
+ setPickerLoading(false)
283
+ }
284
+ }, [])
285
+
286
+ const openDirPicker = useCallback(() => {
287
+ setShowDirPicker(true)
288
+ fetchDirectories(currentCwd || '')
289
+ }, [currentCwd, fetchDirectories])
290
+
291
+ const selectDirectory = useCallback((dirPath: string) => {
292
+ changeCwd(dirPath)
293
+ setShowDirPicker(false)
294
+ }, [changeCwd])
295
+
296
+ // ==========================================================================
297
+ // User Actions
298
+ // ==========================================================================
299
+
300
+ const handleSend = useCallback(() => {
301
+ if (!input.trim()) return
302
+ sendPrompt(input.trim())
303
+ setInput('')
304
+ }, [input, sendPrompt])
305
+
306
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
307
+ if (e.key === 'Enter' && !e.shiftKey) {
308
+ e.preventDefault()
309
+ handleSend()
310
+ }
311
+ }, [handleSend])
312
+
313
+ const refreshAuth = useCallback(async () => {
314
+ const res = await fetch('/api/auth/status')
315
+ setAuthStatus(await res.json())
316
+ }, [])
317
+
318
+ // ==========================================================================
319
+ // Build Render Items from Messages
320
+ // ==========================================================================
321
+
322
+ const toolCallMap = useMemo(() => buildToolCallMap(messages), [messages])
323
+ const completedToolCallIds = useMemo(() => getCompletedToolCallIds(messages), [messages])
324
+ const renderItems = useMemo(
325
+ () => buildRenderItems(messages, streamingMessageIndex, toolCallMap, completedToolCallIds),
326
+ [messages, streamingMessageIndex, toolCallMap, completedToolCallIds]
327
+ )
328
+
329
+ // ==========================================================================
330
+ // Render Helpers
331
+ // ==========================================================================
332
+
333
+ const supportsReasoning = sessionState?.model?.reasoning === true
334
+
335
+ const renderTextBlocks = (blocks: ContentBlock[], isStreamingContent: boolean, isUser: boolean) => {
336
+ const elements: React.ReactNode[] = []
337
+
338
+ for (let i = 0; i < blocks.length; i++) {
339
+ const block = blocks[i]!
340
+
341
+ if (block.type === 'text' && block.text.trim()) {
342
+ if (isUser) {
343
+ elements.push(
344
+ <div key={i} className="whitespace-pre-wrap break-words overflow-hidden">
345
+ {block.text}
346
+ </div>
347
+ )
348
+ } else {
349
+ elements.push(
350
+ <div key={i} className="prose prose-sm dark:prose-invert max-w-none break-words overflow-hidden">
351
+ <Streamdown mode={isStreamingContent ? undefined : "static"} plugins={{ code }}>
352
+ {block.text}
353
+ </Streamdown>
354
+ </div>
355
+ )
356
+ }
357
+ }
358
+ else if (block.type === 'thinking') {
359
+ const thinking = (block as { type: 'thinking'; thinking: string }).thinking
360
+ if (thinking.trim()) {
361
+ elements.push(
362
+ <details key={i} className="text-muted-foreground text-sm">
363
+ <summary className="cursor-pointer">Thinking...</summary>
364
+ <div className="mt-2 pl-3 border-l-2 border-muted break-words overflow-hidden">{thinking}</div>
365
+ </details>
366
+ )
367
+ }
368
+ }
369
+ }
370
+
371
+ return elements
372
+ }
373
+
374
+ // ==========================================================================
375
+ // Auth Screen
376
+ // ==========================================================================
377
+
378
+ if (authStatus && !authStatus.authenticated && status === 'connected') {
379
+ return (
380
+ <div className="h-screen flex items-center justify-center bg-background p-4">
381
+ <Card className="max-w-sm w-full p-6 text-center space-y-4">
382
+ <p className="text-muted-foreground text-sm">
383
+ Run <code className="bg-muted px-1.5 py-0.5 rounded">pi</code> in your terminal to log in.
384
+ </p>
385
+ <div className="text-left text-sm text-muted-foreground">
386
+ <p className="mb-2">Supported providers:</p>
387
+ <ul className="list-disc list-inside space-y-1">
388
+ <li>Anthropic (Claude Pro/Max)</li>
389
+ <li>GitHub Copilot</li>
390
+ <li>Google Gemini CLI</li>
391
+ <li>OpenAI Codex</li>
392
+ </ul>
393
+ </div>
394
+ <Button variant="secondary" onClick={refreshAuth}>Refresh</Button>
395
+ </Card>
396
+ </div>
397
+ )
398
+ }
399
+
400
+ // ==========================================================================
401
+ // Main Render
402
+ // ==========================================================================
403
+
404
+ return (
405
+ <div className="h-screen flex flex-col bg-background overflow-hidden">
406
+ {/* Header */}
407
+ <div className="flex items-center justify-between px-4 py-2 border-b sticky top-0 z-10 bg-background">
408
+ <div className="flex items-center gap-2">
409
+ {/* Directory picker */}
410
+ <div className="relative" ref={pickerRef}>
411
+ <button
412
+ onClick={openDirPicker}
413
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors truncate max-w-[200px] flex items-center gap-1"
414
+ title={currentCwd || 'Click to set working directory'}
415
+ >
416
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
417
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
418
+ </svg>
419
+ {currentCwd ? currentCwd.split('/').pop() || '/' : 'Set cwd...'}
420
+ </button>
421
+
422
+ {showDirPicker && (
423
+ <div className="absolute top-full left-0 mt-1 w-72 bg-popover border rounded-lg shadow-lg z-50">
424
+ <div className="px-3 py-2 border-b text-xs text-muted-foreground truncate" title={pickerPath || ''}>
425
+ {pickerPath || 'Loading...'}
426
+ </div>
427
+ {!pickerIsRoot && pickerParent && (
428
+ <button
429
+ onClick={() => fetchDirectories(pickerParent)}
430
+ className="w-full px-3 py-1.5 text-left text-sm hover:bg-muted flex items-center gap-2 border-b"
431
+ >
432
+ <svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
433
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 17l-5-5m0 0l5-5m-5 5h12" />
434
+ </svg>
435
+ <span className="text-muted-foreground">..</span>
436
+ </button>
437
+ )}
438
+ <div className="max-h-64 overflow-y-auto">
439
+ {pickerLoading ? (
440
+ <div className="px-3 py-4 text-center text-sm text-muted-foreground">Loading...</div>
441
+ ) : pickerDirs.length === 0 ? (
442
+ <div className="px-3 py-4 text-center text-sm text-muted-foreground">No subdirectories</div>
443
+ ) : (
444
+ pickerDirs.map((dir) => (
445
+ <button
446
+ key={dir}
447
+ onClick={() => fetchDirectories(`${pickerPath}/${dir}`)}
448
+ className="w-full px-3 py-1.5 text-left text-sm hover:bg-muted flex items-center gap-2"
449
+ >
450
+ <svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
451
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
452
+ </svg>
453
+ {dir}
454
+ </button>
455
+ ))
456
+ )}
457
+ </div>
458
+ <div className="px-3 py-2 border-t flex gap-2">
459
+ <Button
460
+ size="sm"
461
+ className="flex-1 h-7 text-xs"
462
+ onClick={() => pickerPath && selectDirectory(pickerPath)}
463
+ disabled={!pickerPath}
464
+ >
465
+ Select
466
+ </Button>
467
+ <Button
468
+ size="sm"
469
+ variant="outline"
470
+ className="h-7 text-xs"
471
+ onClick={() => setShowDirPicker(false)}
472
+ >
473
+ Cancel
474
+ </Button>
475
+ </div>
476
+ </div>
477
+ )}
478
+ </div>
479
+ </div>
480
+
481
+ <div className="flex items-center gap-2">
482
+ <Button
483
+ variant="outline"
484
+ size="sm"
485
+ className="h-7 px-3 text-xs"
486
+ disabled={sessionState?.isStreaming}
487
+ onClick={() => {
488
+ getSessions()
489
+ setShowSessionPicker(true)
490
+ }}
491
+ >
492
+ Sessions
493
+ </Button>
494
+ <DropdownMenu>
495
+ <DropdownMenuTrigger asChild>
496
+ <Button variant="ghost" size="sm" className="h-7 px-2 text-xs">Settings</Button>
497
+ </DropdownMenuTrigger>
498
+ <DropdownMenuContent align="end">
499
+ <div className="flex items-center justify-between px-2 py-1.5 gap-4">
500
+ <span className="text-xs">Auto-compact</span>
501
+ <Switch
502
+ checked={sessionState?.autoCompactionEnabled ?? true}
503
+ onCheckedChange={setAutoCompaction}
504
+ className="scale-75"
505
+ />
506
+ </div>
507
+ <DropdownMenuItem
508
+ onClick={compact}
509
+ disabled={sessionState?.isCompacting || sessionState?.isStreaming}
510
+ className="text-xs"
511
+ >
512
+ {sessionState?.isCompacting ? 'Compacting...' : 'Compact now'}
513
+ </DropdownMenuItem>
514
+ </DropdownMenuContent>
515
+ </DropdownMenu>
516
+ <div className={`w-2 h-2 rounded-full flex-shrink-0 ${
517
+ status === 'connected' ? 'bg-green-500' :
518
+ status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
519
+ }`} />
520
+ </div>
521
+ </div>
522
+
523
+ {/* Error */}
524
+ {error && (
525
+ <div className="px-4 py-2 bg-destructive/10 text-destructive text-sm flex justify-between flex-shrink-0">
526
+ <span>{error}</span>
527
+ <button onClick={() => setError(null)} className="text-xs hover:underline">Dismiss</button>
528
+ </div>
529
+ )}
530
+
531
+ {/* Messages */}
532
+ <StickToBottom className="flex-1 relative overflow-x-hidden" resize="smooth" initial="smooth">
533
+ <StickToBottom.Content className="flex flex-col gap-4 p-4">
534
+ {renderItems.length === 0 && activeToolCalls.size === 0 && !sessionState?.isStreaming && (
535
+ <div className="h-full flex items-center justify-center text-muted-foreground min-h-[200px]">
536
+ Start a conversation
537
+ </div>
538
+ )}
539
+
540
+ {renderItems.map((item, i) => {
541
+ if (item.type === 'user-message') {
542
+ return (
543
+ <div key={i} className="flex justify-end">
544
+ <div className="max-w-[80%] rounded-lg px-4 py-2 overflow-hidden bg-primary text-primary-foreground">
545
+ {renderTextBlocks(item.content, false, true)}
546
+ </div>
547
+ </div>
548
+ )
549
+ }
550
+
551
+ if (item.type === 'text-chunk') {
552
+ return (
553
+ <div key={i} className="flex justify-start">
554
+ <div className="max-w-[80%] rounded-lg px-4 py-2 overflow-hidden bg-muted">
555
+ {renderTextBlocks(item.blocks, item.isStreaming, false)}
556
+ {item.isStreaming && (
557
+ <span className="inline-block w-2 h-4 bg-foreground/50 animate-pulse ml-1" />
558
+ )}
559
+ </div>
560
+ </div>
561
+ )
562
+ }
563
+
564
+ if (item.type === 'tool-call') {
565
+ // Tool call without result yet - show as pending
566
+ // Check if it's in activeToolCalls for status
567
+ const activeTool = activeToolCalls.get(item.id)
568
+ const toolStatus = activeTool?.status || 'running'
569
+ const partialOutput = activeTool?.partialOutput
570
+
571
+ const isBash = item.name === 'bash' || item.name === 'Bash'
572
+ const command = isBash ? (item.args as { command?: string }).command : undefined
573
+
574
+ return (
575
+ <Card key={i} className="max-w-[90%] overflow-hidden font-mono">
576
+ <div className="px-3 py-2 text-xs flex items-center gap-2 bg-muted/50">
577
+ <span className={`w-2 h-2 rounded-full ${
578
+ toolStatus === 'running' ? 'bg-yellow-500 animate-pulse' :
579
+ toolStatus === 'done' ? 'bg-green-500' : 'bg-red-500'
580
+ }`} />
581
+ {command ? (
582
+ <code className="text-foreground">$ {command}</code>
583
+ ) : (
584
+ <code className="text-muted-foreground">{item.name}</code>
585
+ )}
586
+ {toolStatus === 'running' && (
587
+ <span className="ml-auto text-xs text-muted-foreground">Running</span>
588
+ )}
589
+ </div>
590
+ {partialOutput && (
591
+ <pre className="p-3 text-xs overflow-auto max-h-48 bg-background whitespace-pre-wrap break-all text-muted-foreground">
592
+ {partialOutput}
593
+ </pre>
594
+ )}
595
+ </Card>
596
+ )
597
+ }
598
+
599
+ if (item.type === 'tool-result') {
600
+ // Diff view for Edit/Write tools
601
+ if (item.diff) {
602
+ return (
603
+ <Card key={i} className="max-w-[90%] overflow-hidden">
604
+ <div className={`px-3 py-2 text-xs flex items-center gap-2 ${item.isError ? 'bg-destructive/20' : 'bg-muted/50'}`}>
605
+ <span className={`w-2 h-2 rounded-full ${item.isError ? 'bg-red-500' : 'bg-green-500'}`} />
606
+ <code className="text-foreground font-mono">{item.toolName}: {item.filePath || 'file'}</code>
607
+ </div>
608
+ <div className="max-h-64 overflow-auto text-xs">
609
+ <DiffView patch={item.diff} />
610
+ </div>
611
+ </Card>
612
+ )
613
+ }
614
+
615
+ // Standard tool result (bash, etc.)
616
+ return (
617
+ <Card key={i} className="max-w-[90%] overflow-hidden font-mono">
618
+ <div className={`px-3 py-2 text-xs flex items-center gap-2 ${item.isError ? 'bg-destructive/20' : 'bg-muted/50'}`}>
619
+ <span className={`w-2 h-2 rounded-full ${item.isError ? 'bg-red-500' : 'bg-green-500'}`} />
620
+ {item.command ? (
621
+ <code className="text-foreground">$ {item.command}</code>
622
+ ) : (
623
+ <code className="text-muted-foreground">{item.toolName}</code>
624
+ )}
625
+ </div>
626
+ {item.output && (
627
+ <pre className={`p-3 text-xs overflow-auto max-h-48 bg-background whitespace-pre-wrap break-all ${item.isError ? 'text-destructive' : 'text-muted-foreground'}`}>
628
+ {item.output}
629
+ </pre>
630
+ )}
631
+ </Card>
632
+ )
633
+ }
634
+
635
+ return null
636
+ })}
637
+
638
+ {/* Active tool calls not yet in messages (shouldn't normally happen with proper flow) */}
639
+ {Array.from(activeToolCalls.values())
640
+ .filter(tool => !completedToolCallIds.has(tool.toolCallId))
641
+ .filter(tool => !renderItems.some(item => item.type === 'tool-call' && item.id === tool.toolCallId))
642
+ .map(tool => (
643
+ <Card key={tool.toolCallId} className="max-w-[90%] overflow-hidden font-mono">
644
+ <div className={`px-3 py-2 text-xs flex items-center gap-2 bg-muted/50`}>
645
+ <span className={`w-2 h-2 rounded-full ${
646
+ tool.status === 'running' ? 'bg-yellow-500 animate-pulse' :
647
+ tool.status === 'done' ? 'bg-green-500' : 'bg-red-500'
648
+ }`} />
649
+ {(tool.toolName === 'bash' || tool.toolName === 'Bash') ? (
650
+ <code className="text-foreground">$ {(tool.args as { command?: string }).command}</code>
651
+ ) : (
652
+ <code className="text-muted-foreground">{tool.toolName}</code>
653
+ )}
654
+ {tool.status === 'running' && (
655
+ <span className="ml-auto text-xs text-muted-foreground">Running</span>
656
+ )}
657
+ </div>
658
+ {tool.partialOutput && (
659
+ <pre className="p-3 text-xs overflow-auto max-h-48 bg-background whitespace-pre-wrap break-all text-muted-foreground">
660
+ {tool.partialOutput}
661
+ </pre>
662
+ )}
663
+ </Card>
664
+ ))}
665
+ </StickToBottom.Content>
666
+
667
+ <ScrollToBottomButton />
668
+ </StickToBottom>
669
+
670
+ {/* Input area */}
671
+ <div className="border-t p-4 sticky bottom-0 z-10 bg-background">
672
+ <div className="flex items-center gap-2 mb-3">
673
+ <DropdownMenu onOpenChange={(open) => {
674
+ if (open) getAvailableModels()
675
+ }}>
676
+ <DropdownMenuTrigger asChild>
677
+ <Badge variant="secondary" className="text-xs cursor-pointer">
678
+ {sessionState?.model?.name || sessionState?.model?.id || 'Select model'}
679
+ </Badge>
680
+ </DropdownMenuTrigger>
681
+ <DropdownMenuContent align="start" className="max-h-80 overflow-y-auto min-w-[200px]">
682
+ {availableModels.length === 0 ? (
683
+ <div className="px-2 py-1 text-xs text-muted-foreground">Loading models...</div>
684
+ ) : (
685
+ availableModels.map((model) => (
686
+ <DropdownMenuItem
687
+ key={`${model.provider}/${model.id}`}
688
+ onClick={() => setModel(model.provider, model.id)}
689
+ >
690
+ <span className="text-xs">{model.name || model.id}</span>
691
+ <span className="text-xs text-muted-foreground ml-2">{model.provider}</span>
692
+ {model.reasoning && <span className="text-xs text-muted-foreground ml-1">(reasoning)</span>}
693
+ </DropdownMenuItem>
694
+ ))
695
+ )}
696
+ </DropdownMenuContent>
697
+ </DropdownMenu>
698
+
699
+ {supportsReasoning && (
700
+ <DropdownMenu>
701
+ <DropdownMenuTrigger asChild>
702
+ <Badge variant="outline" className="text-xs cursor-pointer">
703
+ thinking: {sessionState?.thinkingLevel || 'medium'}
704
+ </Badge>
705
+ </DropdownMenuTrigger>
706
+ <DropdownMenuContent align="start">
707
+ {THINKING_LEVELS.map((level) => (
708
+ <DropdownMenuItem
709
+ key={level}
710
+ onClick={() => setThinkingLevel(level)}
711
+ >
712
+ <span className={`text-xs ${sessionState?.thinkingLevel === level ? 'font-bold' : ''}`}>
713
+ {level}
714
+ </span>
715
+ </DropdownMenuItem>
716
+ ))}
717
+ </DropdownMenuContent>
718
+ </DropdownMenu>
719
+ )}
720
+ </div>
721
+
722
+ <div className="flex gap-2 items-end">
723
+ <Textarea
724
+ value={input}
725
+ onChange={(e) => setInput(e.target.value)}
726
+ onKeyDown={handleKeyDown}
727
+ placeholder={sessionState?.isStreaming ? 'Agent is responding...' : 'Type a message...'}
728
+ disabled={status !== 'connected'}
729
+ className="min-h-[44px] max-h-[200px] resize-none flex-1"
730
+ rows={1}
731
+ />
732
+ {sessionState?.isStreaming ? (
733
+ <Button variant="destructive" onClick={abort} className="h-[44px]">Stop</Button>
734
+ ) : (
735
+ <Button onClick={handleSend} disabled={!input.trim() || status !== 'connected'} className="h-[44px]">
736
+ Send
737
+ </Button>
738
+ )}
739
+ </div>
740
+ </div>
741
+
742
+ {/* Session Picker Modal */}
743
+ {showSessionPicker && (
744
+ <SessionPicker
745
+ sessions={sessions}
746
+ onSelect={switchSession}
747
+ onNewSession={newSession}
748
+ onClose={() => setShowSessionPicker(false)}
749
+ />
750
+ )}
751
+ </div>
752
+ )
753
+ }
754
+
755
+ export default App