@atercates/claude-deck 0.2.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.
Files changed (293) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/app/api/claude/hidden/route.ts +66 -0
  4. package/app/api/claude/projects/[name]/sessions/route.ts +71 -0
  5. package/app/api/claude/projects/route.ts +44 -0
  6. package/app/api/code-search/available/route.ts +12 -0
  7. package/app/api/code-search/route.ts +47 -0
  8. package/app/api/dev-servers/[id]/logs/route.ts +23 -0
  9. package/app/api/dev-servers/[id]/restart/route.ts +20 -0
  10. package/app/api/dev-servers/[id]/route.ts +51 -0
  11. package/app/api/dev-servers/[id]/stop/route.ts +20 -0
  12. package/app/api/dev-servers/detect/route.ts +39 -0
  13. package/app/api/dev-servers/route.ts +48 -0
  14. package/app/api/exec/route.ts +60 -0
  15. package/app/api/files/content/route.ts +76 -0
  16. package/app/api/files/route.ts +37 -0
  17. package/app/api/files/upload-temp/route.ts +41 -0
  18. package/app/api/git/check/route.ts +54 -0
  19. package/app/api/git/clone/route.ts +99 -0
  20. package/app/api/git/commit/route.ts +75 -0
  21. package/app/api/git/discard/route.ts +38 -0
  22. package/app/api/git/file-content/route.ts +64 -0
  23. package/app/api/git/history/[hash]/diff/route.ts +38 -0
  24. package/app/api/git/history/[hash]/route.ts +34 -0
  25. package/app/api/git/history/route.ts +27 -0
  26. package/app/api/git/multi-status/route.ts +46 -0
  27. package/app/api/git/pr/route.ts +164 -0
  28. package/app/api/git/push/route.ts +64 -0
  29. package/app/api/git/stage/route.ts +40 -0
  30. package/app/api/git/status/route.ts +51 -0
  31. package/app/api/git/unstage/route.ts +46 -0
  32. package/app/api/groups/[...path]/route.ts +136 -0
  33. package/app/api/groups/route.ts +93 -0
  34. package/app/api/orchestrate/spawn/route.ts +45 -0
  35. package/app/api/orchestrate/workers/[id]/route.ts +89 -0
  36. package/app/api/orchestrate/workers/route.ts +31 -0
  37. package/app/api/projects/[id]/detect/route.ts +27 -0
  38. package/app/api/projects/[id]/dev-servers/[dsId]/route.ts +66 -0
  39. package/app/api/projects/[id]/dev-servers/route.ts +51 -0
  40. package/app/api/projects/[id]/repositories/[repoId]/route.ts +67 -0
  41. package/app/api/projects/[id]/repositories/route.ts +74 -0
  42. package/app/api/projects/[id]/route.ts +108 -0
  43. package/app/api/projects/detect/route.ts +33 -0
  44. package/app/api/projects/route.ts +59 -0
  45. package/app/api/sessions/[id]/claude-session/route.ts +42 -0
  46. package/app/api/sessions/[id]/fork/route.ts +74 -0
  47. package/app/api/sessions/[id]/mcp-config/route.ts +34 -0
  48. package/app/api/sessions/[id]/messages/route.ts +60 -0
  49. package/app/api/sessions/[id]/pr/route.ts +188 -0
  50. package/app/api/sessions/[id]/preview/route.ts +42 -0
  51. package/app/api/sessions/[id]/route.ts +229 -0
  52. package/app/api/sessions/[id]/send-keys/route.ts +119 -0
  53. package/app/api/sessions/[id]/summarize/route.ts +331 -0
  54. package/app/api/sessions/init-script/route.ts +84 -0
  55. package/app/api/sessions/route.ts +209 -0
  56. package/app/api/sessions/status/route.ts +237 -0
  57. package/app/api/system/route.ts +9 -0
  58. package/app/api/tmux/kill-all/route.ts +57 -0
  59. package/app/api/tmux/rename/route.ts +30 -0
  60. package/app/globals.css +174 -0
  61. package/app/icon.svg +11 -0
  62. package/app/layout.tsx +122 -0
  63. package/app/page.tsx +629 -0
  64. package/components/ChatMessage.tsx +65 -0
  65. package/components/ChatView.tsx +276 -0
  66. package/components/ClaudeProjects/ClaudeProjectCard.tsx +195 -0
  67. package/components/ClaudeProjects/ClaudeProjectsSection.tsx +89 -0
  68. package/components/ClaudeProjects/ClaudeSessionCard.tsx +100 -0
  69. package/components/ClaudeProjects/index.ts +1 -0
  70. package/components/CodeSearch/CodeSearchResults.tsx +177 -0
  71. package/components/ConductorPanel.tsx +256 -0
  72. package/components/DevServers/DevServerCard.tsx +311 -0
  73. package/components/DevServers/DevServersSection.tsx +91 -0
  74. package/components/DevServers/ServerLogsModal.tsx +151 -0
  75. package/components/DevServers/StartServerDialog.tsx +359 -0
  76. package/components/DevServers/index.ts +4 -0
  77. package/components/DiffViewer/DiffModal.tsx +151 -0
  78. package/components/DiffViewer/UnifiedDiff.tsx +185 -0
  79. package/components/DiffViewer/index.tsx +2 -0
  80. package/components/DirectoryPicker.tsx +355 -0
  81. package/components/FileExplorer/FileEditor.tsx +276 -0
  82. package/components/FileExplorer/FileTabs.tsx +118 -0
  83. package/components/FileExplorer/FileTree.tsx +214 -0
  84. package/components/FileExplorer/HtmlRenderer.tsx +16 -0
  85. package/components/FileExplorer/MarkdownRenderer.tsx +18 -0
  86. package/components/FileExplorer/index.tsx +520 -0
  87. package/components/FilePicker.tsx +339 -0
  88. package/components/FolderPicker.tsx +201 -0
  89. package/components/GitDrawer/FileEditDialog.tsx +400 -0
  90. package/components/GitDrawer/index.tsx +464 -0
  91. package/components/GitPanel/CommitForm.tsx +205 -0
  92. package/components/GitPanel/CommitHistory.tsx +174 -0
  93. package/components/GitPanel/CommitItem.tsx +196 -0
  94. package/components/GitPanel/FileChanges.tsx +414 -0
  95. package/components/GitPanel/GitPanelTabs.tsx +39 -0
  96. package/components/GitPanel/index.tsx +817 -0
  97. package/components/MessageInput.tsx +82 -0
  98. package/components/NewClaudeSessionDialog.tsx +166 -0
  99. package/components/NewSessionDialog/AdvancedSettings.tsx +78 -0
  100. package/components/NewSessionDialog/AgentSelector.tsx +37 -0
  101. package/components/NewSessionDialog/CreatingOverlay.tsx +94 -0
  102. package/components/NewSessionDialog/NewSessionDialog.types.ts +136 -0
  103. package/components/NewSessionDialog/ProjectSelector.tsx +146 -0
  104. package/components/NewSessionDialog/WorkingDirectoryInput.tsx +55 -0
  105. package/components/NewSessionDialog/WorktreeSection.tsx +92 -0
  106. package/components/NewSessionDialog/hooks/useNewSessionForm.ts +370 -0
  107. package/components/NewSessionDialog/index.tsx +106 -0
  108. package/components/NotificationSettings.tsx +127 -0
  109. package/components/PRCreationModal.tsx +272 -0
  110. package/components/Pane/DesktopTabBar.tsx +353 -0
  111. package/components/Pane/MobileTabBar.tsx +210 -0
  112. package/components/Pane/OpenInVSCode.tsx +69 -0
  113. package/components/Pane/PaneSkeletons.tsx +57 -0
  114. package/components/Pane/index.tsx +558 -0
  115. package/components/PaneLayout.tsx +60 -0
  116. package/components/Projects/DevServersSection.tsx +140 -0
  117. package/components/Projects/DirectoryField.tsx +92 -0
  118. package/components/Projects/NewProjectDialog.tsx +188 -0
  119. package/components/Projects/NewProjectDialog.types.ts +46 -0
  120. package/components/Projects/ProjectCard.tsx +276 -0
  121. package/components/Projects/ProjectSettingsDialog.tsx +811 -0
  122. package/components/Projects/hooks/useNewProjectForm.ts +249 -0
  123. package/components/Projects/index.ts +3 -0
  124. package/components/Providers.tsx +49 -0
  125. package/components/QuickSwitcher.tsx +306 -0
  126. package/components/SessionList/KillAllConfirm.tsx +46 -0
  127. package/components/SessionList/SelectionToolbar.tsx +164 -0
  128. package/components/SessionList/SessionList.types.ts +37 -0
  129. package/components/SessionList/SessionListHeader.tsx +71 -0
  130. package/components/SessionList/hooks/useSessionListMutations.ts +269 -0
  131. package/components/SessionList/index.tsx +189 -0
  132. package/components/ShellDrawer/index.tsx +106 -0
  133. package/components/SidebarFooter.tsx +55 -0
  134. package/components/Terminal/KeybarToggleButton.tsx +45 -0
  135. package/components/Terminal/ScrollToBottomButton.tsx +32 -0
  136. package/components/Terminal/SearchBar.tsx +71 -0
  137. package/components/Terminal/TerminalToolbar.tsx +551 -0
  138. package/components/Terminal/VirtualKeyboard.tsx +711 -0
  139. package/components/Terminal/constants.ts +20 -0
  140. package/components/Terminal/hooks/index.ts +5 -0
  141. package/components/Terminal/hooks/resize-handlers.ts +140 -0
  142. package/components/Terminal/hooks/terminal-init.ts +151 -0
  143. package/components/Terminal/hooks/touch-scroll.ts +155 -0
  144. package/components/Terminal/hooks/useTerminalConnection.ts +282 -0
  145. package/components/Terminal/hooks/useTerminalConnection.types.ts +39 -0
  146. package/components/Terminal/hooks/useTerminalSearch.ts +103 -0
  147. package/components/Terminal/hooks/websocket-connection.ts +274 -0
  148. package/components/Terminal/index.tsx +320 -0
  149. package/components/ThemeToggle.tsx +168 -0
  150. package/components/TmuxSessions.tsx +132 -0
  151. package/components/ToolCallDisplay.tsx +71 -0
  152. package/components/WorkerCard.tsx +245 -0
  153. package/components/a/ABadge.tsx +115 -0
  154. package/components/a/AButton.tsx +163 -0
  155. package/components/a/ADialog.tsx +93 -0
  156. package/components/a/ADropdownMenu.tsx +279 -0
  157. package/components/a/AIconButton.tsx +190 -0
  158. package/components/a/ASheet.tsx +150 -0
  159. package/components/a/ATooltip.tsx +77 -0
  160. package/components/a/index.ts +64 -0
  161. package/components/mobile/SwipeSidebar.tsx +122 -0
  162. package/components/ui/badge.tsx +41 -0
  163. package/components/ui/button.tsx +60 -0
  164. package/components/ui/context-menu.tsx +197 -0
  165. package/components/ui/dialog.tsx +143 -0
  166. package/components/ui/dropdown-menu.tsx +257 -0
  167. package/components/ui/input.tsx +21 -0
  168. package/components/ui/scroll-area.tsx +52 -0
  169. package/components/ui/select.tsx +159 -0
  170. package/components/ui/skeleton.tsx +111 -0
  171. package/components/ui/switch.tsx +31 -0
  172. package/components/ui/textarea.tsx +21 -0
  173. package/components/ui/tooltip.tsx +32 -0
  174. package/components/views/DesktopView.tsx +244 -0
  175. package/components/views/MobileView.tsx +110 -0
  176. package/components/views/types.ts +75 -0
  177. package/contexts/PaneContext.tsx +336 -0
  178. package/data/claude/index.ts +9 -0
  179. package/data/claude/keys.ts +6 -0
  180. package/data/claude/queries.ts +120 -0
  181. package/data/claude/useClaudeUpdates.ts +37 -0
  182. package/data/code-search/index.ts +2 -0
  183. package/data/code-search/keys.ts +7 -0
  184. package/data/code-search/queries.ts +61 -0
  185. package/data/dev-servers/index.ts +8 -0
  186. package/data/dev-servers/keys.ts +4 -0
  187. package/data/dev-servers/queries.ts +104 -0
  188. package/data/files/index.ts +3 -0
  189. package/data/files/keys.ts +4 -0
  190. package/data/files/queries.ts +25 -0
  191. package/data/git/keys.ts +15 -0
  192. package/data/git/queries.ts +395 -0
  193. package/data/groups/index.ts +1 -0
  194. package/data/groups/mutations.ts +95 -0
  195. package/data/projects/index.ts +10 -0
  196. package/data/projects/keys.ts +4 -0
  197. package/data/projects/queries.ts +193 -0
  198. package/data/repositories/index.ts +7 -0
  199. package/data/repositories/keys.ts +5 -0
  200. package/data/repositories/queries.ts +122 -0
  201. package/data/sessions/index.ts +12 -0
  202. package/data/sessions/keys.ts +8 -0
  203. package/data/sessions/queries.ts +218 -0
  204. package/data/statuses/index.ts +1 -0
  205. package/data/statuses/queries.ts +69 -0
  206. package/hooks/useCopyToClipboard.ts +48 -0
  207. package/hooks/useDevServersManager.ts +73 -0
  208. package/hooks/useDirectoryBrowser.ts +90 -0
  209. package/hooks/useDrawerAnimation.ts +27 -0
  210. package/hooks/useFileDrop.ts +87 -0
  211. package/hooks/useFileEditor.ts +184 -0
  212. package/hooks/useGroups.ts +37 -0
  213. package/hooks/useHomePath.ts +34 -0
  214. package/hooks/useKeyRepeat.ts +55 -0
  215. package/hooks/useKeybarVisibility.ts +42 -0
  216. package/hooks/useNotifications.ts +257 -0
  217. package/hooks/useProjects.ts +53 -0
  218. package/hooks/useSessionStatuses.ts +30 -0
  219. package/hooks/useSessions.ts +86 -0
  220. package/hooks/useSpeechRecognition.ts +124 -0
  221. package/hooks/useViewport.ts +32 -0
  222. package/hooks/useViewportHeight.ts +50 -0
  223. package/lib/async-operations.ts +35 -0
  224. package/lib/banner.ts +81 -0
  225. package/lib/claude/jsonl-cache.ts +86 -0
  226. package/lib/claude/jsonl-reader.ts +271 -0
  227. package/lib/claude/process-manager.ts +278 -0
  228. package/lib/claude/stream-parser.ts +173 -0
  229. package/lib/claude/types.ts +154 -0
  230. package/lib/claude/watcher.ts +71 -0
  231. package/lib/client/session-registry.ts +111 -0
  232. package/lib/code-search.ts +121 -0
  233. package/lib/db/index.ts +48 -0
  234. package/lib/db/migrations.ts +45 -0
  235. package/lib/db/queries.ts +460 -0
  236. package/lib/db/schema.ts +114 -0
  237. package/lib/db/types.ts +92 -0
  238. package/lib/db.ts +2 -0
  239. package/lib/dev-servers.ts +509 -0
  240. package/lib/diff-parser.ts +221 -0
  241. package/lib/env-setup.ts +285 -0
  242. package/lib/file-upload.ts +34 -0
  243. package/lib/file-utils.ts +50 -0
  244. package/lib/files.ts +207 -0
  245. package/lib/git-history.ts +294 -0
  246. package/lib/git-status.ts +391 -0
  247. package/lib/git.ts +257 -0
  248. package/lib/mcp-config.ts +81 -0
  249. package/lib/multi-repo-git.ts +179 -0
  250. package/lib/notifications.ts +219 -0
  251. package/lib/orchestration.ts +448 -0
  252. package/lib/panes.ts +232 -0
  253. package/lib/ports.ts +97 -0
  254. package/lib/pr-generation.ts +307 -0
  255. package/lib/pr.ts +234 -0
  256. package/lib/projects.ts +578 -0
  257. package/lib/providers/registry.ts +70 -0
  258. package/lib/providers.ts +121 -0
  259. package/lib/query-client.ts +14 -0
  260. package/lib/rangeSelectionUtils.ts +65 -0
  261. package/lib/status-detector.ts +375 -0
  262. package/lib/terminal-themes.ts +265 -0
  263. package/lib/theme-config.ts +327 -0
  264. package/lib/utils.ts +6 -0
  265. package/lib/worktrees.ts +262 -0
  266. package/mcp/orchestration-server.ts +438 -0
  267. package/package.json +139 -0
  268. package/postcss.config.mjs +7 -0
  269. package/public/icon.svg +10 -0
  270. package/public/icons/icon-128x128.png +0 -0
  271. package/public/icons/icon-144x144.png +0 -0
  272. package/public/icons/icon-152x152.png +0 -0
  273. package/public/icons/icon-192x192.png +0 -0
  274. package/public/icons/icon-384x384.png +0 -0
  275. package/public/icons/icon-512x512.png +0 -0
  276. package/public/icons/icon-72x72.png +0 -0
  277. package/public/icons/icon-96x96.png +0 -0
  278. package/public/manifest.json +61 -0
  279. package/public/sw.js +64 -0
  280. package/scripts/agent-os +91 -0
  281. package/scripts/install.sh +48 -0
  282. package/scripts/lib/ai-clis.sh +132 -0
  283. package/scripts/lib/commands.sh +487 -0
  284. package/scripts/lib/common.sh +89 -0
  285. package/scripts/lib/prerequisites.sh +462 -0
  286. package/scripts/setup.sh +134 -0
  287. package/server.ts +155 -0
  288. package/stores/fileOpen.ts +26 -0
  289. package/stores/index.ts +1 -0
  290. package/stores/initialPrompt.ts +24 -0
  291. package/stores/sessionSelection.ts +48 -0
  292. package/styles/themes.css +603 -0
  293. package/tsconfig.json +33 -0
