gitspace 0.2.0-rc.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 (318) hide show
  1. package/.claude/settings.local.json +21 -0
  2. package/.gitspace/bundle.json +50 -0
  3. package/.gitspace/select/01-status.sh +40 -0
  4. package/.gitspace/setup/01-install-deps.sh +12 -0
  5. package/.gitspace/setup/02-typecheck.sh +16 -0
  6. package/AGENTS.md +439 -0
  7. package/CLAUDE.md +1 -0
  8. package/LICENSE +25 -0
  9. package/README.md +607 -0
  10. package/bin/gssh +62 -0
  11. package/bun.lock +647 -0
  12. package/docs/CONNECTION.md +623 -0
  13. package/docs/GATEWAY-WORKER.md +319 -0
  14. package/docs/GETTING-STARTED.md +448 -0
  15. package/docs/GITSPACE-PLATFORM.md +1819 -0
  16. package/docs/INFRASTRUCTURE.md +1347 -0
  17. package/docs/PROTOCOL.md +619 -0
  18. package/docs/QUICKSTART.md +174 -0
  19. package/docs/RELAY.md +327 -0
  20. package/docs/REMOTE-DESIGN.md +549 -0
  21. package/docs/ROADMAP.md +564 -0
  22. package/docs/SITE_DOCS_FIGMA_MAKE.md +1167 -0
  23. package/docs/STACK-DESIGN.md +588 -0
  24. package/docs/UNIFIED_ARCHITECTURE.md +292 -0
  25. package/experiments/pty-benchmark.ts +148 -0
  26. package/experiments/pty-latency.ts +100 -0
  27. package/experiments/router/client.ts +199 -0
  28. package/experiments/router/protocol.ts +74 -0
  29. package/experiments/router/router.ts +217 -0
  30. package/experiments/router/session.ts +180 -0
  31. package/experiments/router/test.ts +133 -0
  32. package/experiments/socket-bandwidth.ts +77 -0
  33. package/homebrew/gitspace.rb +45 -0
  34. package/landing-page/ATTRIBUTIONS.md +3 -0
  35. package/landing-page/README.md +11 -0
  36. package/landing-page/bun.lock +801 -0
  37. package/landing-page/guidelines/Guidelines.md +61 -0
  38. package/landing-page/index.html +37 -0
  39. package/landing-page/package.json +90 -0
  40. package/landing-page/postcss.config.mjs +15 -0
  41. package/landing-page/public/_redirects +1 -0
  42. package/landing-page/public/favicon.png +0 -0
  43. package/landing-page/src/app/App.tsx +53 -0
  44. package/landing-page/src/app/components/figma/ImageWithFallback.tsx +27 -0
  45. package/landing-page/src/app/components/ui/accordion.tsx +66 -0
  46. package/landing-page/src/app/components/ui/alert-dialog.tsx +157 -0
  47. package/landing-page/src/app/components/ui/alert.tsx +66 -0
  48. package/landing-page/src/app/components/ui/aspect-ratio.tsx +11 -0
  49. package/landing-page/src/app/components/ui/avatar.tsx +53 -0
  50. package/landing-page/src/app/components/ui/badge.tsx +46 -0
  51. package/landing-page/src/app/components/ui/breadcrumb.tsx +109 -0
  52. package/landing-page/src/app/components/ui/button.tsx +57 -0
  53. package/landing-page/src/app/components/ui/calendar.tsx +75 -0
  54. package/landing-page/src/app/components/ui/card.tsx +92 -0
  55. package/landing-page/src/app/components/ui/carousel.tsx +241 -0
  56. package/landing-page/src/app/components/ui/chart.tsx +353 -0
  57. package/landing-page/src/app/components/ui/checkbox.tsx +32 -0
  58. package/landing-page/src/app/components/ui/collapsible.tsx +33 -0
  59. package/landing-page/src/app/components/ui/command.tsx +177 -0
  60. package/landing-page/src/app/components/ui/context-menu.tsx +252 -0
  61. package/landing-page/src/app/components/ui/dialog.tsx +135 -0
  62. package/landing-page/src/app/components/ui/drawer.tsx +132 -0
  63. package/landing-page/src/app/components/ui/dropdown-menu.tsx +257 -0
  64. package/landing-page/src/app/components/ui/form.tsx +168 -0
  65. package/landing-page/src/app/components/ui/hover-card.tsx +44 -0
  66. package/landing-page/src/app/components/ui/input-otp.tsx +77 -0
  67. package/landing-page/src/app/components/ui/input.tsx +21 -0
  68. package/landing-page/src/app/components/ui/label.tsx +24 -0
  69. package/landing-page/src/app/components/ui/menubar.tsx +276 -0
  70. package/landing-page/src/app/components/ui/navigation-menu.tsx +168 -0
  71. package/landing-page/src/app/components/ui/pagination.tsx +127 -0
  72. package/landing-page/src/app/components/ui/popover.tsx +48 -0
  73. package/landing-page/src/app/components/ui/progress.tsx +31 -0
  74. package/landing-page/src/app/components/ui/radio-group.tsx +45 -0
  75. package/landing-page/src/app/components/ui/resizable.tsx +56 -0
  76. package/landing-page/src/app/components/ui/scroll-area.tsx +58 -0
  77. package/landing-page/src/app/components/ui/select.tsx +189 -0
  78. package/landing-page/src/app/components/ui/separator.tsx +28 -0
  79. package/landing-page/src/app/components/ui/sheet.tsx +139 -0
  80. package/landing-page/src/app/components/ui/sidebar.tsx +726 -0
  81. package/landing-page/src/app/components/ui/skeleton.tsx +13 -0
  82. package/landing-page/src/app/components/ui/slider.tsx +63 -0
  83. package/landing-page/src/app/components/ui/sonner.tsx +25 -0
  84. package/landing-page/src/app/components/ui/switch.tsx +31 -0
  85. package/landing-page/src/app/components/ui/table.tsx +116 -0
  86. package/landing-page/src/app/components/ui/tabs.tsx +66 -0
  87. package/landing-page/src/app/components/ui/textarea.tsx +18 -0
  88. package/landing-page/src/app/components/ui/toggle-group.tsx +73 -0
  89. package/landing-page/src/app/components/ui/toggle.tsx +47 -0
  90. package/landing-page/src/app/components/ui/tooltip.tsx +61 -0
  91. package/landing-page/src/app/components/ui/use-mobile.ts +21 -0
  92. package/landing-page/src/app/components/ui/utils.ts +6 -0
  93. package/landing-page/src/components/docs/DocsContent.tsx +718 -0
  94. package/landing-page/src/components/docs/DocsSidebar.tsx +84 -0
  95. package/landing-page/src/components/landing/CTA.tsx +59 -0
  96. package/landing-page/src/components/landing/Comparison.tsx +84 -0
  97. package/landing-page/src/components/landing/FaultyTerminal.tsx +424 -0
  98. package/landing-page/src/components/landing/Features.tsx +201 -0
  99. package/landing-page/src/components/landing/Hero.tsx +142 -0
  100. package/landing-page/src/components/landing/Pricing.tsx +140 -0
  101. package/landing-page/src/components/landing/Roadmap.tsx +86 -0
  102. package/landing-page/src/components/landing/Security.tsx +81 -0
  103. package/landing-page/src/components/landing/TerminalWindow.tsx +27 -0
  104. package/landing-page/src/components/landing/UseCases.tsx +55 -0
  105. package/landing-page/src/components/landing/Workflow.tsx +101 -0
  106. package/landing-page/src/components/layout/DashboardNavbar.tsx +37 -0
  107. package/landing-page/src/components/layout/Footer.tsx +55 -0
  108. package/landing-page/src/components/layout/LandingNavbar.tsx +82 -0
  109. package/landing-page/src/components/ui/badge.tsx +39 -0
  110. package/landing-page/src/components/ui/breadcrumb.tsx +115 -0
  111. package/landing-page/src/components/ui/button.tsx +57 -0
  112. package/landing-page/src/components/ui/card.tsx +79 -0
  113. package/landing-page/src/components/ui/mock-terminal.tsx +68 -0
  114. package/landing-page/src/components/ui/separator.tsx +28 -0
  115. package/landing-page/src/lib/utils.ts +6 -0
  116. package/landing-page/src/main.tsx +10 -0
  117. package/landing-page/src/pages/Dashboard.tsx +133 -0
  118. package/landing-page/src/pages/DocsPage.tsx +79 -0
  119. package/landing-page/src/pages/LandingPage.tsx +31 -0
  120. package/landing-page/src/pages/TerminalView.tsx +106 -0
  121. package/landing-page/src/styles/fonts.css +0 -0
  122. package/landing-page/src/styles/index.css +3 -0
  123. package/landing-page/src/styles/tailwind.css +4 -0
  124. package/landing-page/src/styles/theme.css +181 -0
  125. package/landing-page/vite.config.ts +19 -0
  126. package/npm/darwin-arm64/bin/gssh +0 -0
  127. package/npm/darwin-arm64/package.json +20 -0
  128. package/package.json +74 -0
  129. package/scripts/build.ts +284 -0
  130. package/scripts/release.ts +140 -0
  131. package/src/__tests__/test-utils.ts +298 -0
  132. package/src/commands/__tests__/serve-messages.test.ts +190 -0
  133. package/src/commands/access.ts +298 -0
  134. package/src/commands/add.ts +452 -0
  135. package/src/commands/auth.ts +364 -0
  136. package/src/commands/connect.ts +287 -0
  137. package/src/commands/directory.ts +16 -0
  138. package/src/commands/host.ts +396 -0
  139. package/src/commands/identity.ts +184 -0
  140. package/src/commands/list.ts +200 -0
  141. package/src/commands/relay.ts +315 -0
  142. package/src/commands/remove.ts +241 -0
  143. package/src/commands/serve.ts +1493 -0
  144. package/src/commands/share.ts +456 -0
  145. package/src/commands/status.ts +125 -0
  146. package/src/commands/switch.ts +353 -0
  147. package/src/commands/tmux.ts +317 -0
  148. package/src/core/__tests__/access.test.ts +240 -0
  149. package/src/core/access.ts +277 -0
  150. package/src/core/bundle.ts +342 -0
  151. package/src/core/config.ts +510 -0
  152. package/src/core/git.ts +317 -0
  153. package/src/core/github.ts +151 -0
  154. package/src/core/identity.ts +631 -0
  155. package/src/core/linear.ts +225 -0
  156. package/src/core/shell.ts +161 -0
  157. package/src/core/trusted-relays.ts +315 -0
  158. package/src/index.ts +821 -0
  159. package/src/lib/remote-session/index.ts +7 -0
  160. package/src/lib/remote-session/protocol.ts +267 -0
  161. package/src/lib/remote-session/session-handler.ts +581 -0
  162. package/src/lib/remote-session/workspace-scanner.ts +167 -0
  163. package/src/lib/tmux-lite/README.md +81 -0
  164. package/src/lib/tmux-lite/cli.ts +796 -0
  165. package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +349 -0
  166. package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +291 -0
  167. package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +142 -0
  168. package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +339 -0
  169. package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +477 -0
  170. package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +499 -0
  171. package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +371 -0
  172. package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +573 -0
  173. package/src/lib/tmux-lite/crypto/access-control.test.ts +512 -0
  174. package/src/lib/tmux-lite/crypto/access-control.ts +320 -0
  175. package/src/lib/tmux-lite/crypto/frames.test.ts +262 -0
  176. package/src/lib/tmux-lite/crypto/frames.ts +141 -0
  177. package/src/lib/tmux-lite/crypto/handshake.ts +894 -0
  178. package/src/lib/tmux-lite/crypto/identity.test.ts +220 -0
  179. package/src/lib/tmux-lite/crypto/identity.ts +286 -0
  180. package/src/lib/tmux-lite/crypto/index.ts +51 -0
  181. package/src/lib/tmux-lite/crypto/invites.test.ts +381 -0
  182. package/src/lib/tmux-lite/crypto/invites.ts +215 -0
  183. package/src/lib/tmux-lite/crypto/keyexchange.ts +435 -0
  184. package/src/lib/tmux-lite/crypto/keys.test.ts +58 -0
  185. package/src/lib/tmux-lite/crypto/keys.ts +47 -0
  186. package/src/lib/tmux-lite/crypto/secretbox.test.ts +169 -0
  187. package/src/lib/tmux-lite/crypto/secretbox.ts +124 -0
  188. package/src/lib/tmux-lite/handshake-handler.ts +451 -0
  189. package/src/lib/tmux-lite/protocol.test.ts +307 -0
  190. package/src/lib/tmux-lite/protocol.ts +266 -0
  191. package/src/lib/tmux-lite/relay-client.ts +506 -0
  192. package/src/lib/tmux-lite/server.ts +1250 -0
  193. package/src/lib/tmux-lite/shell-integration.sh +37 -0
  194. package/src/lib/tmux-lite/terminal-queries.test.ts +54 -0
  195. package/src/lib/tmux-lite/terminal-queries.ts +49 -0
  196. package/src/relay/__tests__/e2e-flow.test.ts +1284 -0
  197. package/src/relay/__tests__/helpers/auth.ts +354 -0
  198. package/src/relay/__tests__/helpers/ports.ts +51 -0
  199. package/src/relay/__tests__/protocol-validation.test.ts +265 -0
  200. package/src/relay/authorization.ts +303 -0
  201. package/src/relay/embedded-assets.generated.d.ts +15 -0
  202. package/src/relay/identity.ts +352 -0
  203. package/src/relay/index.ts +57 -0
  204. package/src/relay/pipes.test.ts +427 -0
  205. package/src/relay/pipes.ts +195 -0
  206. package/src/relay/protocol.ts +804 -0
  207. package/src/relay/registries.test.ts +437 -0
  208. package/src/relay/registries.ts +593 -0
  209. package/src/relay/server.test.ts +1323 -0
  210. package/src/relay/server.ts +1092 -0
  211. package/src/relay/signing.ts +238 -0
  212. package/src/relay/types.ts +69 -0
  213. package/src/serve/client-session-manager.ts +622 -0
  214. package/src/serve/daemon.ts +497 -0
  215. package/src/serve/pty-session.ts +236 -0
  216. package/src/serve/types.ts +169 -0
  217. package/src/shared/components/Flow.tsx +453 -0
  218. package/src/shared/components/Flow.tui.tsx +343 -0
  219. package/src/shared/components/Flow.web.tsx +442 -0
  220. package/src/shared/components/Inbox.tsx +446 -0
  221. package/src/shared/components/Inbox.tui.tsx +262 -0
  222. package/src/shared/components/Inbox.web.tsx +329 -0
  223. package/src/shared/components/MachineList.tsx +187 -0
  224. package/src/shared/components/MachineList.tui.tsx +161 -0
  225. package/src/shared/components/MachineList.web.tsx +210 -0
  226. package/src/shared/components/ProjectList.tsx +176 -0
  227. package/src/shared/components/ProjectList.tui.tsx +109 -0
  228. package/src/shared/components/ProjectList.web.tsx +143 -0
  229. package/src/shared/components/SpacesBrowser.tsx +332 -0
  230. package/src/shared/components/SpacesBrowser.tui.tsx +163 -0
  231. package/src/shared/components/SpacesBrowser.web.tsx +221 -0
  232. package/src/shared/components/index.ts +103 -0
  233. package/src/shared/hooks/index.ts +16 -0
  234. package/src/shared/hooks/useNavigation.ts +226 -0
  235. package/src/shared/index.ts +122 -0
  236. package/src/shared/providers/LocalMachineProvider.ts +425 -0
  237. package/src/shared/providers/MachineProvider.ts +165 -0
  238. package/src/shared/providers/RemoteMachineProvider.ts +444 -0
  239. package/src/shared/providers/index.ts +26 -0
  240. package/src/shared/types.ts +145 -0
  241. package/src/tui/adapters.ts +120 -0
  242. package/src/tui/app.tsx +1816 -0
  243. package/src/tui/components/Terminal.tsx +580 -0
  244. package/src/tui/hooks/index.ts +35 -0
  245. package/src/tui/hooks/useAppState.ts +314 -0
  246. package/src/tui/hooks/useDaemonStatus.ts +174 -0
  247. package/src/tui/hooks/useInboxTUI.ts +113 -0
  248. package/src/tui/hooks/useRemoteMachines.ts +209 -0
  249. package/src/tui/index.ts +24 -0
  250. package/src/tui/state.ts +299 -0
  251. package/src/tui/terminal-bracketed-paste.test.ts +45 -0
  252. package/src/tui/terminal-bracketed-paste.ts +47 -0
  253. package/src/types/bundle.ts +112 -0
  254. package/src/types/config.ts +89 -0
  255. package/src/types/errors.ts +206 -0
  256. package/src/types/identity.ts +284 -0
  257. package/src/types/workspace-fuzzy.ts +49 -0
  258. package/src/types/workspace.ts +151 -0
  259. package/src/utils/bun-socket-writer.ts +80 -0
  260. package/src/utils/deps.ts +127 -0
  261. package/src/utils/fuzzy-match.ts +125 -0
  262. package/src/utils/logger.ts +127 -0
  263. package/src/utils/markdown.ts +254 -0
  264. package/src/utils/onboarding.ts +229 -0
  265. package/src/utils/prompts.ts +114 -0
  266. package/src/utils/run-commands.ts +112 -0
  267. package/src/utils/run-scripts.ts +142 -0
  268. package/src/utils/sanitize.ts +98 -0
  269. package/src/utils/secrets.ts +122 -0
  270. package/src/utils/shell-escape.ts +40 -0
  271. package/src/utils/utf8.ts +79 -0
  272. package/src/utils/workspace-state.ts +47 -0
  273. package/src/web/README.md +73 -0
  274. package/src/web/bun.lock +575 -0
  275. package/src/web/eslint.config.js +23 -0
  276. package/src/web/index.html +16 -0
  277. package/src/web/package.json +37 -0
  278. package/src/web/public/vite.svg +1 -0
  279. package/src/web/src/App.tsx +604 -0
  280. package/src/web/src/assets/react.svg +1 -0
  281. package/src/web/src/components/Terminal.tsx +207 -0
  282. package/src/web/src/hooks/useRelayConnection.ts +224 -0
  283. package/src/web/src/hooks/useTerminal.ts +699 -0
  284. package/src/web/src/index.css +55 -0
  285. package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +1158 -0
  286. package/src/web/src/lib/crypto/frames.ts +205 -0
  287. package/src/web/src/lib/crypto/handshake.ts +396 -0
  288. package/src/web/src/lib/crypto/identity.ts +128 -0
  289. package/src/web/src/lib/crypto/keyexchange.ts +246 -0
  290. package/src/web/src/lib/crypto/relay-signing.ts +53 -0
  291. package/src/web/src/lib/invite.ts +58 -0
  292. package/src/web/src/lib/storage/identity-store.ts +94 -0
  293. package/src/web/src/main.tsx +10 -0
  294. package/src/web/src/types/identity.ts +45 -0
  295. package/src/web/tsconfig.app.json +28 -0
  296. package/src/web/tsconfig.json +7 -0
  297. package/src/web/tsconfig.node.json +26 -0
  298. package/src/web/vite.config.ts +31 -0
  299. package/todo-security.md +92 -0
  300. package/tsconfig.json +23 -0
  301. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
  302. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
  303. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
  304. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
  305. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
  306. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
  307. package/worker/bun.lock +237 -0
  308. package/worker/package.json +22 -0
  309. package/worker/schema.sql +96 -0
  310. package/worker/src/handlers/auth.ts +451 -0
  311. package/worker/src/handlers/subdomains.ts +376 -0
  312. package/worker/src/handlers/user.ts +98 -0
  313. package/worker/src/index.ts +70 -0
  314. package/worker/src/middleware/auth.ts +152 -0
  315. package/worker/src/services/cloudflare.ts +609 -0
  316. package/worker/src/types.ts +96 -0
  317. package/worker/tsconfig.json +15 -0
  318. package/worker/wrangler.toml +26 -0
