@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,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
|