prjct-cli 0.11.0 → 0.11.1
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/package.json +11 -1
- package/packages/shared/dist/index.d.ts +615 -0
- package/packages/shared/dist/index.js +204 -0
- package/packages/shared/package.json +29 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/schemas.ts +124 -0
- package/packages/shared/src/types.ts +187 -0
- package/packages/shared/src/utils.ts +148 -0
- package/packages/shared/tsconfig.json +18 -0
- package/packages/web/README.md +36 -0
- package/packages/web/app/api/claude/sessions/route.ts +44 -0
- package/packages/web/app/api/claude/status/route.ts +34 -0
- package/packages/web/app/api/projects/[id]/delete/route.ts +21 -0
- package/packages/web/app/api/projects/[id]/icon/route.ts +33 -0
- package/packages/web/app/api/projects/[id]/route.ts +29 -0
- package/packages/web/app/api/projects/[id]/stats/route.ts +36 -0
- package/packages/web/app/api/projects/[id]/status/route.ts +21 -0
- package/packages/web/app/api/projects/route.ts +16 -0
- package/packages/web/app/api/sessions/history/route.ts +122 -0
- package/packages/web/app/api/stats/route.ts +38 -0
- package/packages/web/app/error.tsx +34 -0
- package/packages/web/app/favicon.ico +0 -0
- package/packages/web/app/globals.css +155 -0
- package/packages/web/app/layout.tsx +43 -0
- package/packages/web/app/loading.tsx +7 -0
- package/packages/web/app/not-found.tsx +25 -0
- package/packages/web/app/page.tsx +227 -0
- package/packages/web/app/project/[id]/error.tsx +41 -0
- package/packages/web/app/project/[id]/loading.tsx +9 -0
- package/packages/web/app/project/[id]/not-found.tsx +27 -0
- package/packages/web/app/project/[id]/page.tsx +253 -0
- package/packages/web/app/project/[id]/stats/page.tsx +447 -0
- package/packages/web/app/sessions/page.tsx +165 -0
- package/packages/web/app/settings/page.tsx +150 -0
- package/packages/web/components/AppSidebar.tsx +113 -0
- package/packages/web/components/CommandButton.tsx +39 -0
- package/packages/web/components/ConnectionStatus.tsx +29 -0
- package/packages/web/components/Logo.tsx +65 -0
- package/packages/web/components/MarkdownContent.tsx +123 -0
- package/packages/web/components/ProjectAvatar.tsx +54 -0
- package/packages/web/components/TechStackBadges.tsx +20 -0
- package/packages/web/components/TerminalTab.tsx +84 -0
- package/packages/web/components/TerminalTabs.tsx +210 -0
- package/packages/web/components/charts/SessionsChart.tsx +172 -0
- package/packages/web/components/providers.tsx +45 -0
- package/packages/web/components/ui/alert-dialog.tsx +157 -0
- package/packages/web/components/ui/badge.tsx +46 -0
- package/packages/web/components/ui/button.tsx +60 -0
- package/packages/web/components/ui/card.tsx +92 -0
- package/packages/web/components/ui/chart.tsx +385 -0
- package/packages/web/components/ui/dropdown-menu.tsx +257 -0
- package/packages/web/components/ui/scroll-area.tsx +58 -0
- package/packages/web/components/ui/sheet.tsx +139 -0
- package/packages/web/components/ui/tabs.tsx +66 -0
- package/packages/web/components/ui/tooltip.tsx +61 -0
- package/packages/web/components.json +22 -0
- package/packages/web/context/TerminalContext.tsx +45 -0
- package/packages/web/context/TerminalTabsContext.tsx +136 -0
- package/packages/web/eslint.config.mjs +18 -0
- package/packages/web/hooks/useClaudeTerminal.ts +375 -0
- package/packages/web/hooks/useProjectStats.ts +38 -0
- package/packages/web/hooks/useProjects.ts +73 -0
- package/packages/web/hooks/useStats.ts +28 -0
- package/packages/web/lib/format.ts +23 -0
- package/packages/web/lib/parse-prjct-files.ts +1122 -0
- package/packages/web/lib/projects.ts +452 -0
- package/packages/web/lib/pty.ts +101 -0
- package/packages/web/lib/query-config.ts +44 -0
- package/packages/web/lib/utils.ts +6 -0
- package/packages/web/next-env.d.ts +6 -0
- package/packages/web/next.config.ts +7 -0
- package/packages/web/package.json +53 -0
- package/packages/web/postcss.config.mjs +7 -0
- package/packages/web/public/file.svg +1 -0
- package/packages/web/public/globe.svg +1 -0
- package/packages/web/public/next.svg +1 -0
- package/packages/web/public/vercel.svg +1 -0
- package/packages/web/public/window.svg +1 -0
- package/packages/web/server.ts +262 -0
- package/packages/web/tsconfig.json +34 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Terminal Tabs Context - Manage multiple terminal sessions
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createContext, useContext, useCallback, useState, useRef, ReactNode } from 'react'
|
|
8
|
+
|
|
9
|
+
export interface TerminalSession {
|
|
10
|
+
id: string
|
|
11
|
+
createdAt: Date
|
|
12
|
+
isConnected: boolean
|
|
13
|
+
isLoading: boolean
|
|
14
|
+
label: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TerminalTabsContextType {
|
|
18
|
+
sessions: TerminalSession[]
|
|
19
|
+
activeSessionId: string | null
|
|
20
|
+
createSession: () => string
|
|
21
|
+
closeSession: (sessionId: string) => void
|
|
22
|
+
setActiveSession: (sessionId: string) => void
|
|
23
|
+
updateSession: (sessionId: string, updates: Partial<TerminalSession>) => void
|
|
24
|
+
getActiveSession: () => TerminalSession | null
|
|
25
|
+
sendCommandToActive: (command: string) => void
|
|
26
|
+
registerSendInput: (sessionId: string, fn: (data: string) => void) => void
|
|
27
|
+
registerFocusTerminal: (sessionId: string, fn: () => void) => void
|
|
28
|
+
focusActiveTerminal: () => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TerminalTabsContext = createContext<TerminalTabsContextType | null>(null)
|
|
32
|
+
|
|
33
|
+
let sessionCounter = 0
|
|
34
|
+
|
|
35
|
+
export function TerminalTabsProvider({ children, projectId }: { children: ReactNode; projectId: string }) {
|
|
36
|
+
const [sessions, setSessions] = useState<TerminalSession[]>([])
|
|
37
|
+
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
|
|
38
|
+
const sendInputRefs = useRef<Map<string, (data: string) => void>>(new Map())
|
|
39
|
+
const focusTerminalRefs = useRef<Map<string, () => void>>(new Map())
|
|
40
|
+
|
|
41
|
+
const createSession = useCallback(() => {
|
|
42
|
+
sessionCounter++
|
|
43
|
+
const newSession: TerminalSession = {
|
|
44
|
+
id: `pty_${projectId}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
45
|
+
createdAt: new Date(),
|
|
46
|
+
isConnected: false,
|
|
47
|
+
isLoading: true,
|
|
48
|
+
label: `Terminal ${sessionCounter}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setSessions(prev => [...prev, newSession])
|
|
52
|
+
setActiveSessionId(newSession.id)
|
|
53
|
+
return newSession.id
|
|
54
|
+
}, [projectId])
|
|
55
|
+
|
|
56
|
+
const closeSession = useCallback((sessionId: string) => {
|
|
57
|
+
setSessions(prev => {
|
|
58
|
+
const filtered = prev.filter(s => s.id !== sessionId)
|
|
59
|
+
// If closing active session, switch to last remaining
|
|
60
|
+
if (sessionId === activeSessionId && filtered.length > 0) {
|
|
61
|
+
setActiveSessionId(filtered[filtered.length - 1].id)
|
|
62
|
+
} else if (filtered.length === 0) {
|
|
63
|
+
setActiveSessionId(null)
|
|
64
|
+
}
|
|
65
|
+
return filtered
|
|
66
|
+
})
|
|
67
|
+
sendInputRefs.current.delete(sessionId)
|
|
68
|
+
focusTerminalRefs.current.delete(sessionId)
|
|
69
|
+
}, [activeSessionId])
|
|
70
|
+
|
|
71
|
+
const setActiveSession = useCallback((sessionId: string) => {
|
|
72
|
+
setActiveSessionId(sessionId)
|
|
73
|
+
}, [])
|
|
74
|
+
|
|
75
|
+
const updateSession = useCallback((sessionId: string, updates: Partial<TerminalSession>) => {
|
|
76
|
+
setSessions(prev => prev.map(s =>
|
|
77
|
+
s.id === sessionId ? { ...s, ...updates } : s
|
|
78
|
+
))
|
|
79
|
+
}, [])
|
|
80
|
+
|
|
81
|
+
const getActiveSession = useCallback(() => {
|
|
82
|
+
return sessions.find(s => s.id === activeSessionId) || null
|
|
83
|
+
}, [sessions, activeSessionId])
|
|
84
|
+
|
|
85
|
+
const registerSendInput = useCallback((sessionId: string, fn: (data: string) => void) => {
|
|
86
|
+
sendInputRefs.current.set(sessionId, fn)
|
|
87
|
+
}, [])
|
|
88
|
+
|
|
89
|
+
const registerFocusTerminal = useCallback((sessionId: string, fn: () => void) => {
|
|
90
|
+
focusTerminalRefs.current.set(sessionId, fn)
|
|
91
|
+
}, [])
|
|
92
|
+
|
|
93
|
+
const focusActiveTerminal = useCallback(() => {
|
|
94
|
+
if (!activeSessionId) return
|
|
95
|
+
const focusFn = focusTerminalRefs.current.get(activeSessionId)
|
|
96
|
+
if (focusFn) focusFn()
|
|
97
|
+
}, [activeSessionId])
|
|
98
|
+
|
|
99
|
+
const sendCommandToActive = useCallback((command: string) => {
|
|
100
|
+
if (!activeSessionId) return
|
|
101
|
+
const sendFn = sendInputRefs.current.get(activeSessionId)
|
|
102
|
+
const session = sessions.find(s => s.id === activeSessionId)
|
|
103
|
+
if (sendFn && session?.isConnected) {
|
|
104
|
+
sendFn(command + '\n')
|
|
105
|
+
// Auto-focus terminal after sending command
|
|
106
|
+
const focusFn = focusTerminalRefs.current.get(activeSessionId)
|
|
107
|
+
if (focusFn) focusFn()
|
|
108
|
+
}
|
|
109
|
+
}, [activeSessionId, sessions])
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<TerminalTabsContext.Provider value={{
|
|
113
|
+
sessions,
|
|
114
|
+
activeSessionId,
|
|
115
|
+
createSession,
|
|
116
|
+
closeSession,
|
|
117
|
+
setActiveSession,
|
|
118
|
+
updateSession,
|
|
119
|
+
getActiveSession,
|
|
120
|
+
sendCommandToActive,
|
|
121
|
+
registerSendInput,
|
|
122
|
+
registerFocusTerminal,
|
|
123
|
+
focusActiveTerminal
|
|
124
|
+
}}>
|
|
125
|
+
{children}
|
|
126
|
+
</TerminalTabsContext.Provider>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function useTerminalTabs() {
|
|
131
|
+
const context = useContext(TerminalTabsContext)
|
|
132
|
+
if (!context) {
|
|
133
|
+
throw new Error('useTerminalTabs must be used within TerminalTabsProvider')
|
|
134
|
+
}
|
|
135
|
+
return context
|
|
136
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig, globalIgnores } from "eslint/config";
|
|
2
|
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
|
3
|
+
import nextTs from "eslint-config-next/typescript";
|
|
4
|
+
|
|
5
|
+
const eslintConfig = defineConfig([
|
|
6
|
+
...nextVitals,
|
|
7
|
+
...nextTs,
|
|
8
|
+
// Override default ignores of eslint-config-next.
|
|
9
|
+
globalIgnores([
|
|
10
|
+
// Default ignores of eslint-config-next:
|
|
11
|
+
".next/**",
|
|
12
|
+
"out/**",
|
|
13
|
+
"build/**",
|
|
14
|
+
"next-env.d.ts",
|
|
15
|
+
]),
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export default eslintConfig;
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useClaudeTerminal - WebSocket connection to Claude Code CLI via PTY
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Auto-reconnect with exponential backoff
|
|
8
|
+
* - Session persistence across server restarts
|
|
9
|
+
* - NO API costs - uses your existing Claude Max subscription!
|
|
10
|
+
* - Light/Dark theme support
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useEffect, useRef, useCallback, useState } from 'react'
|
|
14
|
+
import { useTheme } from 'next-themes'
|
|
15
|
+
import type { Terminal, ITheme } from '@xterm/xterm'
|
|
16
|
+
import type { FitAddon } from '@xterm/addon-fit'
|
|
17
|
+
|
|
18
|
+
// Terminal themes
|
|
19
|
+
const darkTheme: ITheme = {
|
|
20
|
+
background: '#0a0a0f',
|
|
21
|
+
foreground: '#e4e4e7',
|
|
22
|
+
cursor: '#e4e4e7',
|
|
23
|
+
cursorAccent: '#0a0a0f',
|
|
24
|
+
selectionBackground: '#3f3f46',
|
|
25
|
+
black: '#18181b',
|
|
26
|
+
red: '#ef4444',
|
|
27
|
+
green: '#22c55e',
|
|
28
|
+
yellow: '#eab308',
|
|
29
|
+
blue: '#3b82f6',
|
|
30
|
+
magenta: '#a855f7',
|
|
31
|
+
cyan: '#06b6d4',
|
|
32
|
+
white: '#e4e4e7',
|
|
33
|
+
brightBlack: '#52525b',
|
|
34
|
+
brightRed: '#f87171',
|
|
35
|
+
brightGreen: '#4ade80',
|
|
36
|
+
brightYellow: '#facc15',
|
|
37
|
+
brightBlue: '#60a5fa',
|
|
38
|
+
brightMagenta: '#c084fc',
|
|
39
|
+
brightCyan: '#22d3ee',
|
|
40
|
+
brightWhite: '#fafafa'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const lightTheme: ITheme = {
|
|
44
|
+
background: '#ffffff',
|
|
45
|
+
foreground: '#18181b',
|
|
46
|
+
cursor: '#18181b',
|
|
47
|
+
cursorAccent: '#ffffff',
|
|
48
|
+
selectionBackground: '#d4d4d8',
|
|
49
|
+
black: '#18181b',
|
|
50
|
+
red: '#dc2626',
|
|
51
|
+
green: '#16a34a',
|
|
52
|
+
yellow: '#ca8a04',
|
|
53
|
+
blue: '#2563eb',
|
|
54
|
+
magenta: '#9333ea',
|
|
55
|
+
cyan: '#0891b2',
|
|
56
|
+
white: '#f4f4f5',
|
|
57
|
+
brightBlack: '#71717a',
|
|
58
|
+
brightRed: '#ef4444',
|
|
59
|
+
brightGreen: '#22c55e',
|
|
60
|
+
brightYellow: '#eab308',
|
|
61
|
+
brightBlue: '#3b82f6',
|
|
62
|
+
brightMagenta: '#a855f7',
|
|
63
|
+
brightCyan: '#06b6d4',
|
|
64
|
+
brightWhite: '#fafafa'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface UseClaudeTerminalOptions {
|
|
68
|
+
sessionId: string
|
|
69
|
+
projectDir: string
|
|
70
|
+
onConnect?: () => void
|
|
71
|
+
onDisconnect?: () => void
|
|
72
|
+
onError?: (error: string) => void
|
|
73
|
+
onReconnecting?: (attempt: number, maxAttempts: number) => void
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const MAX_RECONNECT_ATTEMPTS = 10
|
|
77
|
+
const BASE_RECONNECT_DELAY = 1000 // 1 second
|
|
78
|
+
const MAX_RECONNECT_DELAY = 30000 // 30 seconds
|
|
79
|
+
|
|
80
|
+
export function useClaudeTerminal(options: UseClaudeTerminalOptions) {
|
|
81
|
+
const { sessionId, projectDir, onConnect, onDisconnect, onError, onReconnecting } = options
|
|
82
|
+
const { resolvedTheme } = useTheme()
|
|
83
|
+
|
|
84
|
+
const terminalRef = useRef<Terminal | null>(null)
|
|
85
|
+
const fitAddonRef = useRef<FitAddon | null>(null)
|
|
86
|
+
const wsRef = useRef<WebSocket | null>(null)
|
|
87
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
88
|
+
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
89
|
+
const reconnectAttemptsRef = useRef(0)
|
|
90
|
+
const intentionalDisconnectRef = useRef(false)
|
|
91
|
+
const currentSessionIdRef = useRef<string | null>(null)
|
|
92
|
+
|
|
93
|
+
const [isConnected, setIsConnected] = useState(false)
|
|
94
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
95
|
+
const [isReconnecting, setIsReconnecting] = useState(false)
|
|
96
|
+
|
|
97
|
+
// Update terminal theme when resolvedTheme changes
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (terminalRef.current) {
|
|
100
|
+
const newTheme = resolvedTheme === 'light' ? lightTheme : darkTheme
|
|
101
|
+
terminalRef.current.options.theme = newTheme
|
|
102
|
+
}
|
|
103
|
+
}, [resolvedTheme])
|
|
104
|
+
|
|
105
|
+
// Initialize terminal - returns Promise<void>
|
|
106
|
+
const initTerminal = useCallback(async (container: HTMLDivElement): Promise<void> => {
|
|
107
|
+
if (terminalRef.current) return
|
|
108
|
+
|
|
109
|
+
// Dynamic imports for client-side only
|
|
110
|
+
const { Terminal } = await import('@xterm/xterm')
|
|
111
|
+
const { FitAddon } = await import('@xterm/addon-fit')
|
|
112
|
+
const { WebLinksAddon } = await import('@xterm/addon-web-links')
|
|
113
|
+
// CSS is loaded globally via globals.css
|
|
114
|
+
|
|
115
|
+
// Determine initial theme based on document class
|
|
116
|
+
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
|
117
|
+
const initialTheme = isDark ? darkTheme : lightTheme
|
|
118
|
+
|
|
119
|
+
const term = new Terminal({
|
|
120
|
+
cursorBlink: true,
|
|
121
|
+
cursorStyle: 'block',
|
|
122
|
+
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
|
123
|
+
fontSize: 14,
|
|
124
|
+
lineHeight: 1.2,
|
|
125
|
+
theme: initialTheme
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const fitAddon = new FitAddon()
|
|
129
|
+
const webLinksAddon = new WebLinksAddon()
|
|
130
|
+
|
|
131
|
+
term.loadAddon(fitAddon)
|
|
132
|
+
term.loadAddon(webLinksAddon)
|
|
133
|
+
|
|
134
|
+
term.open(container)
|
|
135
|
+
fitAddon.fit()
|
|
136
|
+
|
|
137
|
+
terminalRef.current = term
|
|
138
|
+
fitAddonRef.current = fitAddon
|
|
139
|
+
containerRef.current = container
|
|
140
|
+
|
|
141
|
+
// Handle window resize
|
|
142
|
+
const handleResize = () => {
|
|
143
|
+
if (fitAddonRef.current) {
|
|
144
|
+
fitAddonRef.current.fit()
|
|
145
|
+
|
|
146
|
+
// Send resize to server
|
|
147
|
+
if (wsRef.current?.readyState === WebSocket.OPEN && terminalRef.current) {
|
|
148
|
+
wsRef.current.send(JSON.stringify({
|
|
149
|
+
type: 'resize',
|
|
150
|
+
cols: terminalRef.current.cols,
|
|
151
|
+
rows: terminalRef.current.rows
|
|
152
|
+
}))
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
window.addEventListener('resize', handleResize)
|
|
158
|
+
|
|
159
|
+
// Note: cleanup is handled by the component unmount, not here
|
|
160
|
+
// The terminal persists for the lifetime of the TerminalTab component
|
|
161
|
+
}, [])
|
|
162
|
+
|
|
163
|
+
// Clear reconnect timeout
|
|
164
|
+
const clearReconnectTimeout = useCallback(() => {
|
|
165
|
+
if (reconnectTimeoutRef.current) {
|
|
166
|
+
clearTimeout(reconnectTimeoutRef.current)
|
|
167
|
+
reconnectTimeoutRef.current = null
|
|
168
|
+
}
|
|
169
|
+
}, [])
|
|
170
|
+
|
|
171
|
+
// Schedule reconnect with exponential backoff
|
|
172
|
+
const scheduleReconnect = useCallback(() => {
|
|
173
|
+
if (intentionalDisconnectRef.current) return
|
|
174
|
+
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
|
|
175
|
+
setIsReconnecting(false)
|
|
176
|
+
onError?.(`Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`)
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const delay = Math.min(
|
|
181
|
+
BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttemptsRef.current),
|
|
182
|
+
MAX_RECONNECT_DELAY
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
reconnectAttemptsRef.current++
|
|
186
|
+
setIsReconnecting(true)
|
|
187
|
+
onReconnecting?.(reconnectAttemptsRef.current, MAX_RECONNECT_ATTEMPTS)
|
|
188
|
+
|
|
189
|
+
console.log(`[Terminal] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})`)
|
|
190
|
+
|
|
191
|
+
reconnectTimeoutRef.current = setTimeout(() => {
|
|
192
|
+
connectWebSocket()
|
|
193
|
+
}, delay)
|
|
194
|
+
}, [onError, onReconnecting])
|
|
195
|
+
|
|
196
|
+
// Connect WebSocket (internal)
|
|
197
|
+
const connectWebSocket = useCallback(async () => {
|
|
198
|
+
// Close existing connection if any
|
|
199
|
+
if (wsRef.current) {
|
|
200
|
+
wsRef.current.close()
|
|
201
|
+
wsRef.current = null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const activeSessionId = currentSessionIdRef.current || sessionId
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
// Create PTY session on server
|
|
208
|
+
const response = await fetch('/api/claude/sessions', {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
headers: { 'Content-Type': 'application/json' },
|
|
211
|
+
body: JSON.stringify({ sessionId: activeSessionId, projectDir })
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
if (!response.ok) {
|
|
215
|
+
throw new Error('Failed to create PTY session')
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Connect WebSocket
|
|
219
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
220
|
+
const wsUrl = `${protocol}//${window.location.host}/ws/claude/${activeSessionId}`
|
|
221
|
+
const ws = new WebSocket(wsUrl)
|
|
222
|
+
|
|
223
|
+
ws.onopen = () => {
|
|
224
|
+
console.log('[Terminal] WebSocket connected')
|
|
225
|
+
reconnectAttemptsRef.current = 0
|
|
226
|
+
setIsConnected(true)
|
|
227
|
+
setIsLoading(false)
|
|
228
|
+
setIsReconnecting(false)
|
|
229
|
+
onConnect?.()
|
|
230
|
+
|
|
231
|
+
// Send initial resize
|
|
232
|
+
if (terminalRef.current) {
|
|
233
|
+
ws.send(JSON.stringify({
|
|
234
|
+
type: 'resize',
|
|
235
|
+
cols: terminalRef.current.cols,
|
|
236
|
+
rows: terminalRef.current.rows
|
|
237
|
+
}))
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
ws.onmessage = (event) => {
|
|
242
|
+
try {
|
|
243
|
+
const message = JSON.parse(event.data)
|
|
244
|
+
|
|
245
|
+
switch (message.type) {
|
|
246
|
+
case 'output':
|
|
247
|
+
terminalRef.current?.write(message.data)
|
|
248
|
+
break
|
|
249
|
+
|
|
250
|
+
case 'connected':
|
|
251
|
+
console.log('[Terminal] Connected to Claude Code CLI')
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
case 'exit':
|
|
255
|
+
console.log('[Terminal] Claude Code exited with code:', message.code)
|
|
256
|
+
terminalRef.current?.write('\r\n[Session ended]\r\n')
|
|
257
|
+
break
|
|
258
|
+
|
|
259
|
+
case 'error':
|
|
260
|
+
onError?.(message.message)
|
|
261
|
+
break
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
// Raw data, write directly
|
|
265
|
+
terminalRef.current?.write(event.data)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
ws.onclose = (event) => {
|
|
270
|
+
console.log(`[Terminal] WebSocket closed (code: ${event.code})`)
|
|
271
|
+
setIsConnected(false)
|
|
272
|
+
wsRef.current = null
|
|
273
|
+
|
|
274
|
+
// Only trigger reconnect if not intentional
|
|
275
|
+
if (!intentionalDisconnectRef.current) {
|
|
276
|
+
terminalRef.current?.write('\r\n\x1b[33m[Disconnected - Reconnecting...]\x1b[0m\r\n')
|
|
277
|
+
scheduleReconnect()
|
|
278
|
+
} else {
|
|
279
|
+
onDisconnect?.()
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
ws.onerror = (error) => {
|
|
284
|
+
console.error('[Terminal] WebSocket error:', error)
|
|
285
|
+
// Don't call onError here, onclose will handle reconnect
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
wsRef.current = ws
|
|
289
|
+
|
|
290
|
+
// Forward terminal input to WebSocket
|
|
291
|
+
terminalRef.current?.onData((data) => {
|
|
292
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
293
|
+
ws.send(JSON.stringify({ type: 'input', data }))
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
} catch (error) {
|
|
298
|
+
console.error('[Terminal] Connection error:', error)
|
|
299
|
+
setIsLoading(false)
|
|
300
|
+
|
|
301
|
+
// Schedule reconnect on connection failure
|
|
302
|
+
if (!intentionalDisconnectRef.current) {
|
|
303
|
+
scheduleReconnect()
|
|
304
|
+
} else {
|
|
305
|
+
onError?.(error instanceof Error ? error.message : 'Connection failed')
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}, [sessionId, projectDir, onConnect, onDisconnect, onError, scheduleReconnect])
|
|
309
|
+
|
|
310
|
+
// Public connect function
|
|
311
|
+
const connect = useCallback(async () => {
|
|
312
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) return
|
|
313
|
+
|
|
314
|
+
intentionalDisconnectRef.current = false
|
|
315
|
+
reconnectAttemptsRef.current = 0
|
|
316
|
+
currentSessionIdRef.current = sessionId
|
|
317
|
+
setIsLoading(true)
|
|
318
|
+
|
|
319
|
+
await connectWebSocket()
|
|
320
|
+
}, [sessionId, connectWebSocket])
|
|
321
|
+
|
|
322
|
+
// Disconnect (intentional)
|
|
323
|
+
const disconnect = useCallback(() => {
|
|
324
|
+
intentionalDisconnectRef.current = true
|
|
325
|
+
clearReconnectTimeout()
|
|
326
|
+
setIsReconnecting(false)
|
|
327
|
+
|
|
328
|
+
if (wsRef.current) {
|
|
329
|
+
wsRef.current.close()
|
|
330
|
+
wsRef.current = null
|
|
331
|
+
}
|
|
332
|
+
setIsConnected(false)
|
|
333
|
+
onDisconnect?.()
|
|
334
|
+
}, [clearReconnectTimeout, onDisconnect])
|
|
335
|
+
|
|
336
|
+
// Send input
|
|
337
|
+
const sendInput = useCallback((data: string) => {
|
|
338
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
339
|
+
wsRef.current.send(JSON.stringify({ type: 'input', data }))
|
|
340
|
+
}
|
|
341
|
+
}, [])
|
|
342
|
+
|
|
343
|
+
// Focus terminal
|
|
344
|
+
const focusTerminal = useCallback(() => {
|
|
345
|
+
if (terminalRef.current) {
|
|
346
|
+
terminalRef.current.focus()
|
|
347
|
+
}
|
|
348
|
+
}, [])
|
|
349
|
+
|
|
350
|
+
// Cleanup on unmount - empty deps to run only on unmount
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
return () => {
|
|
353
|
+
intentionalDisconnectRef.current = true
|
|
354
|
+
if (reconnectTimeoutRef.current) {
|
|
355
|
+
clearTimeout(reconnectTimeoutRef.current)
|
|
356
|
+
}
|
|
357
|
+
if (wsRef.current) {
|
|
358
|
+
wsRef.current.close()
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}, []) // Empty deps - run cleanup only on unmount
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
initTerminal,
|
|
365
|
+
connect,
|
|
366
|
+
disconnect,
|
|
367
|
+
sendInput,
|
|
368
|
+
focusTerminal,
|
|
369
|
+
isConnected,
|
|
370
|
+
isLoading,
|
|
371
|
+
isReconnecting,
|
|
372
|
+
terminalRef,
|
|
373
|
+
containerRef
|
|
374
|
+
}
|
|
375
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useQuery } from '@tanstack/react-query'
|
|
4
|
+
import type { ProjectStats, RawProjectFiles } from '@/lib/parse-prjct-files'
|
|
5
|
+
|
|
6
|
+
interface ProjectStatsResponse {
|
|
7
|
+
success: boolean
|
|
8
|
+
data?: ProjectStats
|
|
9
|
+
raw?: RawProjectFiles
|
|
10
|
+
error?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ProjectStatsData {
|
|
14
|
+
stats: ProjectStats
|
|
15
|
+
raw: RawProjectFiles
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function fetchProjectStats(projectId: string): Promise<ProjectStatsData> {
|
|
19
|
+
const res = await fetch(`/api/projects/${projectId}/stats`, {
|
|
20
|
+
cache: 'no-store',
|
|
21
|
+
})
|
|
22
|
+
if (!res.ok) throw new Error('Failed to fetch project stats')
|
|
23
|
+
const json: ProjectStatsResponse = await res.json()
|
|
24
|
+
if (!json.success || !json.data || !json.raw) {
|
|
25
|
+
throw new Error(json.error || 'Failed to fetch project stats')
|
|
26
|
+
}
|
|
27
|
+
return { stats: json.data, raw: json.raw }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useProjectStats(projectId: string) {
|
|
31
|
+
return useQuery({
|
|
32
|
+
queryKey: ['project-stats', projectId],
|
|
33
|
+
queryFn: () => fetchProjectStats(projectId),
|
|
34
|
+
staleTime: 30_000, // Cache 30s
|
|
35
|
+
refetchOnWindowFocus: true,
|
|
36
|
+
enabled: !!projectId,
|
|
37
|
+
})
|
|
38
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
4
|
+
import { queryPresets } from '@/lib/query-config'
|
|
5
|
+
|
|
6
|
+
export interface Project {
|
|
7
|
+
id: string
|
|
8
|
+
name: string
|
|
9
|
+
path: string
|
|
10
|
+
repoPath?: string | null
|
|
11
|
+
currentTask?: string | null
|
|
12
|
+
hasActiveSession?: boolean
|
|
13
|
+
lastActivity?: string | null
|
|
14
|
+
ideasCount?: number
|
|
15
|
+
nextTasksCount?: number
|
|
16
|
+
techStack?: string[]
|
|
17
|
+
iconPath?: string | null
|
|
18
|
+
version?: string
|
|
19
|
+
stack?: string
|
|
20
|
+
filesCount?: number
|
|
21
|
+
commitsCount?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Fetch with no-cache headers for fresh data
|
|
25
|
+
async function fetchJson<T>(url: string): Promise<T> {
|
|
26
|
+
const res = await fetch(url, {
|
|
27
|
+
cache: 'no-store',
|
|
28
|
+
headers: {
|
|
29
|
+
'Cache-Control': 'no-cache',
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
if (!res.ok) throw new Error(`Failed to fetch ${url}`)
|
|
33
|
+
const json = await res.json()
|
|
34
|
+
return json.data ?? json
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Hook for fetching all projects
|
|
38
|
+
export function useProjects() {
|
|
39
|
+
return useQuery({
|
|
40
|
+
queryKey: ['projects'],
|
|
41
|
+
queryFn: () => fetchJson<Project[]>('/api/projects'),
|
|
42
|
+
...queryPresets.normal,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Hook for fetching a single project
|
|
47
|
+
export function useProject(projectId: string | null) {
|
|
48
|
+
return useQuery({
|
|
49
|
+
queryKey: ['project', projectId],
|
|
50
|
+
queryFn: () => fetchJson<Project>(`/api/projects/${projectId}`),
|
|
51
|
+
enabled: !!projectId,
|
|
52
|
+
...queryPresets.fast,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Hook for deleting a project
|
|
57
|
+
export function useDeleteProject() {
|
|
58
|
+
const queryClient = useQueryClient()
|
|
59
|
+
|
|
60
|
+
return useMutation({
|
|
61
|
+
mutationFn: async (projectId: string) => {
|
|
62
|
+
const res = await fetch(`/api/projects/${projectId}/delete`, { method: 'POST' })
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const error = await res.json()
|
|
65
|
+
throw new Error(error.error || 'Failed to delete project')
|
|
66
|
+
}
|
|
67
|
+
return res.json()
|
|
68
|
+
},
|
|
69
|
+
onSuccess: () => {
|
|
70
|
+
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useQuery } from '@tanstack/react-query'
|
|
4
|
+
import { queryPresets } from '@/lib/query-config'
|
|
5
|
+
|
|
6
|
+
export interface Stats {
|
|
7
|
+
userName?: string
|
|
8
|
+
totalProjects?: number
|
|
9
|
+
activeProjects?: number
|
|
10
|
+
totalSessions?: number
|
|
11
|
+
sessionsThisWeek?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useStats() {
|
|
15
|
+
return useQuery({
|
|
16
|
+
queryKey: ['stats'],
|
|
17
|
+
queryFn: async () => {
|
|
18
|
+
const res = await fetch('/api/stats', {
|
|
19
|
+
cache: 'no-store',
|
|
20
|
+
headers: { 'Cache-Control': 'no-cache' },
|
|
21
|
+
})
|
|
22
|
+
if (!res.ok) throw new Error('Failed to fetch stats')
|
|
23
|
+
const json = await res.json()
|
|
24
|
+
return json.data as Stats
|
|
25
|
+
},
|
|
26
|
+
...queryPresets.normal,
|
|
27
|
+
})
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared formatting utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function formatRelativeTime(dateStr: string | null | undefined): string {
|
|
6
|
+
if (!dateStr) return ''
|
|
7
|
+
const date = new Date(dateStr)
|
|
8
|
+
const now = new Date()
|
|
9
|
+
const diffMs = now.getTime() - date.getTime()
|
|
10
|
+
const diffMins = Math.floor(diffMs / 60000)
|
|
11
|
+
const diffHours = Math.floor(diffMs / 3600000)
|
|
12
|
+
const diffDays = Math.floor(diffMs / 86400000)
|
|
13
|
+
|
|
14
|
+
if (diffMins < 1) return 'just now'
|
|
15
|
+
if (diffMins < 60) return `${diffMins}m ago`
|
|
16
|
+
if (diffHours < 24) return `${diffHours}h ago`
|
|
17
|
+
if (diffDays < 7) return `${diffDays}d ago`
|
|
18
|
+
return date.toLocaleDateString()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatPath(path: string): string {
|
|
22
|
+
return path.replace(/^\/Users\/[^/]+\//, '~/')
|
|
23
|
+
}
|