@@ -0,0 +1,282 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, useCallback } from "react";
4
+ import type { Terminal as XTerm } from "@xterm/xterm";
5
+ import type { FitAddon } from "@xterm/addon-fit";
6
+ import type { SearchAddon } from "@xterm/addon-search";
7
+ import { WS_RECONNECT_BASE_DELAY } from "../constants";
8
+ import type {
9
+ TerminalScrollState,
10
+ UseTerminalConnectionProps,
11
+ UseTerminalConnectionReturn,
12
+ } from "./useTerminalConnection.types";
13
+ import {
14
+ createTerminal,
15
+ updateTerminalForMobile,
16
+ updateTerminalTheme,
17
+ } from "./terminal-init";
18
+ import { setupTouchScroll } from "./touch-scroll";
19
+ import { createWebSocketConnection } from "./websocket-connection";
20
+ import { setupResizeHandlers } from "./resize-handlers";
21
+
22
+ export type { TerminalScrollState } from "./useTerminalConnection.types";
23
+
24
+ export function useTerminalConnection({
25
+ terminalRef,
26
+ onConnected,
27
+ onDisconnected,
28
+ onBeforeUnmount,
29
+ initialScrollState,
30
+ isMobile = false,
31
+ theme = "dark",
32
+ selectMode = false,
33
+ }: UseTerminalConnectionProps): UseTerminalConnectionReturn {
34
+ const [connected, setConnected] = useState(false);
35
+ const [isAtBottom, setIsAtBottom] = useState(true);
36
+ const [connectionState, setConnectionState] = useState<
37
+ "connecting" | "connected" | "disconnected" | "reconnecting"
38
+ >("connecting");
39
+
40
+ const wsRef = useRef<WebSocket | null>(null);
41
+ const xtermRef = useRef<XTerm | null>(null);
42
+ const fitAddonRef = useRef<FitAddon | null>(null);
43
+ const searchAddonRef = useRef<SearchAddon | null>(null);
44
+ const reconnectFnRef = useRef<(() => void) | null>(null);
45
+
46
+ // Reconnection tracking
47
+ const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
48
+ const reconnectDelayRef = useRef<number>(WS_RECONNECT_BASE_DELAY);
49
+ const intentionalCloseRef = useRef<boolean>(false);
50
+
51
+ // Store callbacks and state in refs
52
+ const callbacksRef = useRef({ onConnected, onDisconnected, onBeforeUnmount });
53
+ callbacksRef.current = { onConnected, onDisconnected, onBeforeUnmount };
54
+ const initialScrollStateRef = useRef(initialScrollState);
55
+ const selectModeRef = useRef(selectMode);
56
+ selectModeRef.current = selectMode;
57
+
58
+ // Simple callbacks
59
+ const scrollToBottom = useCallback(
60
+ () => xtermRef.current?.scrollToBottom(),
61
+ []
62
+ );
63
+
64
+ const copySelection = useCallback(() => {
65
+ const selection = xtermRef.current?.getSelection();
66
+ if (selection) {
67
+ navigator.clipboard.writeText(selection);
68
+ return true;
69
+ }
70
+ return false;
71
+ }, []);
72
+
73
+ const sendInput = useCallback((data: string) => {
74
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
75
+ wsRef.current.send(JSON.stringify({ type: "input", data }));
76
+ }
77
+ }, []);
78
+
79
+ const sendCommand = useCallback((command: string) => {
80
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
81
+ wsRef.current.send(JSON.stringify({ type: "command", data: command }));
82
+ }
83
+ }, []);
84
+
85
+ const focus = useCallback(() => xtermRef.current?.focus(), []);
86
+
87
+ const getScrollState = useCallback((): TerminalScrollState | null => {
88
+ if (!xtermRef.current || !terminalRef.current) return null;
89
+ const buffer = xtermRef.current.buffer.active;
90
+ const viewport = terminalRef.current.querySelector(
91
+ ".xterm-viewport"
92
+ ) as HTMLElement;
93
+ return {
94
+ scrollTop: viewport?.scrollTop ?? 0,
95
+ cursorY: buffer.cursorY,
96
+ baseY: buffer.baseY,
97
+ };
98
+ }, [terminalRef]);
99
+
100
+ const restoreScrollState = useCallback(
101
+ (state: TerminalScrollState) => {
102
+ const viewport = terminalRef.current?.querySelector(
103
+ ".xterm-viewport"
104
+ ) as HTMLElement;
105
+ if (viewport) {
106
+ requestAnimationFrame(() => {
107
+ viewport.scrollTop = state.scrollTop;
108
+ });
109
+ }
110
+ },
111
+ [terminalRef]
112
+ );
113
+
114
+ const triggerResize = useCallback(() => {
115
+ const fitAddon = fitAddonRef.current;
116
+ const term = xtermRef.current;
117
+ if (!fitAddon || !term) return;
118
+ fitAddon.fit();
119
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
120
+ wsRef.current.send(
121
+ JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })
122
+ );
123
+ }
124
+ }, []);
125
+
126
+ const reconnect = useCallback(() => {
127
+ reconnectFnRef.current?.();
128
+ }, []);
129
+
130
+ // Main setup effect
131
+ useEffect(() => {
132
+ if (!terminalRef.current) return;
133
+
134
+ let cancelled = false;
135
+ // Reset intentional close flag (may be true from previous cleanup)
136
+ intentionalCloseRef.current = false;
137
+ let cleanupTouchScroll: (() => void) | null = null;
138
+ let cleanupResizeHandlers: (() => void) | null = null;
139
+ let cleanupWebSocket: (() => void) | null = null;
140
+ let cleanupTerminal: (() => void) | null = null;
141
+
142
+ const connectTimeout = setTimeout(() => {
143
+ if (cancelled || !terminalRef.current) return;
144
+
145
+ // Initialize terminal
146
+ const { term, fitAddon, searchAddon, cleanup } = createTerminal(
147
+ terminalRef.current,
148
+ isMobile,
149
+ theme
150
+ );
151
+ xtermRef.current = term;
152
+ fitAddonRef.current = fitAddon;
153
+ searchAddonRef.current = searchAddon;
154
+ cleanupTerminal = cleanup;
155
+
156
+ // Scroll tracking
157
+ term.onScroll(() => {
158
+ const buffer = term.buffer.active;
159
+ setIsAtBottom(buffer.viewportY >= buffer.baseY);
160
+ });
161
+
162
+ // Setup touch scroll
163
+ cleanupTouchScroll = setupTouchScroll({ term, selectModeRef });
164
+
165
+ // Setup WebSocket
166
+ const wsManager = createWebSocketConnection(
167
+ term,
168
+ {
169
+ onConnected: () => {
170
+ callbacksRef.current.onConnected?.();
171
+ // Restore scroll state after connection
172
+ if (initialScrollStateRef.current && terminalRef.current) {
173
+ setTimeout(() => {
174
+ const viewport = terminalRef.current?.querySelector(
175
+ ".xterm-viewport"
176
+ ) as HTMLElement;
177
+ if (viewport)
178
+ viewport.scrollTop = initialScrollStateRef.current!.scrollTop;
179
+ }, 200);
180
+ }
181
+ },
182
+ onDisconnected: () => callbacksRef.current.onDisconnected?.(),
183
+ onConnectionStateChange: setConnectionState,
184
+ onSetConnected: setConnected,
185
+ },
186
+ wsRef,
187
+ reconnectTimeoutRef,
188
+ reconnectDelayRef,
189
+ intentionalCloseRef
190
+ );
191
+ cleanupWebSocket = wsManager.cleanup;
192
+ reconnectFnRef.current = wsManager.reconnect;
193
+
194
+ // Setup resize handlers
195
+ cleanupResizeHandlers = setupResizeHandlers({
196
+ term,
197
+ fitAddon,
198
+ containerRef: terminalRef,
199
+ isMobile,
200
+ sendResize: wsManager.sendResize,
201
+ });
202
+ }, 150);
203
+
204
+ return () => {
205
+ cancelled = true;
206
+ clearTimeout(connectTimeout);
207
+ intentionalCloseRef.current = true;
208
+
209
+ // Save scroll state before unmount
210
+ const term = xtermRef.current;
211
+ if (term && callbacksRef.current.onBeforeUnmount && terminalRef.current) {
212
+ const buffer = term.buffer.active;
213
+ const viewport = terminalRef.current.querySelector(
214
+ ".xterm-viewport"
215
+ ) as HTMLElement;
216
+ callbacksRef.current.onBeforeUnmount({
217
+ scrollTop: viewport?.scrollTop ?? 0,
218
+ cursorY: buffer.cursorY,
219
+ baseY: buffer.baseY,
220
+ });
221
+ }
222
+
223
+ // Cleanup in reverse order
224
+ cleanupResizeHandlers?.();
225
+ cleanupWebSocket?.();
226
+ cleanupTouchScroll?.();
227
+ cleanupTerminal?.();
228
+
229
+ // Reset refs
230
+ reconnectDelayRef.current = WS_RECONNECT_BASE_DELAY;
231
+
232
+ if (wsRef.current) wsRef.current = null;
233
+ if (xtermRef.current) {
234
+ try {
235
+ xtermRef.current.dispose();
236
+ } catch {
237
+ /* ignore */
238
+ }
239
+ xtermRef.current = null;
240
+ }
241
+ fitAddonRef.current = null;
242
+ searchAddonRef.current = null;
243
+ };
244
+ }, [isMobile, terminalRef, theme]);
245
+
246
+ // Handle isMobile changes dynamically
247
+ useEffect(() => {
248
+ const term = xtermRef.current;
249
+ const fitAddon = fitAddonRef.current;
250
+ if (!term || !fitAddon) return;
251
+
252
+ updateTerminalForMobile(term, fitAddon, isMobile, (cols, rows) => {
253
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
254
+ wsRef.current.send(JSON.stringify({ type: "resize", cols, rows }));
255
+ }
256
+ });
257
+ }, [isMobile]);
258
+
259
+ // Handle theme changes dynamically
260
+ useEffect(() => {
261
+ if (xtermRef.current) {
262
+ updateTerminalTheme(xtermRef.current, theme);
263
+ }
264
+ }, [theme]);
265
+
266
+ return {
267
+ connected,
268
+ connectionState,
269
+ isAtBottom,
270
+ xtermRef,
271
+ searchAddonRef,
272
+ scrollToBottom,
273
+ copySelection,
274
+ sendInput,
275
+ sendCommand,
276
+ focus,
277
+ getScrollState,
278
+ restoreScrollState,
279
+ triggerResize,
280
+ reconnect,
281
+ };
282
+ }
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import type { RefObject } from "react";
4
+ import type { Terminal as XTerm } from "@xterm/xterm";
5
+ import type { SearchAddon } from "@xterm/addon-search";
6
+
7
+ export interface TerminalScrollState {
8
+ scrollTop: number;
9
+ cursorY: number;
10
+ baseY: number;
11
+ }
12
+
13
+ export interface UseTerminalConnectionProps {
14
+ terminalRef: RefObject<HTMLDivElement | null>;
15
+ onConnected?: () => void;
16
+ onDisconnected?: () => void;
17
+ onBeforeUnmount?: (scrollState: TerminalScrollState) => void;
18
+ initialScrollState?: TerminalScrollState;
19
+ isMobile?: boolean;
20
+ theme?: string;
21
+ selectMode?: boolean;
22
+ }
23
+
24
+ export interface UseTerminalConnectionReturn {
25
+ connected: boolean;
26
+ connectionState: "connecting" | "connected" | "disconnected" | "reconnecting";
27
+ isAtBottom: boolean;
28
+ xtermRef: RefObject<XTerm | null>;
29
+ searchAddonRef: RefObject<SearchAddon | null>;
30
+ scrollToBottom: () => void;
31
+ copySelection: () => boolean;
32
+ sendInput: (data: string) => void;
33
+ sendCommand: (command: string) => void;
34
+ focus: () => void;
35
+ getScrollState: () => TerminalScrollState | null;
36
+ restoreScrollState: (state: TerminalScrollState) => void;
37
+ triggerResize: () => void;
38
+ reconnect: () => void;
39
+ }
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useEffect, useRef } from "react";
4
+ import type { SearchAddon } from "@xterm/addon-search";
5
+ import type { Terminal as XTerm } from "@xterm/xterm";
6
+
7
+ export function useTerminalSearch(
8
+ searchAddonRef: React.RefObject<SearchAddon | null>,
9
+ xtermRef: React.RefObject<XTerm | null>
10
+ ) {
11
+ const [searchVisible, setSearchVisible] = useState(false);
12
+ const [searchQuery, setSearchQuery] = useState("");
13
+ const searchInputRef = useRef<HTMLInputElement>(null);
14
+
15
+ const findNext = useCallback(() => {
16
+ if (searchAddonRef.current && searchQuery) {
17
+ searchAddonRef.current.findNext(searchQuery, {
18
+ regex: false,
19
+ caseSensitive: false,
20
+ incremental: true,
21
+ });
22
+ }
23
+ }, [searchQuery, searchAddonRef]);
24
+
25
+ const findPrevious = useCallback(() => {
26
+ if (searchAddonRef.current && searchQuery) {
27
+ searchAddonRef.current.findPrevious(searchQuery, {
28
+ regex: false,
29
+ caseSensitive: false,
30
+ });
31
+ }
32
+ }, [searchQuery, searchAddonRef]);
33
+
34
+ const closeSearch = useCallback(() => {
35
+ setSearchVisible(false);
36
+ setSearchQuery("");
37
+ if (searchAddonRef.current) {
38
+ searchAddonRef.current.clearDecorations();
39
+ }
40
+ xtermRef.current?.focus();
41
+ }, [searchAddonRef, xtermRef]);
42
+
43
+ const openSearch = useCallback(() => {
44
+ setSearchVisible(true);
45
+ setTimeout(() => searchInputRef.current?.focus(), 0);
46
+ }, []);
47
+
48
+ // Keyboard shortcuts
49
+ useEffect(() => {
50
+ const handleKeyDown = (e: KeyboardEvent) => {
51
+ // Ctrl/Cmd + Shift + F = Open search
52
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "f") {
53
+ e.preventDefault();
54
+ openSearch();
55
+ }
56
+ // Escape = Close search
57
+ if (e.key === "Escape" && searchVisible) {
58
+ closeSearch();
59
+ }
60
+ // Enter in search = Find next
61
+ if (
62
+ e.key === "Enter" &&
63
+ searchVisible &&
64
+ document.activeElement === searchInputRef.current
65
+ ) {
66
+ e.preventDefault();
67
+ if (e.shiftKey) {
68
+ findPrevious();
69
+ } else {
70
+ findNext();
71
+ }
72
+ }
73
+ };
74
+
75
+ window.addEventListener("keydown", handleKeyDown);
76
+ return () => window.removeEventListener("keydown", handleKeyDown);
77
+ }, [searchVisible, findNext, findPrevious, closeSearch, openSearch]);
78
+
79
+ // Auto-search when query changes
80
+ useEffect(() => {
81
+ if (searchQuery && searchAddonRef.current) {
82
+ findNext();
83
+ }
84
+ }, [searchQuery, findNext, searchAddonRef]);
85
+
86
+ return {
87
+ searchVisible,
88
+ searchQuery,
89
+ setSearchQuery,
90
+ searchInputRef,
91
+ openSearch,
92
+ closeSearch,
93
+ findNext,
94
+ findPrevious,
95
+ toggleSearch: () => {
96
+ if (searchVisible) {
97
+ closeSearch();
98
+ } else {
99
+ openSearch();
100
+ }
101
+ },
102
+ };
103
+ }
@@ -0,0 +1,274 @@
1
+ "use client";
2
+
3
+ import type { Terminal as XTerm } from "@xterm/xterm";
4
+ import {
5
+ WS_RECONNECT_BASE_DELAY,
6
+ WS_RECONNECT_MAX_DELAY,
7
+ WS_INACTIVITY_TIMEOUT,
8
+ } from "../constants";
9
+
10
+ export interface WebSocketCallbacks {
11
+ onConnected?: () => void;
12
+ onDisconnected?: () => void;
13
+ onConnectionStateChange: (
14
+ state: "connecting" | "connected" | "disconnected" | "reconnecting"
15
+ ) => void;
16
+ onSetConnected: (connected: boolean) => void;
17
+ }
18
+
19
+ export interface WebSocketManager {
20
+ ws: WebSocket;
21
+ sendInput: (data: string) => void;
22
+ sendCommand: (command: string) => void;
23
+ sendResize: (cols: number, rows: number) => void;
24
+ reconnect: () => void;
25
+ cleanup: () => void;
26
+ }
27
+
28
+ export function createWebSocketConnection(
29
+ term: XTerm,
30
+ callbacks: WebSocketCallbacks,
31
+ wsRef: React.MutableRefObject<WebSocket | null>,
32
+ reconnectTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>,
33
+ reconnectDelayRef: React.MutableRefObject<number>,
34
+ intentionalCloseRef: React.MutableRefObject<boolean>
35
+ ): WebSocketManager {
36
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
37
+ const ws = new WebSocket(`${protocol}//${window.location.host}/ws/terminal`);
38
+ wsRef.current = ws;
39
+
40
+ const sendResize = (cols: number, rows: number) => {
41
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
42
+ wsRef.current.send(JSON.stringify({ type: "resize", cols, rows }));
43
+ }
44
+ };
45
+
46
+ const sendInput = (data: string) => {
47
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
48
+ wsRef.current.send(JSON.stringify({ type: "input", data }));
49
+ }
50
+ };
51
+
52
+ const sendCommand = (command: string) => {
53
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
54
+ wsRef.current.send(JSON.stringify({ type: "command", data: command }));
55
+ }
56
+ };
57
+
58
+ // Force reconnect - kills any existing connection and creates fresh one
59
+ // Note: savedHandlers is populated after handlers are defined below
60
+ let savedHandlers: {
61
+ onopen: typeof ws.onopen;
62
+ onmessage: typeof ws.onmessage;
63
+ onclose: typeof ws.onclose;
64
+ onerror: typeof ws.onerror;
65
+ };
66
+
67
+ const forceReconnect = () => {
68
+ if (intentionalCloseRef.current) return;
69
+
70
+ // Clear any pending reconnect
71
+ if (reconnectTimeoutRef.current) {
72
+ clearTimeout(reconnectTimeoutRef.current);
73
+ reconnectTimeoutRef.current = null;
74
+ }
75
+
76
+ // Force close existing socket regardless of state (handles hung sockets)
77
+ const oldWs = wsRef.current;
78
+ if (oldWs) {
79
+ // Remove handlers to prevent callbacks
80
+ oldWs.onopen = null;
81
+ oldWs.onmessage = null;
82
+ oldWs.onclose = null;
83
+ oldWs.onerror = null;
84
+ try {
85
+ oldWs.close();
86
+ } catch {
87
+ /* ignore */
88
+ }
89
+ wsRef.current = null;
90
+ }
91
+
92
+ callbacks.onConnectionStateChange("reconnecting");
93
+ reconnectDelayRef.current = WS_RECONNECT_BASE_DELAY;
94
+
95
+ // Create fresh connection with saved handlers
96
+ const newWs = new WebSocket(
97
+ `${protocol}//${window.location.host}/ws/terminal`
98
+ );
99
+ wsRef.current = newWs;
100
+ newWs.onopen = savedHandlers.onopen;
101
+ newWs.onmessage = savedHandlers.onmessage;
102
+ newWs.onclose = savedHandlers.onclose;
103
+ newWs.onerror = savedHandlers.onerror;
104
+ };
105
+
106
+ // Soft reconnect - only if not already connected
107
+ const attemptReconnect = () => {
108
+ if (intentionalCloseRef.current) return;
109
+ if (wsRef.current?.readyState === WebSocket.OPEN) return;
110
+ forceReconnect();
111
+ };
112
+
113
+ let inactivityTimer: ReturnType<typeof setTimeout> | null = null;
114
+ const resetInactivityTimer = () => {
115
+ if (inactivityTimer) clearTimeout(inactivityTimer);
116
+ inactivityTimer = setTimeout(() => {
117
+ if (ws.readyState === WebSocket.OPEN) {
118
+ forceReconnect();
119
+ }
120
+ }, WS_INACTIVITY_TIMEOUT);
121
+ };
122
+
123
+ ws.onopen = () => {
124
+ callbacks.onSetConnected(true);
125
+ callbacks.onConnectionStateChange("connected");
126
+ reconnectDelayRef.current = WS_RECONNECT_BASE_DELAY;
127
+ callbacks.onConnected?.();
128
+ sendResize(term.cols, term.rows);
129
+ term.focus();
130
+ resetInactivityTimer();
131
+ };
132
+
133
+ ws.onmessage = (event) => {
134
+ resetInactivityTimer();
135
+ try {
136
+ const msg = JSON.parse(event.data);
137
+ if (msg.type === "output") {
138
+ const buffer = term.buffer.active;
139
+ const scrollYBefore = buffer.viewportY;
140
+ const wasAtTop = scrollYBefore <= 0;
141
+ const wasAtBottom = scrollYBefore >= buffer.baseY;
142
+
143
+ term.write(msg.data);
144
+
145
+ // After write, check if scroll jumped to top unexpectedly
146
+ // Give it a moment for the write to complete
147
+ requestAnimationFrame(() => {
148
+ const scrollYAfter = term.buffer.active.viewportY;
149
+ const isNowAtTop = scrollYAfter <= 0;
150
+
151
+ // If we jumped to top but weren't at top before, and weren't at bottom
152
+ // (at bottom means we want to follow output), restore position
153
+ if (isNowAtTop && !wasAtTop && !wasAtBottom && scrollYBefore > 5) {
154
+ term.scrollToLine(scrollYBefore);
155
+ }
156
+ });
157
+ } else if (msg.type === "exit") {
158
+ term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n");
159
+ }
160
+ } catch {
161
+ term.write(event.data);
162
+ }
163
+ };
164
+
165
+ ws.onclose = () => {
166
+ callbacks.onSetConnected(false);
167
+ callbacks.onDisconnected?.();
168
+
169
+ if (intentionalCloseRef.current) {
170
+ callbacks.onConnectionStateChange("disconnected");
171
+ return;
172
+ }
173
+
174
+ callbacks.onConnectionStateChange("disconnected");
175
+
176
+ const currentDelay = reconnectDelayRef.current;
177
+ reconnectDelayRef.current = Math.min(
178
+ currentDelay * 2,
179
+ WS_RECONNECT_MAX_DELAY
180
+ );
181
+ reconnectTimeoutRef.current = setTimeout(attemptReconnect, currentDelay);
182
+ };
183
+
184
+ ws.onerror = () => {
185
+ // Errors are handled by onclose
186
+ };
187
+
188
+ // Save handlers now that they're defined (for reconnection)
189
+ savedHandlers = {
190
+ onopen: ws.onopen,
191
+ onmessage: ws.onmessage,
192
+ onclose: ws.onclose,
193
+ onerror: ws.onerror,
194
+ };
195
+
196
+ // Handle terminal input
197
+ term.onData((data) => {
198
+ sendInput(data);
199
+ });
200
+
201
+ // Handle Shift+Enter for multi-line input
202
+ term.attachCustomKeyEventHandler((event) => {
203
+ if (event.type === "keydown" && event.key === "Enter" && event.shiftKey) {
204
+ sendInput("\n");
205
+ return false;
206
+ }
207
+ return true;
208
+ });
209
+
210
+ // Track when page was last hidden (for detecting long sleeps)
211
+ let hiddenAt: number | null = null;
212
+
213
+ // Handle visibility change for reconnection
214
+ const handleVisibilityChange = () => {
215
+ if (intentionalCloseRef.current) return;
216
+
217
+ if (document.visibilityState === "hidden") {
218
+ hiddenAt = Date.now();
219
+ return;
220
+ }
221
+
222
+ // Page became visible
223
+ if (document.visibilityState !== "visible") return;
224
+
225
+ const wasHiddenFor = hiddenAt ? Date.now() - hiddenAt : 0;
226
+ hiddenAt = null;
227
+
228
+ // If hidden for more than 5 seconds, force reconnect (iOS Safari kills sockets)
229
+ // This handles the "hung socket" problem where readyState says OPEN but it's dead
230
+ if (wasHiddenFor > 5000) {
231
+ forceReconnect();
232
+ return;
233
+ }
234
+
235
+ // Otherwise only reconnect if actually disconnected
236
+ const currentWs = wsRef.current;
237
+ const isDisconnected =
238
+ !currentWs ||
239
+ currentWs.readyState === WebSocket.CLOSED ||
240
+ currentWs.readyState === WebSocket.CLOSING;
241
+ const isStaleConnection = currentWs?.readyState === WebSocket.CONNECTING;
242
+
243
+ if (isDisconnected || isStaleConnection) {
244
+ forceReconnect();
245
+ }
246
+ };
247
+ document.addEventListener("visibilitychange", handleVisibilityChange);
248
+
249
+ const cleanup = () => {
250
+ if (inactivityTimer) clearTimeout(inactivityTimer);
251
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
252
+ if (reconnectTimeoutRef.current) {
253
+ clearTimeout(reconnectTimeoutRef.current);
254
+ reconnectTimeoutRef.current = null;
255
+ }
256
+ const currentWs = wsRef.current;
257
+ if (
258
+ currentWs &&
259
+ (currentWs.readyState === WebSocket.OPEN ||
260
+ currentWs.readyState === WebSocket.CONNECTING)
261
+ ) {
262
+ currentWs.close(1000, "Component unmounting");
263
+ }
264
+ };
265
+
266
+ return {
267
+ ws,
268
+ sendInput,
269
+ sendCommand,
270
+ sendResize,
271
+ reconnect: forceReconnect,
272
+ cleanup,
273
+ };
274
+ }