gitspace 0.2.0-rc.2 → 0.2.0-rc.21

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 (316) hide show
  1. package/README.md +68 -38
  2. package/package.json +36 -25
  3. package/.claude/settings.local.json +0 -21
  4. package/.gitspace/bundle.json +0 -50
  5. package/.gitspace/select/01-status.sh +0 -40
  6. package/.gitspace/setup/01-install-deps.sh +0 -12
  7. package/.gitspace/setup/02-typecheck.sh +0 -16
  8. package/AGENTS.md +0 -439
  9. package/CLAUDE.md +0 -1
  10. package/bun.lock +0 -647
  11. package/docs/CONNECTION.md +0 -623
  12. package/docs/GATEWAY-WORKER.md +0 -319
  13. package/docs/GETTING-STARTED.md +0 -448
  14. package/docs/GITSPACE-PLATFORM.md +0 -1819
  15. package/docs/INFRASTRUCTURE.md +0 -1347
  16. package/docs/PROTOCOL.md +0 -619
  17. package/docs/QUICKSTART.md +0 -174
  18. package/docs/RELAY.md +0 -327
  19. package/docs/REMOTE-DESIGN.md +0 -549
  20. package/docs/ROADMAP.md +0 -564
  21. package/docs/SITE_DOCS_FIGMA_MAKE.md +0 -1167
  22. package/docs/STACK-DESIGN.md +0 -588
  23. package/docs/UNIFIED_ARCHITECTURE.md +0 -292
  24. package/experiments/pty-benchmark.ts +0 -148
  25. package/experiments/pty-latency.ts +0 -100
  26. package/experiments/router/client.ts +0 -199
  27. package/experiments/router/protocol.ts +0 -74
  28. package/experiments/router/router.ts +0 -217
  29. package/experiments/router/session.ts +0 -180
  30. package/experiments/router/test.ts +0 -133
  31. package/experiments/socket-bandwidth.ts +0 -77
  32. package/homebrew/gitspace.rb +0 -45
  33. package/landing-page/ATTRIBUTIONS.md +0 -3
  34. package/landing-page/README.md +0 -11
  35. package/landing-page/bun.lock +0 -801
  36. package/landing-page/guidelines/Guidelines.md +0 -61
  37. package/landing-page/index.html +0 -37
  38. package/landing-page/package.json +0 -90
  39. package/landing-page/postcss.config.mjs +0 -15
  40. package/landing-page/public/_redirects +0 -1
  41. package/landing-page/public/favicon.png +0 -0
  42. package/landing-page/src/app/App.tsx +0 -53
  43. package/landing-page/src/app/components/figma/ImageWithFallback.tsx +0 -27
  44. package/landing-page/src/app/components/ui/accordion.tsx +0 -66
  45. package/landing-page/src/app/components/ui/alert-dialog.tsx +0 -157
  46. package/landing-page/src/app/components/ui/alert.tsx +0 -66
  47. package/landing-page/src/app/components/ui/aspect-ratio.tsx +0 -11
  48. package/landing-page/src/app/components/ui/avatar.tsx +0 -53
  49. package/landing-page/src/app/components/ui/badge.tsx +0 -46
  50. package/landing-page/src/app/components/ui/breadcrumb.tsx +0 -109
  51. package/landing-page/src/app/components/ui/button.tsx +0 -57
  52. package/landing-page/src/app/components/ui/calendar.tsx +0 -75
  53. package/landing-page/src/app/components/ui/card.tsx +0 -92
  54. package/landing-page/src/app/components/ui/carousel.tsx +0 -241
  55. package/landing-page/src/app/components/ui/chart.tsx +0 -353
  56. package/landing-page/src/app/components/ui/checkbox.tsx +0 -32
  57. package/landing-page/src/app/components/ui/collapsible.tsx +0 -33
  58. package/landing-page/src/app/components/ui/command.tsx +0 -177
  59. package/landing-page/src/app/components/ui/context-menu.tsx +0 -252
  60. package/landing-page/src/app/components/ui/dialog.tsx +0 -135
  61. package/landing-page/src/app/components/ui/drawer.tsx +0 -132
  62. package/landing-page/src/app/components/ui/dropdown-menu.tsx +0 -257
  63. package/landing-page/src/app/components/ui/form.tsx +0 -168
  64. package/landing-page/src/app/components/ui/hover-card.tsx +0 -44
  65. package/landing-page/src/app/components/ui/input-otp.tsx +0 -77
  66. package/landing-page/src/app/components/ui/input.tsx +0 -21
  67. package/landing-page/src/app/components/ui/label.tsx +0 -24
  68. package/landing-page/src/app/components/ui/menubar.tsx +0 -276
  69. package/landing-page/src/app/components/ui/navigation-menu.tsx +0 -168
  70. package/landing-page/src/app/components/ui/pagination.tsx +0 -127
  71. package/landing-page/src/app/components/ui/popover.tsx +0 -48
  72. package/landing-page/src/app/components/ui/progress.tsx +0 -31
  73. package/landing-page/src/app/components/ui/radio-group.tsx +0 -45
  74. package/landing-page/src/app/components/ui/resizable.tsx +0 -56
  75. package/landing-page/src/app/components/ui/scroll-area.tsx +0 -58
  76. package/landing-page/src/app/components/ui/select.tsx +0 -189
  77. package/landing-page/src/app/components/ui/separator.tsx +0 -28
  78. package/landing-page/src/app/components/ui/sheet.tsx +0 -139
  79. package/landing-page/src/app/components/ui/sidebar.tsx +0 -726
  80. package/landing-page/src/app/components/ui/skeleton.tsx +0 -13
  81. package/landing-page/src/app/components/ui/slider.tsx +0 -63
  82. package/landing-page/src/app/components/ui/sonner.tsx +0 -25
  83. package/landing-page/src/app/components/ui/switch.tsx +0 -31
  84. package/landing-page/src/app/components/ui/table.tsx +0 -116
  85. package/landing-page/src/app/components/ui/tabs.tsx +0 -66
  86. package/landing-page/src/app/components/ui/textarea.tsx +0 -18
  87. package/landing-page/src/app/components/ui/toggle-group.tsx +0 -73
  88. package/landing-page/src/app/components/ui/toggle.tsx +0 -47
  89. package/landing-page/src/app/components/ui/tooltip.tsx +0 -61
  90. package/landing-page/src/app/components/ui/use-mobile.ts +0 -21
  91. package/landing-page/src/app/components/ui/utils.ts +0 -6
  92. package/landing-page/src/components/docs/DocsContent.tsx +0 -718
  93. package/landing-page/src/components/docs/DocsSidebar.tsx +0 -84
  94. package/landing-page/src/components/landing/CTA.tsx +0 -59
  95. package/landing-page/src/components/landing/Comparison.tsx +0 -84
  96. package/landing-page/src/components/landing/FaultyTerminal.tsx +0 -424
  97. package/landing-page/src/components/landing/Features.tsx +0 -201
  98. package/landing-page/src/components/landing/Hero.tsx +0 -142
  99. package/landing-page/src/components/landing/Pricing.tsx +0 -140
  100. package/landing-page/src/components/landing/Roadmap.tsx +0 -86
  101. package/landing-page/src/components/landing/Security.tsx +0 -81
  102. package/landing-page/src/components/landing/TerminalWindow.tsx +0 -27
  103. package/landing-page/src/components/landing/UseCases.tsx +0 -55
  104. package/landing-page/src/components/landing/Workflow.tsx +0 -101
  105. package/landing-page/src/components/layout/DashboardNavbar.tsx +0 -37
  106. package/landing-page/src/components/layout/Footer.tsx +0 -55
  107. package/landing-page/src/components/layout/LandingNavbar.tsx +0 -82
  108. package/landing-page/src/components/ui/badge.tsx +0 -39
  109. package/landing-page/src/components/ui/breadcrumb.tsx +0 -115
  110. package/landing-page/src/components/ui/button.tsx +0 -57
  111. package/landing-page/src/components/ui/card.tsx +0 -79
  112. package/landing-page/src/components/ui/mock-terminal.tsx +0 -68
  113. package/landing-page/src/components/ui/separator.tsx +0 -28
  114. package/landing-page/src/lib/utils.ts +0 -6
  115. package/landing-page/src/main.tsx +0 -10
  116. package/landing-page/src/pages/Dashboard.tsx +0 -133
  117. package/landing-page/src/pages/DocsPage.tsx +0 -79
  118. package/landing-page/src/pages/LandingPage.tsx +0 -31
  119. package/landing-page/src/pages/TerminalView.tsx +0 -106
  120. package/landing-page/src/styles/fonts.css +0 -0
  121. package/landing-page/src/styles/index.css +0 -3
  122. package/landing-page/src/styles/tailwind.css +0 -4
  123. package/landing-page/src/styles/theme.css +0 -181
  124. package/landing-page/vite.config.ts +0 -19
  125. package/npm/darwin-arm64/bin/gssh +0 -0
  126. package/npm/darwin-arm64/package.json +0 -20
  127. package/scripts/build.ts +0 -298
  128. package/scripts/release.ts +0 -140
  129. package/src/__tests__/test-utils.ts +0 -298
  130. package/src/commands/__tests__/serve-messages.test.ts +0 -190
  131. package/src/commands/access.ts +0 -298
  132. package/src/commands/add.ts +0 -452
  133. package/src/commands/auth.ts +0 -364
  134. package/src/commands/connect.ts +0 -287
  135. package/src/commands/directory.ts +0 -16
  136. package/src/commands/host.ts +0 -396
  137. package/src/commands/identity.ts +0 -184
  138. package/src/commands/list.ts +0 -200
  139. package/src/commands/relay.ts +0 -315
  140. package/src/commands/remove.ts +0 -241
  141. package/src/commands/serve.ts +0 -1493
  142. package/src/commands/share.ts +0 -456
  143. package/src/commands/status.ts +0 -125
  144. package/src/commands/switch.ts +0 -353
  145. package/src/commands/tmux.ts +0 -317
  146. package/src/core/__tests__/access.test.ts +0 -240
  147. package/src/core/access.ts +0 -277
  148. package/src/core/bundle.ts +0 -342
  149. package/src/core/config.ts +0 -510
  150. package/src/core/git.ts +0 -317
  151. package/src/core/github.ts +0 -151
  152. package/src/core/identity.ts +0 -631
  153. package/src/core/linear.ts +0 -225
  154. package/src/core/shell.ts +0 -161
  155. package/src/core/trusted-relays.ts +0 -315
  156. package/src/index.ts +0 -810
  157. package/src/lib/remote-session/index.ts +0 -7
  158. package/src/lib/remote-session/protocol.ts +0 -267
  159. package/src/lib/remote-session/session-handler.ts +0 -581
  160. package/src/lib/remote-session/workspace-scanner.ts +0 -167
  161. package/src/lib/tmux-lite/README.md +0 -81
  162. package/src/lib/tmux-lite/cli.ts +0 -796
  163. package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +0 -349
  164. package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +0 -291
  165. package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +0 -142
  166. package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +0 -339
  167. package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +0 -477
  168. package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +0 -499
  169. package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +0 -371
  170. package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +0 -573
  171. package/src/lib/tmux-lite/crypto/access-control.test.ts +0 -512
  172. package/src/lib/tmux-lite/crypto/access-control.ts +0 -320
  173. package/src/lib/tmux-lite/crypto/frames.test.ts +0 -262
  174. package/src/lib/tmux-lite/crypto/frames.ts +0 -141
  175. package/src/lib/tmux-lite/crypto/handshake.ts +0 -894
  176. package/src/lib/tmux-lite/crypto/identity.test.ts +0 -220
  177. package/src/lib/tmux-lite/crypto/identity.ts +0 -286
  178. package/src/lib/tmux-lite/crypto/index.ts +0 -51
  179. package/src/lib/tmux-lite/crypto/invites.test.ts +0 -381
  180. package/src/lib/tmux-lite/crypto/invites.ts +0 -215
  181. package/src/lib/tmux-lite/crypto/keyexchange.ts +0 -435
  182. package/src/lib/tmux-lite/crypto/keys.test.ts +0 -58
  183. package/src/lib/tmux-lite/crypto/keys.ts +0 -47
  184. package/src/lib/tmux-lite/crypto/secretbox.test.ts +0 -169
  185. package/src/lib/tmux-lite/crypto/secretbox.ts +0 -124
  186. package/src/lib/tmux-lite/handshake-handler.ts +0 -451
  187. package/src/lib/tmux-lite/protocol.test.ts +0 -307
  188. package/src/lib/tmux-lite/protocol.ts +0 -266
  189. package/src/lib/tmux-lite/relay-client.ts +0 -506
  190. package/src/lib/tmux-lite/server.ts +0 -1250
  191. package/src/lib/tmux-lite/shell-integration.sh +0 -37
  192. package/src/lib/tmux-lite/terminal-queries.test.ts +0 -54
  193. package/src/lib/tmux-lite/terminal-queries.ts +0 -49
  194. package/src/relay/__tests__/e2e-flow.test.ts +0 -1284
  195. package/src/relay/__tests__/helpers/auth.ts +0 -354
  196. package/src/relay/__tests__/helpers/ports.ts +0 -51
  197. package/src/relay/__tests__/protocol-validation.test.ts +0 -265
  198. package/src/relay/authorization.ts +0 -303
  199. package/src/relay/embedded-assets.generated.d.ts +0 -15
  200. package/src/relay/identity.ts +0 -352
  201. package/src/relay/index.ts +0 -57
  202. package/src/relay/pipes.test.ts +0 -427
  203. package/src/relay/pipes.ts +0 -195
  204. package/src/relay/protocol.ts +0 -804
  205. package/src/relay/registries.test.ts +0 -437
  206. package/src/relay/registries.ts +0 -593
  207. package/src/relay/server.test.ts +0 -1323
  208. package/src/relay/server.ts +0 -1092
  209. package/src/relay/signing.ts +0 -238
  210. package/src/relay/types.ts +0 -69
  211. package/src/serve/client-session-manager.ts +0 -622
  212. package/src/serve/daemon.ts +0 -497
  213. package/src/serve/pty-session.ts +0 -236
  214. package/src/serve/types.ts +0 -169
  215. package/src/shared/components/Flow.tsx +0 -453
  216. package/src/shared/components/Flow.tui.tsx +0 -343
  217. package/src/shared/components/Flow.web.tsx +0 -442
  218. package/src/shared/components/Inbox.tsx +0 -446
  219. package/src/shared/components/Inbox.tui.tsx +0 -262
  220. package/src/shared/components/Inbox.web.tsx +0 -329
  221. package/src/shared/components/MachineList.tsx +0 -187
  222. package/src/shared/components/MachineList.tui.tsx +0 -161
  223. package/src/shared/components/MachineList.web.tsx +0 -210
  224. package/src/shared/components/ProjectList.tsx +0 -176
  225. package/src/shared/components/ProjectList.tui.tsx +0 -109
  226. package/src/shared/components/ProjectList.web.tsx +0 -143
  227. package/src/shared/components/SpacesBrowser.tsx +0 -332
  228. package/src/shared/components/SpacesBrowser.tui.tsx +0 -163
  229. package/src/shared/components/SpacesBrowser.web.tsx +0 -221
  230. package/src/shared/components/index.ts +0 -103
  231. package/src/shared/hooks/index.ts +0 -16
  232. package/src/shared/hooks/useNavigation.ts +0 -226
  233. package/src/shared/index.ts +0 -122
  234. package/src/shared/providers/LocalMachineProvider.ts +0 -425
  235. package/src/shared/providers/MachineProvider.ts +0 -165
  236. package/src/shared/providers/RemoteMachineProvider.ts +0 -444
  237. package/src/shared/providers/index.ts +0 -26
  238. package/src/shared/types.ts +0 -145
  239. package/src/tui/adapters.ts +0 -120
  240. package/src/tui/app.tsx +0 -1816
  241. package/src/tui/components/Terminal.tsx +0 -580
  242. package/src/tui/hooks/index.ts +0 -35
  243. package/src/tui/hooks/useAppState.ts +0 -314
  244. package/src/tui/hooks/useDaemonStatus.ts +0 -174
  245. package/src/tui/hooks/useInboxTUI.ts +0 -113
  246. package/src/tui/hooks/useRemoteMachines.ts +0 -209
  247. package/src/tui/index.ts +0 -24
  248. package/src/tui/state.ts +0 -299
  249. package/src/tui/terminal-bracketed-paste.test.ts +0 -45
  250. package/src/tui/terminal-bracketed-paste.ts +0 -47
  251. package/src/types/bundle.ts +0 -112
  252. package/src/types/config.ts +0 -89
  253. package/src/types/errors.ts +0 -206
  254. package/src/types/identity.ts +0 -284
  255. package/src/types/workspace-fuzzy.ts +0 -49
  256. package/src/types/workspace.ts +0 -151
  257. package/src/utils/bun-socket-writer.ts +0 -80
  258. package/src/utils/deps.ts +0 -127
  259. package/src/utils/fuzzy-match.ts +0 -125
  260. package/src/utils/logger.ts +0 -127
  261. package/src/utils/markdown.ts +0 -254
  262. package/src/utils/onboarding.ts +0 -229
  263. package/src/utils/prompts.ts +0 -114
  264. package/src/utils/run-commands.ts +0 -112
  265. package/src/utils/run-scripts.ts +0 -142
  266. package/src/utils/sanitize.ts +0 -98
  267. package/src/utils/secrets.ts +0 -122
  268. package/src/utils/shell-escape.ts +0 -40
  269. package/src/utils/utf8.ts +0 -79
  270. package/src/utils/workspace-state.ts +0 -47
  271. package/src/web/README.md +0 -73
  272. package/src/web/bun.lock +0 -575
  273. package/src/web/eslint.config.js +0 -23
  274. package/src/web/index.html +0 -16
  275. package/src/web/package.json +0 -37
  276. package/src/web/public/vite.svg +0 -1
  277. package/src/web/src/App.tsx +0 -604
  278. package/src/web/src/assets/react.svg +0 -1
  279. package/src/web/src/components/Terminal.tsx +0 -207
  280. package/src/web/src/hooks/useRelayConnection.ts +0 -224
  281. package/src/web/src/hooks/useTerminal.ts +0 -699
  282. package/src/web/src/index.css +0 -55
  283. package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +0 -1158
  284. package/src/web/src/lib/crypto/frames.ts +0 -205
  285. package/src/web/src/lib/crypto/handshake.ts +0 -396
  286. package/src/web/src/lib/crypto/identity.ts +0 -128
  287. package/src/web/src/lib/crypto/keyexchange.ts +0 -246
  288. package/src/web/src/lib/crypto/relay-signing.ts +0 -53
  289. package/src/web/src/lib/invite.ts +0 -58
  290. package/src/web/src/lib/storage/identity-store.ts +0 -94
  291. package/src/web/src/main.tsx +0 -10
  292. package/src/web/src/types/identity.ts +0 -45
  293. package/src/web/tsconfig.app.json +0 -28
  294. package/src/web/tsconfig.json +0 -7
  295. package/src/web/tsconfig.node.json +0 -26
  296. package/src/web/vite.config.ts +0 -31
  297. package/todo-security.md +0 -92
  298. package/tsconfig.json +0 -23
  299. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
  300. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
  301. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
  302. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
  303. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
  304. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
  305. package/worker/bun.lock +0 -237
  306. package/worker/package.json +0 -22
  307. package/worker/schema.sql +0 -96
  308. package/worker/src/handlers/auth.ts +0 -451
  309. package/worker/src/handlers/subdomains.ts +0 -376
  310. package/worker/src/handlers/user.ts +0 -98
  311. package/worker/src/index.ts +0 -70
  312. package/worker/src/middleware/auth.ts +0 -152
  313. package/worker/src/services/cloudflare.ts +0 -609
  314. package/worker/src/types.ts +0 -96
  315. package/worker/tsconfig.json +0 -15
  316. package/worker/wrangler.toml +0 -26
package/src/tui/app.tsx DELETED
@@ -1,1816 +0,0 @@
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
- }