@@ -0,0 +1,1816 @@
1
+ /**
2
+ * TUI Application v2 - Using Shared Components
3
+ *
4
+ * Clean implementation using shared hooks and components:
5
+ * - useFlow for modal system
6
+ * - useMachineList for machine selection
7
+ * - useSpacesBrowser for workspace browsing
8
+ * - useProjectList for project selection
9
+ */
10
+
11
+ import { createCliRenderer } from '@opentui/core';
12
+ import { createRoot, useKeyboard } from '@opentui/react';
13
+ import { useState, useEffect, useCallback, useReducer } from 'react';
14
+
15
+ // Terminal component
16
+ import { Terminal, useTerminalSession } from './components/Terminal.js';
17
+ import type { Session } from '../lib/tmux-lite/protocol.js';
18
+ import { listSessions, createSession, ensureServer, killSession } from '../lib/tmux-lite/cli.js';
19
+ import { getSessionSocketPath } from '../lib/tmux-lite/protocol.js';
20
+
21
+ // Shared components and hooks
22
+ import {
23
+ useFlow,
24
+ useMachineList,
25
+ useSpacesBrowser,
26
+ useProjectList,
27
+ getDefaultShortcuts,
28
+ isFlowInput,
29
+ isFlowConfirmTyped,
30
+ type MachineInfo,
31
+ type ProjectInfo,
32
+ } from '../shared/components/index.js';
33
+ import { FlowTUI } from '../shared/components/Flow.tui.js';
34
+ import { MachineListTUI } from '../shared/components/MachineList.tui.js';
35
+ import { SpacesBrowserTUI } from '../shared/components/SpacesBrowser.tui.js';
36
+ import { ProjectListTUI } from '../shared/components/ProjectList.tui.js';
37
+ import { InboxTUI } from '../shared/components/Inbox.tui.js';
38
+ import { useInbox } from '../shared/components/Inbox.js';
39
+ import { clearInbox, markInboxRead } from '../lib/tmux-lite/cli.js';
40
+
41
+ // Local state and config
42
+ import {
43
+ loadProjects,
44
+ loadWorkspaces,
45
+ loadInbox,
46
+ buildTree,
47
+ type ProjectState,
48
+ type WorkspaceState,
49
+ } from './state.js';
50
+ import { useDaemonStatus, formatUptime, formatRelayStatus } from './hooks/useDaemonStatus.js';
51
+ import {
52
+ setCurrentProject,
53
+ readProjectConfig,
54
+ getProjectBaseDir,
55
+ getProjectWorkspacesDir,
56
+ createProject,
57
+ projectExists,
58
+ updateProjectConfig,
59
+ } from '../core/config.js';
60
+ import { removeWorkspace, removeProject } from '../commands/remove.js';
61
+
62
+ // Git and workspace creation
63
+ import { listRemoteBranches, createWorktree, checkRemoteBranch, getDefaultBranch } from '../core/git.js';
64
+ import { fetchUnstartedIssues } from '../core/linear.js';
65
+ import { generateMarkdown } from '../utils/markdown.js';
66
+ import { sanitizeForFileSystem, generateWorkspaceName, isValidWorkspaceName, extractRepoName } from '../utils/sanitize.js';
67
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
68
+ import { join } from 'path';
69
+ import type { LinearIssue } from '../types/workspace.js';
70
+
71
+ // Project creation
72
+ import { listAllRepos, cloneRepository } from '../core/github.js';
73
+ import { detectBundleInRepo, loadBundleFromPath, copyBundleScripts } from '../core/bundle.js';
74
+ import { setProjectSecret } from '../utils/secrets.js';
75
+ import type { OnboardingStep } from '../types/bundle.js';
76
+ import { exec } from 'child_process';
77
+ import { promisify } from 'util';
78
+
79
+ const execAsync = promisify(exec);
80
+
81
+ // TUI hooks
82
+ import { useRemoteMachines, type RelayConfig } from './hooks/useRemoteMachines.js';
83
+
84
+ // Types
85
+ import type { InboxItem } from '../lib/tmux-lite/cli.js';
86
+
87
+ // ============================================================================
88
+ // Workspace Flow Types (Custom State Machine)
89
+ // ============================================================================
90
+
91
+ /** Available workspace creation sources */
92
+ type WorkspaceSource = 'branch' | 'linear' | 'manual';
93
+
94
+ /** Workspace flow states - explicit state machine */
95
+ type WorkspaceFlowState =
96
+ | { type: 'closed' }
97
+ | { type: 'source-select'; selectedIndex: number; options: Array<{ label: string; description: string; value: WorkspaceSource }> }
98
+ | { type: 'loading'; title: string; message: string }
99
+ | { type: 'branch-select'; branches: string[]; selectedIndex: number }
100
+ | { type: 'linear-select'; issues: LinearIssue[]; selectedIndex: number }
101
+ | { type: 'manual-input'; inputValue: string; error: string | null }
102
+ | { type: 'creating'; workspaceName: string };
103
+
104
+ /** Project flow states - explicit state machine for project creation */
105
+ type ProjectFlowState =
106
+ | { type: 'closed' }
107
+ | { type: 'loading-repos' }
108
+ | { type: 'repo-select'; repos: string[]; selectedIndex: number }
109
+ | { type: 'cloning'; repo: string }
110
+ | { type: 'onboarding';
111
+ repo: string;
112
+ projectName: string;
113
+ baseBranch: string;
114
+ bundleDir: string;
115
+ bundleName: string;
116
+ steps: OnboardingStep[];
117
+ currentStep: number;
118
+ collectedValues: Record<string, string>;
119
+ collectedSecretKeys: string[];
120
+ inputValue: string;
121
+ confirmStatus?: 'checking' | 'found' | 'missing' | null;
122
+ }
123
+ | { type: 'creating'; projectName: string };
124
+
125
+ // ============================================================================
126
+ // Constants
127
+ // ============================================================================
128
+
129
+ const COLORS = {
130
+ border: '#555555',
131
+ borderFocused: '#00AAFF',
132
+ text: '#FFFFFF',
133
+ textDim: '#888888',
134
+ selected: '#00AAFF',
135
+ title: '#00FF88',
136
+ statusBar: '#333333',
137
+ loading: '#FFAA00',
138
+ error: '#FF4444',
139
+ // ASCII art gradient
140
+ gradient1: '#00FFFF',
141
+ gradient2: '#00DDFF',
142
+ gradient3: '#00BBFF',
143
+ gradient4: '#0099FF',
144
+ gradient5: '#0077FF',
145
+ gradient6: '#0055FF',
146
+ asciiBox: '#444466',
147
+ subtitle: '#888899',
148
+ };
149
+
150
+ // ASCII art header
151
+ const ASCII_LINES = [
152
+ { text: '╔══════════════════════════════════════════════════════════════╗', color: COLORS.asciiBox },
153
+ { text: '║ ║', color: COLORS.asciiBox },
154
+ { text: '║ ███████╗██████╗ █████╗ ██████╗███████╗███████╗ ║', color: COLORS.gradient1 },
155
+ { text: '║ ██╔════╝██╔══██╗██╔══██╗██╔════╝██╔════╝██╔════╝ ║', color: COLORS.gradient2 },
156
+ { text: '║ ███████╗██████╔╝███████║██║ █████╗ ███████╗ ║', color: COLORS.gradient3 },
157
+ { text: '║ ╚════██║██╔═══╝ ██╔══██║██║ ██╔══╝ ╚════██║ ║', color: COLORS.gradient4 },
158
+ { text: '║ ███████║██║ ██║ ██║╚██████╗███████╗███████║ ║', color: COLORS.gradient5 },
159
+ { text: '║ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝╚══════╝ ║', color: COLORS.gradient6 },
160
+ { text: '║ ║', color: COLORS.asciiBox },
161
+ { text: '║ worktree manager ║', color: COLORS.subtitle },
162
+ { text: '║ ║', color: COLORS.asciiBox },
163
+ { text: '╚══════════════════════════════════════════════════════════════╝', color: COLORS.asciiBox },
164
+ ];
165
+
166
+ // ============================================================================
167
+ // App State
168
+ // ============================================================================
169
+
170
+ type AppView = 'machines' | 'projects' | 'workspaces' | 'terminal' | 'inbox';
171
+ type PanelFocus = 'projects' | 'workspaces';
172
+
173
+ interface AppState {
174
+ view: AppView;
175
+ panelFocus: PanelFocus;
176
+ selectedMachine: MachineInfo | null;
177
+ projects: ProjectState[];
178
+ workspaces: WorkspaceState[];
179
+ currentProject: string | null;
180
+ inbox: InboxItem[];
181
+ unreadCount: number;
182
+ isLoading: boolean;
183
+ error: string | null;
184
+ attachedSession: Session | null;
185
+ }
186
+
187
+ type AppAction =
188
+ | { type: 'SET_VIEW'; view: AppView }
189
+ | { type: 'SET_PANEL_FOCUS'; focus: PanelFocus }
190
+ | { type: 'SET_MACHINE'; machine: MachineInfo | null }
191
+ | { type: 'SET_PROJECTS'; projects: ProjectState[] }
192
+ | { type: 'SET_WORKSPACES'; workspaces: WorkspaceState[] }
193
+ | { type: 'SET_CURRENT_PROJECT'; project: string | null }
194
+ | { type: 'SET_INBOX'; inbox: InboxItem[]; unreadCount: number }
195
+ | { type: 'SET_LOADING'; loading: boolean }
196
+ | { type: 'SET_ERROR'; error: string | null }
197
+ | { type: 'SWITCH_PANEL' }
198
+ | { type: 'SET_ATTACHED_SESSION'; session: Session | null };
199
+
200
+ function appReducer(state: AppState, action: AppAction): AppState {
201
+ switch (action.type) {
202
+ case 'SET_VIEW':
203
+ return { ...state, view: action.view };
204
+ case 'SET_PANEL_FOCUS':
205
+ return { ...state, panelFocus: action.focus };
206
+ case 'SET_MACHINE':
207
+ return { ...state, selectedMachine: action.machine };
208
+ case 'SET_PROJECTS':
209
+ return { ...state, projects: action.projects };
210
+ case 'SET_WORKSPACES':
211
+ return { ...state, workspaces: action.workspaces };
212
+ case 'SET_CURRENT_PROJECT':
213
+ return { ...state, currentProject: action.project };
214
+ case 'SET_INBOX':
215
+ return { ...state, inbox: action.inbox, unreadCount: action.unreadCount };
216
+ case 'SET_LOADING':
217
+ return { ...state, isLoading: action.loading };
218
+ case 'SET_ERROR':
219
+ return { ...state, error: action.error };
220
+ case 'SWITCH_PANEL':
221
+ return { ...state, panelFocus: state.panelFocus === 'projects' ? 'workspaces' : 'projects' };
222
+ case 'SET_ATTACHED_SESSION':
223
+ return { ...state, attachedSession: action.session };
224
+ default:
225
+ return state;
226
+ }
227
+ }
228
+
229
+ // ============================================================================
230
+ // Props
231
+ // ============================================================================
232
+
233
+ export interface AppProps {
234
+ relayConfig?: RelayConfig;
235
+ onQuit?: () => void;
236
+ }
237
+
238
+ // ============================================================================
239
+ // Main App Component
240
+ // ============================================================================
241
+
242
+ function App({ relayConfig, onQuit }: AppProps) {
243
+ const isRemoteMode = !!relayConfig;
244
+
245
+ // Force re-render counter for resize
246
+ const [, forceUpdate] = useState(0);
247
+
248
+ // Handle terminal resize
249
+ useEffect(() => {
250
+ const handleResize = () => {
251
+ // Force React to re-render by updating state
252
+ forceUpdate(n => n + 1);
253
+ };
254
+
255
+ process.on('SIGWINCH', handleResize);
256
+ return () => {
257
+ process.removeListener('SIGWINCH', handleResize);
258
+ };
259
+ }, []);
260
+
261
+ // App state
262
+ const [state, dispatch] = useReducer(appReducer, {
263
+ view: isRemoteMode ? 'machines' : 'projects',
264
+ panelFocus: 'projects',
265
+ selectedMachine: null,
266
+ projects: [],
267
+ workspaces: [],
268
+ currentProject: null,
269
+ inbox: [],
270
+ unreadCount: 0,
271
+ isLoading: true,
272
+ error: null,
273
+ attachedSession: null,
274
+ });
275
+
276
+ // Shared Flow hook (for non-workspace flows)
277
+ const flow = useFlow({
278
+ onError: (error) => dispatch({ type: 'SET_ERROR', error: error.message }),
279
+ });
280
+
281
+ // Workspace creation flow (custom state machine)
282
+ const [workspaceFlow, setWorkspaceFlow] = useState<WorkspaceFlowState>({ type: 'closed' });
283
+
284
+ // Project creation flow (custom state machine)
285
+ const [projectFlow, setProjectFlow] = useState<ProjectFlowState>({ type: 'closed' });
286
+
287
+ // Remote machines hook
288
+ const remoteMachines = useRemoteMachines({
289
+ relayConfig,
290
+ onError: (error) => dispatch({ type: 'SET_ERROR', error: error.message }),
291
+ });
292
+
293
+ // Daemon status hook (tmux-lite and serve)
294
+ const { status: daemonStatus } = useDaemonStatus({ pollInterval: 5000 });
295
+
296
+ // ========== Data Loading ==========
297
+
298
+ // Load projects
299
+ const refreshProjects = useCallback(async () => {
300
+ const projects = loadProjects();
301
+ dispatch({ type: 'SET_PROJECTS', projects });
302
+
303
+ // Set current project if not set
304
+ const current = projects.find(p => p.isCurrent);
305
+ if (current) {
306
+ dispatch({ type: 'SET_CURRENT_PROJECT', project: current.name });
307
+ }
308
+ }, []);
309
+
310
+ // Load workspaces for current project
311
+ const refreshWorkspaces = useCallback(async () => {
312
+ if (!state.currentProject) return;
313
+ const workspaces = await loadWorkspaces(state.currentProject);
314
+ dispatch({ type: 'SET_WORKSPACES', workspaces });
315
+ }, [state.currentProject]);
316
+
317
+ // Load inbox
318
+ const refreshInbox = useCallback(async () => {
319
+ const { items, unreadCount } = await loadInbox();
320
+ dispatch({ type: 'SET_INBOX', inbox: items, unreadCount });
321
+ }, []);
322
+
323
+ // Initial load
324
+ useEffect(() => {
325
+ const load = async () => {
326
+ dispatch({ type: 'SET_LOADING', loading: true });
327
+ try {
328
+ await refreshProjects();
329
+ // Load inbox in background (don't block initial render)
330
+ refreshInbox().catch(() => {});
331
+ } catch (err) {
332
+ dispatch({ type: 'SET_ERROR', error: err instanceof Error ? err.message : 'Failed to load' });
333
+ } finally {
334
+ dispatch({ type: 'SET_LOADING', loading: false });
335
+ }
336
+ };
337
+ load();
338
+ }, []);
339
+
340
+ // Load workspaces when project changes
341
+ useEffect(() => {
342
+ if (state.currentProject) {
343
+ refreshWorkspaces();
344
+ }
345
+ }, [state.currentProject]);
346
+
347
+ // ========== Action Handlers ==========
348
+
349
+ // Select a project
350
+ const handleSelectProject = useCallback((project: ProjectInfo) => {
351
+ setCurrentProject(project.name);
352
+ dispatch({ type: 'SET_CURRENT_PROJECT', project: project.name });
353
+ dispatch({ type: 'SET_PANEL_FOCUS', focus: 'workspaces' });
354
+ }, []);
355
+
356
+ // Create new project - show confirmation
357
+ const handleCreateProject = useCallback(() => {
358
+ flow.showMessage({
359
+ title: 'New Project',
360
+ message: 'Use "gssh add project" from command line to add a new project.',
361
+ variant: 'info',
362
+ });
363
+ }, [flow]);
364
+
365
+ // Delete project - show typed confirmation
366
+ const handleDeleteProject = useCallback((project: ProjectInfo) => {
367
+ flow.showConfirmTyped({
368
+ title: 'Delete Project',
369
+ message: `Are you sure you want to delete project "${project.name}"?`,
370
+ confirmText: project.name,
371
+ warning: 'This will delete all workspaces in this project!',
372
+ onConfirm: async () => {
373
+ flow.showLoading({ title: 'Deleting', message: 'Removing project...' });
374
+ await removeProject(project.name, { force: false });
375
+ flow.close();
376
+ await refreshProjects();
377
+ },
378
+ });
379
+ }, [flow, refreshProjects]);
380
+
381
+ // Attach to session using embedded terminal
382
+ const handleAttachSession = useCallback(async (params: { sessionId?: string; workspaceId?: string }) => {
383
+ await ensureServer();
384
+
385
+ if (params.sessionId) {
386
+ // Get fresh session list from server to verify session still exists
387
+ const liveSessions = await listSessions();
388
+ const liveSession = liveSessions.find(s => s.id === params.sessionId);
389
+
390
+ if (!liveSession) {
391
+ // Session no longer exists on server - refresh workspaces to update UI
392
+ await refreshWorkspaces();
393
+ dispatch({ type: 'SET_ERROR', error: 'Session no longer exists. The session list has been refreshed.' });
394
+ return;
395
+ }
396
+
397
+ // Use the live session info from the server (not stale state)
398
+ const sessionInfo: Session = liveSession;
399
+
400
+ if (sessionInfo.attached) {
401
+ // Show steal confirmation
402
+ flow.showConfirm({
403
+ title: 'Session In Use',
404
+ message: `This session is currently attached. Steal it?`,
405
+ variant: 'warning',
406
+ confirmLabel: 'Steal',
407
+ onConfirm: async () => {
408
+ // Attach using embedded terminal (will kick the other client)
409
+ dispatch({ type: 'SET_ATTACHED_SESSION', session: sessionInfo });
410
+ dispatch({ type: 'SET_VIEW', view: 'terminal' });
411
+ },
412
+ });
413
+ return;
414
+ }
415
+
416
+ // Attach using embedded terminal
417
+ dispatch({ type: 'SET_ATTACHED_SESSION', session: sessionInfo });
418
+ dispatch({ type: 'SET_VIEW', view: 'terminal' });
419
+ } else if (params.workspaceId) {
420
+ // Create new session
421
+ const workspace = state.workspaces.find(w => w.name === params.workspaceId);
422
+ if (workspace) {
423
+ flow.showInput({
424
+ title: 'New Session',
425
+ label: 'Session name (optional):',
426
+ placeholder: 'Leave empty for auto-generated name',
427
+ onSubmit: async (name) => {
428
+ const sessionName = name || `${state.currentProject}:${workspace.name}:${Date.now()}`;
429
+ try {
430
+ const session = await createSession(sessionName, workspace.path);
431
+ // Attach to newly created session
432
+ dispatch({ type: 'SET_ATTACHED_SESSION', session });
433
+ dispatch({ type: 'SET_VIEW', view: 'terminal' });
434
+ } catch (err) {
435
+ dispatch({ type: 'SET_ERROR', error: err instanceof Error ? err.message : 'Failed to create session' });
436
+ }
437
+ },
438
+ });
439
+ }
440
+ }
441
+ }, [state.workspaces, state.currentProject, flow, refreshWorkspaces]);
442
+
443
+ // Handle terminal detach
444
+ const handleTerminalDetach = useCallback(async () => {
445
+ dispatch({ type: 'SET_ATTACHED_SESSION', session: null });
446
+ dispatch({ type: 'SET_VIEW', view: 'projects' });
447
+ await refreshWorkspaces();
448
+ }, [refreshWorkspaces]);
449
+
450
+ // Handle terminal exit
451
+ const handleTerminalExit = useCallback(async (code: number) => {
452
+ dispatch({ type: 'SET_ATTACHED_SESSION', session: null });
453
+ dispatch({ type: 'SET_VIEW', view: 'projects' });
454
+ await refreshWorkspaces();
455
+ // Optionally show exit notification
456
+ if (code !== 0) {
457
+ flow.showMessage({
458
+ title: 'Session Exited',
459
+ message: `Process exited with code ${code}`,
460
+ variant: 'info',
461
+ });
462
+ }
463
+ }, [refreshWorkspaces, flow]);
464
+
465
+ // Handle terminal kicked
466
+ const handleTerminalKicked = useCallback(async () => {
467
+ dispatch({ type: 'SET_ATTACHED_SESSION', session: null });
468
+ dispatch({ type: 'SET_VIEW', view: 'projects' });
469
+ await refreshWorkspaces();
470
+ flow.showMessage({
471
+ title: 'Session Taken Over',
472
+ message: 'Another client took over this session',
473
+ variant: 'warning',
474
+ });
475
+ }, [refreshWorkspaces, flow]);
476
+
477
+ // Handle terminal error
478
+ const handleTerminalError = useCallback(async (error: string) => {
479
+ dispatch({ type: 'SET_ATTACHED_SESSION', session: null });
480
+ dispatch({ type: 'SET_VIEW', view: 'projects' });
481
+ dispatch({ type: 'SET_ERROR', error });
482
+ await refreshWorkspaces();
483
+ }, [refreshWorkspaces]);
484
+
485
+ // Delete workspace
486
+ const handleDeleteWorkspace = useCallback((workspace: WorkspaceState) => {
487
+ flow.showConfirmTyped({
488
+ title: 'Delete Workspace',
489
+ message: `Are you sure you want to delete workspace "${workspace.name}"?`,
490
+ confirmText: workspace.name,
491
+ warning: workspace.sessions.length > 0 ? `This will kill ${workspace.sessions.length} active session(s)!` : undefined,
492
+ onConfirm: async () => {
493
+ if (!state.currentProject) return;
494
+ flow.showLoading({ title: 'Deleting', message: 'Removing workspace...' });
495
+ await removeWorkspace(workspace.name, { force: false });
496
+ flow.close();
497
+ await refreshWorkspaces();
498
+ },
499
+ });
500
+ }, [flow, state.currentProject, refreshWorkspaces]);
501
+
502
+ // Delete session
503
+ const handleDeleteSession = useCallback((sessionId: string, sessionName: string) => {
504
+ flow.showConfirm({
505
+ title: 'Kill Session',
506
+ message: `Kill session "${sessionName}"?`,
507
+ variant: 'warning',
508
+ confirmLabel: 'Kill',
509
+ onConfirm: async () => {
510
+ try {
511
+ await killSession(sessionId);
512
+ // Small delay to let server process the kill before refreshing
513
+ await new Promise(resolve => setTimeout(resolve, 100));
514
+ await refreshWorkspaces();
515
+ } catch (err) {
516
+ dispatch({ type: 'SET_ERROR', error: err instanceof Error ? err.message : 'Failed to kill session' });
517
+ }
518
+ },
519
+ });
520
+ }, [flow, refreshWorkspaces]);
521
+
522
+ // ========== Workspace Creation (Custom State Machine) ==========
523
+
524
+ // Core function to create workspace and open session
525
+ const createWorkspaceAndOpenSession = useCallback(async (
526
+ workspaceName: string,
527
+ branchName: string,
528
+ existsRemotely: boolean,
529
+ linearIssue?: LinearIssue
530
+ ) => {
531
+ if (!state.currentProject) return;
532
+
533
+ try {
534
+ const baseDir = getProjectBaseDir(state.currentProject);
535
+ const workspacesDir = getProjectWorkspacesDir(state.currentProject);
536
+ const workspacePath = join(workspacesDir, workspaceName);
537
+ const config = readProjectConfig(state.currentProject);
538
+
539
+ // Check if workspace already exists
540
+ if (existsSync(workspacePath)) {
541
+ setWorkspaceFlow({ type: 'closed' });
542
+ dispatch({ type: 'SET_ERROR', error: `Workspace "${workspaceName}" already exists` });
543
+ return;
544
+ }
545
+
546
+ setWorkspaceFlow({ type: 'creating', workspaceName });
547
+
548
+ // Create worktree
549
+ await createWorktree(baseDir, workspacePath, branchName, config.baseBranch, existsRemotely);
550
+
551
+ // Save Linear issue if present
552
+ if (linearIssue && config.linearApiKey) {
553
+ const promptDir = join(workspacePath, '.prompt');
554
+ mkdirSync(promptDir, { recursive: true });
555
+ const markdown = await generateMarkdown(linearIssue, promptDir, config.linearApiKey);
556
+ writeFileSync(join(promptDir, 'issue.md'), markdown, 'utf-8');
557
+ }
558
+
559
+ setWorkspaceFlow({ type: 'closed' });
560
+ await refreshWorkspaces();
561
+
562
+ // Create session and attach
563
+ await ensureServer();
564
+ const session = await createSession(`${state.currentProject}:${workspaceName}:${Date.now()}`, workspacePath);
565
+ dispatch({ type: 'SET_ATTACHED_SESSION', session });
566
+ dispatch({ type: 'SET_VIEW', view: 'terminal' });
567
+ } catch (err) {
568
+ setWorkspaceFlow({ type: 'closed' });
569
+ dispatch({ type: 'SET_ERROR', error: err instanceof Error ? err.message : 'Failed to create workspace' });
570
+ }
571
+ }, [state.currentProject, refreshWorkspaces]);
572
+
573
+ // Handle selecting a source (branch/linear/manual)
574
+ const handleSourceSelect = useCallback(async (source: WorkspaceSource) => {
575
+ if (!state.currentProject) return;
576
+
577
+ if (source === 'branch') {
578
+ setWorkspaceFlow({ type: 'loading', title: 'Loading', message: 'Fetching remote branches...' });
579
+
580
+ try {
581
+ const baseDir = getProjectBaseDir(state.currentProject);
582
+ const config = readProjectConfig(state.currentProject);
583
+ const allBranches = await listRemoteBranches(baseDir);
584
+ const branches = allBranches.filter(b => b !== config.baseBranch);
585
+
586
+ if (branches.length === 0) {
587
+ flow.showMessage({
588
+ title: 'No Branches',
589
+ message: `No remote branches found (excluding base branch ${config.baseBranch})`,
590
+ variant: 'warning',
591
+ });
592
+ setWorkspaceFlow({ type: 'closed' });
593
+ return;
594
+ }
595
+
596
+ setWorkspaceFlow({ type: 'branch-select', branches, selectedIndex: 0 });
597
+ } catch (err) {
598
+ flow.showMessage({
599
+ title: 'Error',
600
+ message: err instanceof Error ? err.message : 'Failed to fetch branches',
601
+ variant: 'error',
602
+ });
603
+ setWorkspaceFlow({ type: 'closed' });
604
+ }
605
+ } else if (source === 'linear') {
606
+ const config = readProjectConfig(state.currentProject);
607
+ if (!config.linearApiKey) {
608
+ flow.showMessage({
609
+ title: 'Not Configured',
610
+ message: 'Linear is not configured for this project',
611
+ variant: 'warning',
612
+ });
613
+ setWorkspaceFlow({ type: 'closed' });
614
+ return;
615
+ }
616
+
617
+ setWorkspaceFlow({ type: 'loading', title: 'Loading', message: 'Fetching Linear issues...' });
618
+
619
+ try {
620
+ const issues = await fetchUnstartedIssues(config.linearApiKey, config.linearTeamKey);
621
+
622
+ if (issues.length === 0) {
623
+ flow.showMessage({
624
+ title: 'No Issues',
625
+ message: 'No unstarted Linear issues found',
626
+ variant: 'warning',
627
+ });
628
+ setWorkspaceFlow({ type: 'closed' });
629
+ return;
630
+ }
631
+
632
+ setWorkspaceFlow({ type: 'linear-select', issues, selectedIndex: 0 });
633
+ } catch (err) {
634
+ flow.showMessage({
635
+ title: 'Error',
636
+ message: err instanceof Error ? err.message : 'Failed to fetch Linear issues',
637
+ variant: 'error',
638
+ });
639
+ setWorkspaceFlow({ type: 'closed' });
640
+ }
641
+ } else if (source === 'manual') {
642
+ setWorkspaceFlow({ type: 'manual-input', inputValue: '', error: null });
643
+ }
644
+ }, [state.currentProject, flow]);
645
+
646
+ // Handle branch selection
647
+ const handleBranchSelect = useCallback(async (branch: string) => {
648
+ const workspaceName = sanitizeForFileSystem(branch);
649
+ await createWorkspaceAndOpenSession(workspaceName, branch, true);
650
+ }, [createWorkspaceAndOpenSession]);
651
+
652
+ // Handle Linear issue selection
653
+ const handleLinearSelect = useCallback(async (issue: LinearIssue) => {
654
+ const workspaceName = generateWorkspaceName(issue.identifier, issue.title);
655
+ await createWorkspaceAndOpenSession(workspaceName, workspaceName, false, issue);
656
+ }, [createWorkspaceAndOpenSession]);
657
+
658
+ // Handle manual name submission
659
+ const handleManualSubmit = useCallback(async (name: string) => {
660
+ if (!name || name.trim().length === 0) {
661
+ setWorkspaceFlow(prev => prev.type === 'manual-input' ? { ...prev, error: 'Workspace name is required' } : prev);
662
+ return;
663
+ }
664
+ if (!isValidWorkspaceName(name)) {
665
+ setWorkspaceFlow(prev => prev.type === 'manual-input' ? { ...prev, error: 'Use only letters, numbers, hyphens, underscores' } : prev);
666
+ return;
667
+ }
668
+ await createWorkspaceAndOpenSession(name, name, false);
669
+ }, [createWorkspaceAndOpenSession]);
670
+
671
+ // Main handler to start new workspace flow
672
+ const handleNewWorkspaceFlow = useCallback(() => {
673
+ if (!state.currentProject) return;
674
+
675
+ const config = readProjectConfig(state.currentProject);
676
+ const hasLinear = !!config.linearApiKey;
677
+
678
+ const options: Array<{ label: string; description: string; value: WorkspaceSource }> = [
679
+ { label: 'GitHub Branch', description: 'Create from existing remote branch', value: 'branch' },
680
+ ...(hasLinear ? [{ label: 'Linear Issue', description: 'Create from Linear ticket', value: 'linear' as const }] : []),
681
+ { label: 'Manual Name', description: 'Enter a custom workspace name', value: 'manual' },
682
+ ];
683
+
684
+ setWorkspaceFlow({ type: 'source-select', selectedIndex: 0, options });
685
+ }, [state.currentProject]);
686
+
687
+ // ========== Project Creation (Custom State Machine) ==========
688
+
689
+ // Finalize project creation
690
+ const finalizeProject = useCallback(async (projectName: string) => {
691
+ setCurrentProject(projectName);
692
+ await refreshProjects();
693
+ setProjectFlow({ type: 'closed' });
694
+ flow.showMessage({
695
+ title: 'Project Created',
696
+ message: `Project "${projectName}" has been created successfully!`,
697
+ variant: 'success',
698
+ });
699
+ }, [refreshProjects, flow]);
700
+
701
+ // Check if a command exists (for onboarding confirm steps)
702
+ const checkCommand = useCallback(async (command: string): Promise<boolean> => {
703
+ try {
704
+ await execAsync(command);
705
+ return true;
706
+ } catch {
707
+ return false;
708
+ }
709
+ }, []);
710
+
711
+ // Advance to the next onboarding step
712
+ const advanceOnboardingStep = useCallback(async () => {
713
+ if (projectFlow.type !== 'onboarding') return;
714
+
715
+ const currentStep = projectFlow.steps[projectFlow.currentStep];
716
+ const newValues = { ...projectFlow.collectedValues };
717
+ const newSecretKeys = [...projectFlow.collectedSecretKeys];
718
+
719
+ // Save current step's value if applicable
720
+ if (currentStep && (currentStep.type === 'input' || currentStep.type === 'secret')) {
721
+ const stepWithKey = currentStep as { configKey: string; defaultValue?: string };
722
+ const value = projectFlow.inputValue.trim() || stepWithKey.defaultValue || '';
723
+
724
+ if (currentStep.type === 'secret') {
725
+ await setProjectSecret(projectFlow.projectName, stepWithKey.configKey, value);
726
+ newSecretKeys.push(stepWithKey.configKey);
727
+ } else {
728
+ newValues[stepWithKey.configKey] = value;
729
+ }
730
+ }
731
+
732
+ const nextStepIndex = projectFlow.currentStep + 1;
733
+
734
+ if (nextStepIndex >= projectFlow.steps.length) {
735
+ // All steps done - create the project
736
+ setProjectFlow({ type: 'creating', projectName: projectFlow.projectName });
737
+
738
+ try {
739
+ createProject(projectFlow.projectName, projectFlow.repo, projectFlow.baseBranch);
740
+ copyBundleScripts(projectFlow.bundleDir, projectFlow.projectName);
741
+
742
+ // Update project config with bundle values
743
+ if (Object.keys(newValues).length > 0 || newSecretKeys.length > 0) {
744
+ updateProjectConfig(projectFlow.projectName, {
745
+ bundleValues: Object.keys(newValues).length > 0 ? newValues : undefined,
746
+ bundleSecretKeys: newSecretKeys.length > 0 ? newSecretKeys : undefined,
747
+ appliedBundle: {
748
+ name: projectFlow.bundleName,
749
+ version: '1.0',
750
+ source: projectFlow.bundleDir,
751
+ appliedAt: new Date().toISOString(),
752
+ },
753
+ });
754
+ }
755
+
756
+ await finalizeProject(projectFlow.projectName);
757
+ } catch (err) {
758
+ flow.showMessage({
759
+ title: 'Error',
760
+ message: err instanceof Error ? err.message : 'Failed to create project',
761
+ variant: 'error',
762
+ });
763
+ setProjectFlow({ type: 'closed' });
764
+ }
765
+ } else {
766
+ // Move to next step
767
+ const nextStep = projectFlow.steps[nextStepIndex];
768
+
769
+ // If it's a confirm step with checkCommand, start checking
770
+ if (nextStep.type === 'confirm' && (nextStep as { checkCommand?: string }).checkCommand) {
771
+ setProjectFlow({
772
+ ...projectFlow,
773
+ currentStep: nextStepIndex,
774
+ collectedValues: newValues,
775
+ collectedSecretKeys: newSecretKeys,
776
+ inputValue: '',
777
+ confirmStatus: 'checking',
778
+ });
779
+
780
+ const found = await checkCommand((nextStep as { checkCommand: string }).checkCommand);
781
+ setProjectFlow(prev =>
782
+ prev.type === 'onboarding'
783
+ ? { ...prev, confirmStatus: found ? 'found' : 'missing' }
784
+ : prev
785
+ );
786
+ } else {
787
+ const defaultValue = (nextStep as { defaultValue?: string }).defaultValue || '';
788
+ setProjectFlow({
789
+ ...projectFlow,
790
+ currentStep: nextStepIndex,
791
+ collectedValues: newValues,
792
+ collectedSecretKeys: newSecretKeys,
793
+ inputValue: defaultValue,
794
+ confirmStatus: null,
795
+ });
796
+ }
797
+ }
798
+ }, [projectFlow, checkCommand, finalizeProject, flow]);
799
+
800
+ // Handle repository selection
801
+ const handleSelectRepo = useCallback(async (repo: string) => {
802
+ const projectName = extractRepoName(repo);
803
+
804
+ // Check if project already exists
805
+ if (projectExists(projectName)) {
806
+ flow.showMessage({
807
+ title: 'Project Exists',
808
+ message: `Project "${projectName}" already exists`,
809
+ variant: 'error',
810
+ });
811
+ setProjectFlow({ type: 'closed' });
812
+ return;
813
+ }
814
+
815
+ setProjectFlow({ type: 'cloning', repo });
816
+
817
+ try {
818
+ const baseDir = getProjectBaseDir(projectName);
819
+ await cloneRepository(repo, baseDir);
820
+ const baseBranch = await getDefaultBranch(baseDir);
821
+
822
+ // Check for bundle
823
+ const bundleDir = detectBundleInRepo(baseDir);
824
+ if (bundleDir) {
825
+ const loadedBundle = loadBundleFromPath(bundleDir);
826
+
827
+ if (loadedBundle.bundle.onboarding && loadedBundle.bundle.onboarding.length > 0) {
828
+ // Start onboarding flow
829
+ const firstStep = loadedBundle.bundle.onboarding[0];
830
+ const initialInputValue = (firstStep as { defaultValue?: string }).defaultValue || '';
831
+
832
+ // If first step is a confirm with checkCommand, start checking
833
+ if (firstStep.type === 'confirm' && (firstStep as { checkCommand?: string }).checkCommand) {
834
+ setProjectFlow({
835
+ type: 'onboarding',
836
+ repo,
837
+ projectName,
838
+ baseBranch,
839
+ bundleDir: loadedBundle.bundleDir,
840
+ bundleName: loadedBundle.bundle.name,
841
+ steps: loadedBundle.bundle.onboarding,
842
+ currentStep: 0,
843
+ collectedValues: {},
844
+ collectedSecretKeys: [],
845
+ inputValue: '',
846
+ confirmStatus: 'checking',
847
+ });
848
+
849
+ const found = await checkCommand((firstStep as { checkCommand: string }).checkCommand);
850
+ setProjectFlow(prev =>
851
+ prev.type === 'onboarding'
852
+ ? { ...prev, confirmStatus: found ? 'found' : 'missing' }
853
+ : prev
854
+ );
855
+ } else {
856
+ setProjectFlow({
857
+ type: 'onboarding',
858
+ repo,
859
+ projectName,
860
+ baseBranch,
861
+ bundleDir: loadedBundle.bundleDir,
862
+ bundleName: loadedBundle.bundle.name,
863
+ steps: loadedBundle.bundle.onboarding,
864
+ currentStep: 0,
865
+ collectedValues: {},
866
+ collectedSecretKeys: [],
867
+ inputValue: initialInputValue,
868
+ confirmStatus: null,
869
+ });
870
+ }
871
+ return;
872
+ }
873
+
874
+ // No onboarding, just copy scripts and create project
875
+ createProject(projectName, repo, baseBranch);
876
+ copyBundleScripts(bundleDir, projectName);
877
+ updateProjectConfig(projectName, {
878
+ appliedBundle: {
879
+ name: loadedBundle.bundle.name,
880
+ version: loadedBundle.bundle.version,
881
+ source: loadedBundle.source,
882
+ appliedAt: new Date().toISOString(),
883
+ },
884
+ });
885
+ } else {
886
+ // No bundle, just create project
887
+ createProject(projectName, repo, baseBranch);
888
+ }
889
+
890
+ await finalizeProject(projectName);
891
+ } catch (err) {
892
+ flow.showMessage({
893
+ title: 'Error',
894
+ message: err instanceof Error ? err.message : 'Failed to clone repository',
895
+ variant: 'error',
896
+ });
897
+ setProjectFlow({ type: 'closed' });
898
+ }
899
+ }, [flow, checkCommand, finalizeProject]);
900
+
901
+ // Start new project flow
902
+ const handleNewProjectFlow = useCallback(async () => {
903
+ setProjectFlow({ type: 'loading-repos' });
904
+
905
+ try {
906
+ const repos = await listAllRepos();
907
+
908
+ if (repos.length === 0) {
909
+ flow.showMessage({
910
+ title: 'No Repositories',
911
+ message: 'No GitHub repositories found. Make sure you are logged in with `gh auth login`.',
912
+ variant: 'warning',
913
+ });
914
+ setProjectFlow({ type: 'closed' });
915
+ return;
916
+ }
917
+
918
+ setProjectFlow({ type: 'repo-select', repos, selectedIndex: 0 });
919
+ } catch (err) {
920
+ flow.showMessage({
921
+ title: 'Error',
922
+ message: err instanceof Error ? err.message : 'Failed to fetch repositories',
923
+ variant: 'error',
924
+ });
925
+ setProjectFlow({ type: 'closed' });
926
+ }
927
+ }, [flow]);
928
+
929
+ // ========== Shared Hooks ==========
930
+
931
+ // Convert projects to ProjectInfo format
932
+ const projectInfos: ProjectInfo[] = state.projects.map(p => ({
933
+ name: p.name,
934
+ repository: p.repository,
935
+ workspaceCount: p.workspaceCount,
936
+ isCurrent: p.isCurrent,
937
+ }));
938
+
939
+ // Project list hook
940
+ const projectListProps = useProjectList({
941
+ projects: projectInfos,
942
+ onSelect: handleSelectProject,
943
+ onCreateNew: handleCreateProject,
944
+ onDelete: handleDeleteProject,
945
+ onRefresh: refreshProjects,
946
+ });
947
+
948
+ // Convert workspaces to shared format
949
+ const workspaceInfos = state.workspaces.map(w => ({
950
+ id: w.name,
951
+ name: w.name,
952
+ path: w.path,
953
+ projectName: state.currentProject || '',
954
+ branch: w.branch,
955
+ sessionCount: w.sessions.length,
956
+ isStale: w.isStale,
957
+ }));
958
+
959
+ // Extract sessions
960
+ const sessionInfos = state.workspaces.flatMap(w =>
961
+ w.sessions.map(s => ({
962
+ id: s.id,
963
+ name: s.name,
964
+ workspaceId: w.name,
965
+ attached: s.attached,
966
+ createdAt: s.createdAt,
967
+ processTitle: s.processTitle,
968
+ }))
969
+ );
970
+
971
+ // Spaces browser hook
972
+ const spacesBrowserProps = useSpacesBrowser({
973
+ workspaces: workspaceInfos,
974
+ sessions: sessionInfos,
975
+ onRequestSessions: () => {}, // Sessions already loaded
976
+ onAttachSession: handleAttachSession,
977
+ onRefresh: refreshWorkspaces,
978
+ onBack: () => dispatch({ type: 'SET_PANEL_FOCUS', focus: 'projects' }),
979
+ onCreateWorkspace: handleNewWorkspaceFlow,
980
+ machineName: state.currentProject || undefined,
981
+ showProjectHeaders: false, // Don't show project headers since we're already filtered
982
+ });
983
+
984
+ // Machine list hook (for remote mode)
985
+ const machineListProps = useMachineList({
986
+ machines: remoteMachines.machines,
987
+ status: remoteMachines.status,
988
+ error: remoteMachines.error,
989
+ publicKey: undefined,
990
+ onConnect: async (machine) => {
991
+ dispatch({ type: 'SET_MACHINE', machine });
992
+ dispatch({ type: 'SET_VIEW', view: 'projects' });
993
+ },
994
+ onRefresh: remoteMachines.refreshMachines,
995
+ });
996
+
997
+ // Inbox hook
998
+ const inboxProps = useInbox({
999
+ items: state.inbox,
1000
+ unreadCount: state.unreadCount,
1001
+ onClearItem: async (id) => {
1002
+ await clearInbox(id);
1003
+ await refreshInbox();
1004
+ },
1005
+ onClearAll: async () => {
1006
+ await clearInbox();
1007
+ await refreshInbox();
1008
+ },
1009
+ onMarkRead: async (id) => {
1010
+ await markInboxRead(id);
1011
+ await refreshInbox();
1012
+ },
1013
+ onAttachSession: async (sessionId) => {
1014
+ dispatch({ type: 'SET_VIEW', view: 'projects' });
1015
+ await handleAttachSession({ sessionId });
1016
+ },
1017
+ onClose: () => {
1018
+ dispatch({ type: 'SET_VIEW', view: 'projects' });
1019
+ },
1020
+ });
1021
+
1022
+ // ========== Keyboard Handlers ==========
1023
+
1024
+ useKeyboard(async (key) => {
1025
+ // Don't handle keys when in terminal view (Terminal component handles input)
1026
+ if (state.view === 'terminal') {
1027
+ return;
1028
+ }
1029
+
1030
+ // Handle project creation flow (custom state machine)
1031
+ if (projectFlow.type !== 'closed') {
1032
+ if (key.name === 'escape') {
1033
+ setProjectFlow({ type: 'closed' });
1034
+ return;
1035
+ }
1036
+
1037
+ if (projectFlow.type === 'repo-select') {
1038
+ if (key.name === 'up' || key.raw === 'k') {
1039
+ setProjectFlow({
1040
+ ...projectFlow,
1041
+ selectedIndex: Math.max(0, projectFlow.selectedIndex - 1),
1042
+ });
1043
+ } else if (key.name === 'down' || key.raw === 'j') {
1044
+ setProjectFlow({
1045
+ ...projectFlow,
1046
+ selectedIndex: Math.min(projectFlow.repos.length - 1, projectFlow.selectedIndex + 1),
1047
+ });
1048
+ } else if (key.name === 'return') {
1049
+ const repo = projectFlow.repos[projectFlow.selectedIndex];
1050
+ if (repo) {
1051
+ await handleSelectRepo(repo);
1052
+ }
1053
+ }
1054
+ return;
1055
+ }
1056
+
1057
+ if (projectFlow.type === 'onboarding') {
1058
+ const step = projectFlow.steps[projectFlow.currentStep];
1059
+
1060
+ if (step.type === 'info' || step.type === 'confirm') {
1061
+ // For info/confirm steps, Enter to continue (if not checking)
1062
+ if (key.name === 'return' && projectFlow.confirmStatus !== 'checking') {
1063
+ await advanceOnboardingStep();
1064
+ }
1065
+ return;
1066
+ }
1067
+
1068
+ if (step.type === 'input' || step.type === 'secret') {
1069
+ if (key.name === 'return') {
1070
+ await advanceOnboardingStep();
1071
+ } else if (key.name === 'backspace') {
1072
+ setProjectFlow({
1073
+ ...projectFlow,
1074
+ inputValue: projectFlow.inputValue.slice(0, -1),
1075
+ });
1076
+ } else if (key.raw && key.raw.length === 1 && !key.ctrl && !key.meta) {
1077
+ setProjectFlow({
1078
+ ...projectFlow,
1079
+ inputValue: projectFlow.inputValue + key.raw,
1080
+ });
1081
+ }
1082
+ return;
1083
+ }
1084
+ return;
1085
+ }
1086
+
1087
+ // For loading/cloning/creating states, just wait (escape to cancel handled above)
1088
+ return;
1089
+ }
1090
+
1091
+ // Handle workspace creation flow (custom state machine)
1092
+ if (workspaceFlow.type !== 'closed') {
1093
+ if (key.name === 'escape') {
1094
+ setWorkspaceFlow({ type: 'closed' });
1095
+ return;
1096
+ }
1097
+
1098
+ if (workspaceFlow.type === 'source-select') {
1099
+ if (key.name === 'up' || key.raw === 'k') {
1100
+ setWorkspaceFlow({
1101
+ ...workspaceFlow,
1102
+ selectedIndex: Math.max(0, workspaceFlow.selectedIndex - 1),
1103
+ });
1104
+ } else if (key.name === 'down' || key.raw === 'j') {
1105
+ setWorkspaceFlow({
1106
+ ...workspaceFlow,
1107
+ selectedIndex: Math.min(workspaceFlow.options.length - 1, workspaceFlow.selectedIndex + 1),
1108
+ });
1109
+ } else if (key.name === 'return') {
1110
+ const selected = workspaceFlow.options[workspaceFlow.selectedIndex];
1111
+ if (selected) {
1112
+ await handleSourceSelect(selected.value);
1113
+ }
1114
+ }
1115
+ return;
1116
+ }
1117
+
1118
+ if (workspaceFlow.type === 'branch-select') {
1119
+ if (key.name === 'up' || key.raw === 'k') {
1120
+ setWorkspaceFlow({
1121
+ ...workspaceFlow,
1122
+ selectedIndex: Math.max(0, workspaceFlow.selectedIndex - 1),
1123
+ });
1124
+ } else if (key.name === 'down' || key.raw === 'j') {
1125
+ setWorkspaceFlow({
1126
+ ...workspaceFlow,
1127
+ selectedIndex: Math.min(workspaceFlow.branches.length - 1, workspaceFlow.selectedIndex + 1),
1128
+ });
1129
+ } else if (key.name === 'return') {
1130
+ const branch = workspaceFlow.branches[workspaceFlow.selectedIndex];
1131
+ if (branch) {
1132
+ await handleBranchSelect(branch);
1133
+ }
1134
+ }
1135
+ return;
1136
+ }
1137
+
1138
+ if (workspaceFlow.type === 'linear-select') {
1139
+ if (key.name === 'up' || key.raw === 'k') {
1140
+ setWorkspaceFlow({
1141
+ ...workspaceFlow,
1142
+ selectedIndex: Math.max(0, workspaceFlow.selectedIndex - 1),
1143
+ });
1144
+ } else if (key.name === 'down' || key.raw === 'j') {
1145
+ setWorkspaceFlow({
1146
+ ...workspaceFlow,
1147
+ selectedIndex: Math.min(workspaceFlow.issues.length - 1, workspaceFlow.selectedIndex + 1),
1148
+ });
1149
+ } else if (key.name === 'return') {
1150
+ const issue = workspaceFlow.issues[workspaceFlow.selectedIndex];
1151
+ if (issue) {
1152
+ await handleLinearSelect(issue);
1153
+ }
1154
+ }
1155
+ return;
1156
+ }
1157
+
1158
+ if (workspaceFlow.type === 'manual-input') {
1159
+ if (key.name === 'return') {
1160
+ await handleManualSubmit(workspaceFlow.inputValue);
1161
+ } else if (key.name === 'backspace') {
1162
+ setWorkspaceFlow({
1163
+ ...workspaceFlow,
1164
+ inputValue: workspaceFlow.inputValue.slice(0, -1),
1165
+ error: null,
1166
+ });
1167
+ } else if (key.raw && key.raw.length === 1 && !key.ctrl && !key.meta) {
1168
+ setWorkspaceFlow({
1169
+ ...workspaceFlow,
1170
+ inputValue: workspaceFlow.inputValue + key.raw,
1171
+ error: null,
1172
+ });
1173
+ }
1174
+ return;
1175
+ }
1176
+
1177
+ // For loading/creating states, just wait (escape to cancel handled above)
1178
+ return;
1179
+ }
1180
+
1181
+ // Don't handle keys when flow is open
1182
+ if (flow.isOpen) {
1183
+ // Handle confirm modal with y/n shortcuts
1184
+ if (flow.flow.type === 'confirm') {
1185
+ if (key.raw === 'y' || key.name === 'return') {
1186
+ await flow.handleConfirm();
1187
+ } else if (key.raw === 'n' || key.name === 'escape') {
1188
+ flow.handleCancel();
1189
+ }
1190
+ return;
1191
+ }
1192
+
1193
+ // Handle other modals
1194
+ if (key.name === 'escape') {
1195
+ flow.handleCancel();
1196
+ } else if (key.name === 'return') {
1197
+ await flow.handleConfirm();
1198
+ } else if (key.name === 'up' || key.raw === 'k') {
1199
+ flow.moveUp();
1200
+ } else if (key.name === 'down' || key.raw === 'j') {
1201
+ flow.moveDown();
1202
+ } else if (key.raw && isFlowInput(flow.flow)) {
1203
+ // Handle text input (now properly typed)
1204
+ if (key.name === 'backspace') {
1205
+ const current = flow.flow.inputValue || '';
1206
+ flow.handleInput(current.slice(0, -1));
1207
+ } else if (key.raw.length === 1 && !key.ctrl && !key.meta) {
1208
+ const current = flow.flow.inputValue || '';
1209
+ flow.handleInput(current + key.raw);
1210
+ }
1211
+ } else if (key.raw && isFlowConfirmTyped(flow.flow)) {
1212
+ // Handle typed confirmation input (now properly typed)
1213
+ if (key.name === 'backspace') {
1214
+ const current = flow.flow.inputValue || '';
1215
+ flow.handleInput(current.slice(0, -1));
1216
+ } else if (key.raw.length === 1 && !key.ctrl && !key.meta) {
1217
+ const current = flow.flow.inputValue || '';
1218
+ flow.handleInput(current + key.raw);
1219
+ }
1220
+ }
1221
+ return;
1222
+ }
1223
+
1224
+ // Global shortcuts
1225
+ if (key.raw === '?' || (key.shift && key.raw === '?')) {
1226
+ flow.showHelp(getDefaultShortcuts());
1227
+ return;
1228
+ }
1229
+
1230
+ if (key.raw === 'q' || (key.ctrl && key.raw === 'c')) {
1231
+ onQuit?.();
1232
+ return;
1233
+ }
1234
+
1235
+ // Inbox shortcut (global) - open full-screen inbox view
1236
+ if (key.raw === 'i') {
1237
+ dispatch({ type: 'SET_VIEW', view: 'inbox' });
1238
+ return;
1239
+ }
1240
+
1241
+ // Inbox view keyboard handling
1242
+ if (state.view === 'inbox') {
1243
+ if (key.name === 'escape') {
1244
+ if (inboxProps.isViewingThread) {
1245
+ inboxProps.closeThread();
1246
+ } else {
1247
+ inboxProps.close();
1248
+ }
1249
+ } else if (key.name === 'up' || key.raw === 'k') {
1250
+ inboxProps.moveUp();
1251
+ } else if (key.name === 'down' || key.raw === 'j') {
1252
+ inboxProps.moveDown();
1253
+ } else if (key.name === 'return') {
1254
+ await inboxProps.openThread();
1255
+ } else if (key.raw === 'x') {
1256
+ if (inboxProps.isViewingThread) {
1257
+ await inboxProps.deleteThread();
1258
+ } else {
1259
+ await inboxProps.deleteSelected();
1260
+ }
1261
+ } else if (key.raw === 'c') {
1262
+ await inboxProps.clearAll();
1263
+ } else if (key.raw === 'a' && inboxProps.isViewingThread) {
1264
+ await inboxProps.attachToSession();
1265
+ }
1266
+ return;
1267
+ }
1268
+
1269
+ // View-specific shortcuts
1270
+ if (state.view === 'machines') {
1271
+ if (key.name === 'up' || key.raw === 'k') {
1272
+ machineListProps.moveUp();
1273
+ } else if (key.name === 'down' || key.raw === 'j') {
1274
+ machineListProps.moveDown();
1275
+ } else if (key.name === 'return') {
1276
+ machineListProps.connectSelected();
1277
+ } else if (key.raw === 'r') {
1278
+ machineListProps.refresh();
1279
+ }
1280
+ return;
1281
+ }
1282
+
1283
+ if (state.view === 'projects') {
1284
+ // Panel switching
1285
+ if (key.name === 'tab') {
1286
+ dispatch({ type: 'SWITCH_PANEL' });
1287
+ return;
1288
+ }
1289
+
1290
+ if (state.panelFocus === 'projects') {
1291
+ if (key.name === 'up' || key.raw === 'k') {
1292
+ projectListProps.moveUp();
1293
+ } else if (key.name === 'down' || key.raw === 'j') {
1294
+ projectListProps.moveDown();
1295
+ } else if (key.name === 'return') {
1296
+ projectListProps.selectProject();
1297
+ } else if (key.raw === 'n') {
1298
+ // In projects panel, 'n' creates new project
1299
+ await handleNewProjectFlow();
1300
+ } else if (key.raw === 'd') {
1301
+ projectListProps.deleteSelected();
1302
+ } else if (key.raw === 'r') {
1303
+ projectListProps.refresh();
1304
+ }
1305
+ } else {
1306
+ // Workspaces panel
1307
+ if (key.name === 'up' || key.raw === 'k') {
1308
+ spacesBrowserProps.moveUp();
1309
+ } else if (key.name === 'down' || key.raw === 'j') {
1310
+ spacesBrowserProps.moveDown();
1311
+ } else if (key.name === 'return') {
1312
+ // Let the hook handle it:
1313
+ // - workspace: toggle expand/collapse
1314
+ // - session: attach via onAttachSession
1315
+ // - new-session: create via onAttachSession
1316
+ spacesBrowserProps.activateSelected();
1317
+ } else if (key.raw === 'n') {
1318
+ // In workspaces panel, 'n' always creates new workspace
1319
+ // Sessions are created via expand (Enter) → "+ New session" (Enter)
1320
+ handleNewWorkspaceFlow();
1321
+ } else if (key.raw === 'd') {
1322
+ // Delete workspace
1323
+ const selected = spacesBrowserProps.selectedItem;
1324
+ if (selected?.type === 'workspace') {
1325
+ const workspace = state.workspaces.find(w => w.name === selected.workspace.id);
1326
+ if (workspace) {
1327
+ handleDeleteWorkspace(workspace);
1328
+ }
1329
+ }
1330
+ } else if (key.raw === 'x') {
1331
+ // Kill session
1332
+ const selected = spacesBrowserProps.selectedItem;
1333
+ if (selected?.type === 'session') {
1334
+ handleDeleteSession(selected.session.id, selected.session.name);
1335
+ }
1336
+ } else if (key.raw === 'r') {
1337
+ spacesBrowserProps.refresh();
1338
+ } else if (key.name === 'escape') {
1339
+ dispatch({ type: 'SET_PANEL_FOCUS', focus: 'projects' });
1340
+ }
1341
+ }
1342
+ return;
1343
+ }
1344
+ });
1345
+
1346
+ // ========== Render ==========
1347
+
1348
+ // Loading state
1349
+ if (state.isLoading) {
1350
+ return (
1351
+ <box flexDirection="column" flexGrow={1} justifyContent="center" alignItems="center">
1352
+ <text fg={COLORS.loading}>Loading...</text>
1353
+ </box>
1354
+ );
1355
+ }
1356
+
1357
+ // Error state
1358
+ if (state.error) {
1359
+ return (
1360
+ <box flexDirection="column" flexGrow={1} justifyContent="center" alignItems="center">
1361
+ <text fg={COLORS.error}>Error: {state.error}</text>
1362
+ <text fg={COLORS.textDim} marginTop={1}>Press 'q' to quit</text>
1363
+ </box>
1364
+ );
1365
+ }
1366
+
1367
+ // Machine list view (remote mode)
1368
+ if (state.view === 'machines') {
1369
+ return (
1370
+ <box flexDirection="column" flexGrow={1}>
1371
+ <MachineListTUI {...machineListProps} focused={true} />
1372
+ <StatusBar hint="[↑↓] Navigate [Enter] Connect [r] Refresh [?] Help [q] Quit" />
1373
+ <FlowTUI flow={flow} />
1374
+ </box>
1375
+ );
1376
+ }
1377
+
1378
+ // Terminal view (attached to session)
1379
+ if (state.view === 'terminal' && state.attachedSession) {
1380
+ return (
1381
+ <Terminal
1382
+ session={state.attachedSession}
1383
+ onDetach={handleTerminalDetach}
1384
+ onExit={handleTerminalExit}
1385
+ onKicked={handleTerminalKicked}
1386
+ onError={handleTerminalError}
1387
+ />
1388
+ );
1389
+ }
1390
+
1391
+ // Inbox view (full-screen)
1392
+ if (state.view === 'inbox') {
1393
+ return <InboxTUI {...inboxProps} focused={true} />;
1394
+ }
1395
+
1396
+ // Main project/workspace view
1397
+ return (
1398
+ <box flexDirection="column" flexGrow={1} width="100%">
1399
+ {/* ASCII Art Header */}
1400
+ <box flexDirection="row" width="100%" height={13}>
1401
+ {/* ASCII art on left - fixed width */}
1402
+ <box flexDirection="column" alignItems="flex-start" paddingLeft={1} width={68}>
1403
+ {ASCII_LINES.map((line, i) => (
1404
+ <text key={i} fg={line.color}>{line.text}</text>
1405
+ ))}
1406
+ </box>
1407
+
1408
+ {/* Status & Notifications on right */}
1409
+ <box flexDirection="column" flexGrow={1} paddingLeft={2} paddingTop={1}>
1410
+ {/* Daemon status line */}
1411
+ <box flexDirection="row" gap={2}>
1412
+ <text fg={daemonStatus.tmux.running ? COLORS.title : COLORS.textDim}>
1413
+ tmux: {daemonStatus.tmux.running ? '●' : '○'} {daemonStatus.tmux.sessions ?? 0} sessions
1414
+ </text>
1415
+ <text fg={daemonStatus.serve.running ? COLORS.title : COLORS.textDim}>
1416
+ relay: {formatRelayStatus(daemonStatus.serve.relayStatus)} {daemonStatus.serve.running ? (daemonStatus.serve.clients ?? 0) + ' clients' : 'off'}
1417
+ </text>
1418
+ </box>
1419
+
1420
+ {/* Uptime info */}
1421
+ {(daemonStatus.tmux.running || daemonStatus.serve.running) && (
1422
+ <text fg={COLORS.textDim}>
1423
+ {daemonStatus.tmux.uptime ? `tmux: ${formatUptime(daemonStatus.tmux.uptime)}` : ''}
1424
+ {daemonStatus.tmux.uptime && daemonStatus.serve.uptime ? ' ' : ''}
1425
+ {daemonStatus.serve.uptime ? `serve: ${formatUptime(daemonStatus.serve.uptime)}` : ''}
1426
+ </text>
1427
+ )}
1428
+
1429
+ {/* Version mismatch warning */}
1430
+ {daemonStatus.versionMismatch && (
1431
+ <text fg={COLORS.error}>⚠ Version mismatch - restart daemons</text>
1432
+ )}
1433
+
1434
+ {/* Notifications */}
1435
+ <box marginTop={1}>
1436
+ {state.unreadCount > 0 ? (
1437
+ <box flexDirection="column">
1438
+ <text fg={COLORS.loading}>{'📥'} {state.unreadCount} notification{state.unreadCount > 1 ? 's' : ''}</text>
1439
+ <text fg={COLORS.textDim}>[i] view inbox</text>
1440
+ </box>
1441
+ ) : (
1442
+ <text fg={COLORS.textDim}>No notifications</text>
1443
+ )}
1444
+ </box>
1445
+ </box>
1446
+ </box>
1447
+
1448
+ {/* Main content - two panel layout */}
1449
+ <box flexDirection="row" flexGrow={1} width="100%" gap={1} paddingLeft={1} paddingRight={1}>
1450
+ <ProjectListTUI {...projectListProps} focused={state.panelFocus === 'projects'} />
1451
+ <SpacesBrowserTUI {...spacesBrowserProps} focused={state.panelFocus === 'workspaces'} />
1452
+ </box>
1453
+
1454
+ {/* Status bar */}
1455
+ <StatusBar
1456
+ hint={state.panelFocus === 'projects'
1457
+ ? '[Tab] Switch [Enter] Select [n] New Project [d] Delete [?] Help [q] Quit'
1458
+ : '[Tab] Switch [Enter] Open/Join [n] New Workspace [d] Delete [x] Kill [?] Help [q] Quit'
1459
+ }
1460
+ />
1461
+
1462
+ {/* Flow modal overlay */}
1463
+ <FlowTUI flow={flow} />
1464
+
1465
+ {/* Workspace creation flow modal */}
1466
+ <WorkspaceFlowModal flow={workspaceFlow} />
1467
+
1468
+ {/* Project creation flow modal */}
1469
+ <ProjectFlowModal flow={projectFlow} />
1470
+ </box>
1471
+ );
1472
+ }
1473
+
1474
+ // ============================================================================
1475
+ // Workspace Flow Modal Component
1476
+ // ============================================================================
1477
+
1478
+ function WorkspaceFlowModal({ flow }: { flow: WorkspaceFlowState }) {
1479
+ if (flow.type === 'closed') {
1480
+ return null;
1481
+ }
1482
+
1483
+ const modalWidth = 60;
1484
+ // Calculate modal height based on content:
1485
+ // - source-select: title + spacer + (options * 2 lines each) + (spacers between) + spacer + hint + border/padding
1486
+ // - branch/linear-select: title + items (scrollable) + hint + border/padding
1487
+ // - manual-input: title + label + input box + error? + hint + border/padding
1488
+ const modalHeight = flow.type === 'manual-input' ? 10 :
1489
+ flow.type === 'loading' || flow.type === 'creating' ? 6 :
1490
+ flow.type === 'source-select' ? 6 + flow.options.length * 3 :
1491
+ flow.type === 'branch-select' ? Math.min(16, 6 + flow.branches.length) :
1492
+ flow.type === 'linear-select' ? Math.min(16, 6 + flow.issues.length) : 10;
1493
+
1494
+ return (
1495
+ <box
1496
+ position="absolute"
1497
+ width="100%"
1498
+ height="100%"
1499
+ justifyContent="center"
1500
+ alignItems="center"
1501
+ >
1502
+ <box
1503
+ flexDirection="column"
1504
+ width={modalWidth}
1505
+ height={modalHeight}
1506
+ borderStyle="rounded"
1507
+ borderColor={COLORS.borderFocused}
1508
+ backgroundColor="#1a1a2e"
1509
+ padding={1}
1510
+ >
1511
+ {/* Loading state */}
1512
+ {flow.type === 'loading' && (
1513
+ <>
1514
+ <text fg={COLORS.title} height={1}>{flow.title}</text>
1515
+ <text fg={COLORS.loading} height={1} marginTop={1}>{flow.message}</text>
1516
+ </>
1517
+ )}
1518
+
1519
+ {/* Creating state */}
1520
+ {flow.type === 'creating' && (
1521
+ <>
1522
+ <text fg={COLORS.title} height={1}>Creating Workspace</text>
1523
+ <text fg={COLORS.loading} height={1} marginTop={1}>Creating {flow.workspaceName}...</text>
1524
+ </>
1525
+ )}
1526
+
1527
+ {/* Source selection */}
1528
+ {flow.type === 'source-select' && (
1529
+ <>
1530
+ <text fg={COLORS.title} height={1}>Create Workspace From</text>
1531
+ <box height={1} />
1532
+ {flow.options.flatMap((opt, i) => [
1533
+ <text key={`${opt.value}-label`} fg={i === flow.selectedIndex ? COLORS.selected : COLORS.text} height={1}>
1534
+ {i === flow.selectedIndex ? '▸ ' : ' '}{opt.label}
1535
+ </text>,
1536
+ <text key={`${opt.value}-desc`} fg={COLORS.textDim} height={1} paddingLeft={4}>{opt.description}</text>,
1537
+ i < flow.options.length - 1 ? <box key={`${opt.value}-spacer`} height={1} /> : null,
1538
+ ].filter(Boolean))}
1539
+ <box height={1} />
1540
+ <text fg={COLORS.textDim} height={1}>[↑↓] Navigate [Enter] Select [Esc] Cancel</text>
1541
+ </>
1542
+ )}
1543
+
1544
+ {/* Branch selection */}
1545
+ {flow.type === 'branch-select' && (
1546
+ <>
1547
+ <text fg={COLORS.title} height={1}>Select Branch</text>
1548
+ <box flexDirection="column" marginTop={1} flexGrow={1} overflow="hidden">
1549
+ {flow.branches.slice(
1550
+ Math.max(0, flow.selectedIndex - 5),
1551
+ Math.max(0, flow.selectedIndex - 5) + 10
1552
+ ).map((branch, i) => {
1553
+ const actualIndex = Math.max(0, flow.selectedIndex - 5) + i;
1554
+ return (
1555
+ <text key={branch} height={1} fg={actualIndex === flow.selectedIndex ? COLORS.selected : COLORS.text}>
1556
+ {actualIndex === flow.selectedIndex ? '▸ ' : ' '}{branch}
1557
+ </text>
1558
+ );
1559
+ })}
1560
+ </box>
1561
+ <text fg={COLORS.textDim} height={1}>[↑↓] Navigate [Enter] Select [Esc] Cancel</text>
1562
+ </>
1563
+ )}
1564
+
1565
+ {/* Linear issue selection */}
1566
+ {flow.type === 'linear-select' && (
1567
+ <>
1568
+ <text fg={COLORS.title} height={1}>Select Linear Issue</text>
1569
+ <box flexDirection="column" marginTop={1} flexGrow={1} overflow="hidden">
1570
+ {flow.issues.slice(
1571
+ Math.max(0, flow.selectedIndex - 5),
1572
+ Math.max(0, flow.selectedIndex - 5) + 10
1573
+ ).map((issue, i) => {
1574
+ const actualIndex = Math.max(0, flow.selectedIndex - 5) + i;
1575
+ const label = `${issue.identifier} - ${issue.title.slice(0, 40)}${issue.title.length > 40 ? '...' : ''}`;
1576
+ return (
1577
+ <text key={issue.id} height={1} fg={actualIndex === flow.selectedIndex ? COLORS.selected : COLORS.text}>
1578
+ {actualIndex === flow.selectedIndex ? '▸ ' : ' '}{label}
1579
+ </text>
1580
+ );
1581
+ })}
1582
+ </box>
1583
+ <text fg={COLORS.textDim} height={1}>[↑↓] Navigate [Enter] Select [Esc] Cancel</text>
1584
+ </>
1585
+ )}
1586
+
1587
+ {/* Manual input */}
1588
+ {flow.type === 'manual-input' && (
1589
+ <>
1590
+ <text fg={COLORS.title} height={1}>New Workspace</text>
1591
+ <text fg={COLORS.text} height={1} marginTop={1}>Enter workspace name:</text>
1592
+ <box
1593
+ marginTop={1}
1594
+ borderStyle="rounded"
1595
+ borderColor={COLORS.border}
1596
+ padding={0}
1597
+ width="100%"
1598
+ >
1599
+ <text fg={COLORS.text} height={1}>{flow.inputValue || ' '}_</text>
1600
+ </box>
1601
+ {flow.error && <text fg={COLORS.error} height={1} marginTop={1}>{flow.error}</text>}
1602
+ <text fg={COLORS.textDim} height={1} marginTop={1}>[Enter] Create [Esc] Cancel</text>
1603
+ </>
1604
+ )}
1605
+ </box>
1606
+ </box>
1607
+ );
1608
+ }
1609
+
1610
+ // ============================================================================
1611
+ // Project Flow Modal Component
1612
+ // ============================================================================
1613
+
1614
+ function ProjectFlowModal({ flow }: { flow: ProjectFlowState }) {
1615
+ if (flow.type === 'closed') {
1616
+ return null;
1617
+ }
1618
+
1619
+ const modalWidth = 70;
1620
+ const modalHeight = flow.type === 'repo-select' ? 18 :
1621
+ flow.type === 'onboarding' ? 14 :
1622
+ 8;
1623
+
1624
+ return (
1625
+ <box
1626
+ position="absolute"
1627
+ width="100%"
1628
+ height="100%"
1629
+ justifyContent="center"
1630
+ alignItems="center"
1631
+ >
1632
+ <box
1633
+ flexDirection="column"
1634
+ width={modalWidth}
1635
+ height={modalHeight}
1636
+ borderStyle="rounded"
1637
+ borderColor={COLORS.borderFocused}
1638
+ backgroundColor="#1a1a2e"
1639
+ padding={1}
1640
+ >
1641
+ {/* Loading repos state */}
1642
+ {flow.type === 'loading-repos' && (
1643
+ <>
1644
+ <text fg={COLORS.title} height={1}>New Project</text>
1645
+ <text fg={COLORS.loading} height={1} marginTop={1}>Fetching repositories...</text>
1646
+ </>
1647
+ )}
1648
+
1649
+ {/* Repository selection */}
1650
+ {flow.type === 'repo-select' && (
1651
+ <>
1652
+ <text fg={COLORS.title} height={1}>Select Repository</text>
1653
+ <box flexDirection="column" marginTop={1} flexGrow={1} overflow="hidden">
1654
+ {flow.repos.slice(
1655
+ Math.max(0, flow.selectedIndex - 5),
1656
+ Math.max(0, flow.selectedIndex - 5) + 10
1657
+ ).map((repo, i) => {
1658
+ const actualIndex = Math.max(0, flow.selectedIndex - 5) + i;
1659
+ return (
1660
+ <text key={repo} height={1} fg={actualIndex === flow.selectedIndex ? COLORS.selected : COLORS.text}>
1661
+ {actualIndex === flow.selectedIndex ? '▸ ' : ' '}{repo}
1662
+ </text>
1663
+ );
1664
+ })}
1665
+ </box>
1666
+ <text fg={COLORS.textDim} height={1}>[↑↓] Navigate [Enter] Select [Esc] Cancel</text>
1667
+ </>
1668
+ )}
1669
+
1670
+ {/* Cloning state */}
1671
+ {flow.type === 'cloning' && (
1672
+ <>
1673
+ <text fg={COLORS.title} height={1}>Cloning Repository</text>
1674
+ <text fg={COLORS.loading} height={1} marginTop={1}>Cloning {flow.repo}...</text>
1675
+ </>
1676
+ )}
1677
+
1678
+ {/* Onboarding steps */}
1679
+ {flow.type === 'onboarding' && (() => {
1680
+ const step = flow.steps[flow.currentStep];
1681
+ if (!step) return null;
1682
+
1683
+ return (
1684
+ <>
1685
+ <text fg={COLORS.title} height={1}>
1686
+ {flow.bundleName} Setup ({flow.currentStep + 1}/{flow.steps.length})
1687
+ </text>
1688
+ <text fg={COLORS.selected} height={1} marginTop={1}>{step.title}</text>
1689
+ {step.description && (
1690
+ <text fg={COLORS.textDim} height={1} marginTop={1}>{step.description}</text>
1691
+ )}
1692
+
1693
+ {/* Info step */}
1694
+ {step.type === 'info' && (
1695
+ <text fg={COLORS.text} height={1} marginTop={1}>Press Enter to continue</text>
1696
+ )}
1697
+
1698
+ {/* Confirm step */}
1699
+ {step.type === 'confirm' && (
1700
+ <box flexDirection="column" marginTop={1}>
1701
+ {flow.confirmStatus === 'checking' && (
1702
+ <text fg={COLORS.loading} height={1}>⏳ Checking...</text>
1703
+ )}
1704
+ {flow.confirmStatus === 'found' && (
1705
+ <text fg={COLORS.title} height={1}>✅ Found</text>
1706
+ )}
1707
+ {flow.confirmStatus === 'missing' && (
1708
+ <>
1709
+ <text fg={COLORS.error} height={1}>❌ Not found</text>
1710
+ {(step as { installUrl?: string }).installUrl && (
1711
+ <text fg={COLORS.selected} height={1} marginTop={1}>
1712
+ Install: {(step as { installUrl: string }).installUrl}
1713
+ </text>
1714
+ )}
1715
+ </>
1716
+ )}
1717
+ {flow.confirmStatus !== 'checking' && (
1718
+ <text fg={COLORS.text} height={1} marginTop={1}>Press Enter to continue</text>
1719
+ )}
1720
+ </box>
1721
+ )}
1722
+
1723
+ {/* Input step */}
1724
+ {step.type === 'input' && (
1725
+ <box flexDirection="column" marginTop={1}>
1726
+ <box
1727
+ borderStyle="rounded"
1728
+ borderColor={COLORS.border}
1729
+ padding={0}
1730
+ width="100%"
1731
+ >
1732
+ <text fg={COLORS.text} height={1}>{flow.inputValue || ' '}_</text>
1733
+ </box>
1734
+ </box>
1735
+ )}
1736
+
1737
+ {/* Secret step */}
1738
+ {step.type === 'secret' && (
1739
+ <box flexDirection="column" marginTop={1}>
1740
+ <box
1741
+ borderStyle="rounded"
1742
+ borderColor={COLORS.border}
1743
+ padding={0}
1744
+ width="100%"
1745
+ >
1746
+ <text fg={COLORS.text} height={1}>{'•'.repeat(flow.inputValue.length) || ' '}_</text>
1747
+ </box>
1748
+ <text fg={COLORS.textDim} height={1} marginTop={1}>Value will be stored securely in OS keychain</text>
1749
+ </box>
1750
+ )}
1751
+
1752
+ <text fg={COLORS.textDim} height={1} marginTop={1}>
1753
+ [Enter] {flow.currentStep === flow.steps.length - 1 ? 'Finish' : 'Next'} [Esc] Cancel
1754
+ </text>
1755
+ </>
1756
+ );
1757
+ })()}
1758
+
1759
+ {/* Creating state */}
1760
+ {flow.type === 'creating' && (
1761
+ <>
1762
+ <text fg={COLORS.title} height={1}>Creating Project</text>
1763
+ <text fg={COLORS.loading} height={1} marginTop={1}>Setting up {flow.projectName}...</text>
1764
+ </>
1765
+ )}
1766
+ </box>
1767
+ </box>
1768
+ );
1769
+ }
1770
+
1771
+ // ============================================================================
1772
+ // Status Bar Component
1773
+ // ============================================================================
1774
+
1775
+ function StatusBar({ hint }: { hint: string }) {
1776
+ return (
1777
+ <box width="100%" height={1} backgroundColor={COLORS.statusBar}>
1778
+ <text fg={COLORS.textDim} paddingLeft={1}>{hint}</text>
1779
+ </box>
1780
+ );
1781
+ }
1782
+
1783
+ // ============================================================================
1784
+ // Entry Point
1785
+ // ============================================================================
1786
+
1787
+ /** @deprecated Use RelayConfig instead */
1788
+ export type TUIRelayConfig = RelayConfig;
1789
+
1790
+ export async function launchTUI(relayConfig?: RelayConfig): Promise<void> {
1791
+ const renderer = await createCliRenderer({
1792
+ exitOnCtrlC: false,
1793
+ targetFps: 30,
1794
+ });
1795
+ const root = createRoot(renderer);
1796
+
1797
+ // Clean exit handler
1798
+ const handleQuit = () => {
1799
+ renderer.destroy();
1800
+ process.exit(0);
1801
+ };
1802
+
1803
+ // Handle SIGINT
1804
+ process.on('SIGINT', handleQuit);
1805
+
1806
+ // Cleanup on exit
1807
+ process.on('exit', () => {
1808
+ // Reset terminal state
1809
+ process.stdout.write('\x1b[?25h'); // Show cursor
1810
+ process.stdout.write('\x1b[?1049l'); // Exit alternate screen
1811
+ process.stdout.write('\x1b[0m'); // Reset colors
1812
+ });
1813
+
1814
+ root.render(<App relayConfig={relayConfig} onQuit={handleQuit} />);
1815
+ renderer.start();
1816
+ }