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,446 @@
1
+ /**
2
+ * Inbox - Shared Hook
3
+ *
4
+ * Hook that manages inbox notification state and actions.
5
+ * Used by both web and TUI renderers.
6
+ */
7
+
8
+ import { useState, useCallback, useMemo, useEffect } from 'react';
9
+
10
+ // ============================================================================
11
+ // Types
12
+ // ============================================================================
13
+
14
+ /** Inbox item type */
15
+ export type InboxItemType = 'exit' | 'title' | 'idle' | 'bell';
16
+
17
+ /** Inbox item from tmux-lite */
18
+ export interface InboxItem {
19
+ id: string;
20
+ sessionId: string;
21
+ sessionName: string;
22
+ type: InboxItemType;
23
+ context: string;
24
+ timestamp: number;
25
+ read: boolean;
26
+ processTitle?: string;
27
+ exitCode?: number;
28
+ }
29
+
30
+ /** Parsed session name components */
31
+ export interface ParsedSessionName {
32
+ project: string;
33
+ workspace: string;
34
+ session: string;
35
+ }
36
+
37
+ /** Session group in hierarchical view */
38
+ export interface SessionGroup {
39
+ session: string;
40
+ items: InboxItem[];
41
+ }
42
+
43
+ /** Workspace group in hierarchical view */
44
+ export interface WorkspaceGroup {
45
+ workspace: string;
46
+ sessions: SessionGroup[];
47
+ totalItems: number;
48
+ }
49
+
50
+ /** Project group in hierarchical view */
51
+ export interface ProjectGroup {
52
+ project: string;
53
+ workspaces: WorkspaceGroup[];
54
+ totalItems: number;
55
+ }
56
+
57
+ /** Display item types for flattened list */
58
+ export type InboxDisplayItem =
59
+ | { type: 'project-header'; project: string; totalItems: number }
60
+ | { type: 'workspace-header'; workspace: string; itemCount: number; isFirstWorkspace: boolean }
61
+ | { type: 'session-header'; session: string; itemCount: number; isFirstSession: boolean }
62
+ | { type: 'item'; item: InboxItem; flatIndex: number };
63
+
64
+ /** Props for useInbox hook */
65
+ export interface UseInboxProps {
66
+ items: InboxItem[];
67
+ unreadCount: number;
68
+ onClearItem: (itemId: string) => Promise<void>;
69
+ onClearAll: () => Promise<void>;
70
+ onMarkRead: (itemId: string) => Promise<void>;
71
+ onAttachSession: (sessionId: string) => Promise<void>;
72
+ onClose: () => void;
73
+ }
74
+
75
+ /** Return type of useInbox hook */
76
+ export interface UseInboxReturn {
77
+ // Display data
78
+ displayItems: InboxDisplayItem[];
79
+ flatItems: InboxItem[];
80
+ hierarchical: ProjectGroup[];
81
+ selectedIndex: number;
82
+ viewingSessionId: string | null;
83
+ sessionThreadItems: InboxItem[];
84
+ unreadCount: number;
85
+
86
+ // Computed flags
87
+ isEmpty: boolean;
88
+ isViewingThread: boolean;
89
+
90
+ // Actions
91
+ moveUp: () => void;
92
+ moveDown: () => void;
93
+ selectIndex: (index: number) => void;
94
+ openThread: () => void;
95
+ closeThread: () => void;
96
+ deleteSelected: () => Promise<void>;
97
+ deleteThread: () => Promise<void>;
98
+ clearAll: () => Promise<void>;
99
+ attachToSession: () => Promise<void>;
100
+ close: () => void;
101
+ }
102
+
103
+ // ============================================================================
104
+ // Helpers
105
+ // ============================================================================
106
+
107
+ /** Parse session name into project/workspace/session */
108
+ export function parseSessionName(sessionName: string): ParsedSessionName {
109
+ if (!sessionName || typeof sessionName !== 'string') {
110
+ return { project: 'unknown', workspace: 'unknown', session: 'unknown' };
111
+ }
112
+ const parts = sessionName.split(':');
113
+ return {
114
+ project: parts[0] || 'unknown',
115
+ workspace: parts[1] || 'unknown',
116
+ session: parts[2] || 'unknown',
117
+ };
118
+ }
119
+
120
+ /** Get icon for inbox item type */
121
+ export function getInboxIcon(item: InboxItem): string {
122
+ if (item.type === 'exit') return item.exitCode === 0 ? 'โœ…' : 'โŒ';
123
+ if (item.type === 'title') return '๐Ÿ“';
124
+ if (item.type === 'idle') return 'โธ๏ธ';
125
+ return '๐Ÿ””';
126
+ }
127
+
128
+ /** Get label for inbox item type */
129
+ export function getInboxTypeLabel(item: InboxItem): string {
130
+ if (item.type === 'exit') return item.exitCode === 0 ? 'Completed' : `Exit code ${item.exitCode}`;
131
+ if (item.type === 'title') return 'Title Change';
132
+ if (item.type === 'idle') return 'Activity Complete';
133
+ return 'Bell';
134
+ }
135
+
136
+ /** Format relative time */
137
+ export function formatTimeAgo(timestamp: number): string {
138
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
139
+ if (seconds < 60) return 'just now';
140
+ const minutes = Math.floor(seconds / 60);
141
+ if (minutes < 60) return `${minutes}m ago`;
142
+ const hours = Math.floor(minutes / 60);
143
+ if (hours < 24) return `${hours}h ago`;
144
+ const days = Math.floor(hours / 24);
145
+ return `${days}d ago`;
146
+ }
147
+
148
+ /** Group inbox items hierarchically by project > workspace > session */
149
+ function groupInboxHierarchically(items: InboxItem[]): ProjectGroup[] {
150
+ // Filter out invalid items and sort by timestamp (most recent first)
151
+ const validItems = items.filter(item =>
152
+ item &&
153
+ typeof item === 'object' &&
154
+ item.sessionName &&
155
+ typeof item.sessionName === 'string' &&
156
+ item.sessionName !== 'undefined'
157
+ );
158
+ const sortedItems = [...validItems].sort((a, b) => b.timestamp - a.timestamp);
159
+
160
+ // Three-level grouping: project โ†’ workspace โ†’ session
161
+ const projectMap = new Map<string, Map<string, Map<string, InboxItem[]>>>();
162
+ const projectLatest = new Map<string, number>();
163
+ const workspaceLatest = new Map<string, number>();
164
+ const sessionLatest = new Map<string, number>();
165
+
166
+ for (const item of sortedItems) {
167
+ const { project, workspace, session } = parseSessionName(item.sessionName);
168
+ const wsKey = `${project}:${workspace}`;
169
+ const sessKey = `${project}:${workspace}:${session}`;
170
+
171
+ // Track latest timestamp for sorting groups
172
+ if (!projectLatest.has(project) || item.timestamp > projectLatest.get(project)!) {
173
+ projectLatest.set(project, item.timestamp);
174
+ }
175
+ if (!workspaceLatest.has(wsKey) || item.timestamp > workspaceLatest.get(wsKey)!) {
176
+ workspaceLatest.set(wsKey, item.timestamp);
177
+ }
178
+ if (!sessionLatest.has(sessKey) || item.timestamp > sessionLatest.get(sessKey)!) {
179
+ sessionLatest.set(sessKey, item.timestamp);
180
+ }
181
+
182
+ if (!projectMap.has(project)) {
183
+ projectMap.set(project, new Map());
184
+ }
185
+ const workspaceMap = projectMap.get(project)!;
186
+
187
+ if (!workspaceMap.has(workspace)) {
188
+ workspaceMap.set(workspace, new Map());
189
+ }
190
+ const sessionMap = workspaceMap.get(workspace)!;
191
+
192
+ if (!sessionMap.has(session)) {
193
+ sessionMap.set(session, []);
194
+ }
195
+ sessionMap.get(session)!.push(item);
196
+ }
197
+
198
+ const result: ProjectGroup[] = [];
199
+ for (const [project, workspaceMap] of projectMap) {
200
+ const workspaces: WorkspaceGroup[] = [];
201
+ let projectTotal = 0;
202
+
203
+ for (const [workspace, sessionMap] of workspaceMap) {
204
+ const sessions: SessionGroup[] = [];
205
+ let workspaceTotal = 0;
206
+
207
+ for (const [session, sessionItems] of sessionMap) {
208
+ sessions.push({ session, items: sessionItems });
209
+ workspaceTotal += sessionItems.length;
210
+ }
211
+
212
+ // Sort sessions by most recent
213
+ const wsKey = `${project}:${workspace}`;
214
+ sessions.sort((a, b) => {
215
+ const aKey = `${wsKey}:${a.session}`;
216
+ const bKey = `${wsKey}:${b.session}`;
217
+ return (sessionLatest.get(bKey) || 0) - (sessionLatest.get(aKey) || 0);
218
+ });
219
+
220
+ workspaces.push({ workspace, sessions, totalItems: workspaceTotal });
221
+ projectTotal += workspaceTotal;
222
+ }
223
+
224
+ // Sort workspaces by most recent
225
+ workspaces.sort((a, b) => {
226
+ const aKey = `${project}:${a.workspace}`;
227
+ const bKey = `${project}:${b.workspace}`;
228
+ return (workspaceLatest.get(bKey) || 0) - (workspaceLatest.get(aKey) || 0);
229
+ });
230
+
231
+ result.push({ project, workspaces, totalItems: projectTotal });
232
+ }
233
+
234
+ // Sort projects by most recent
235
+ result.sort((a, b) => (projectLatest.get(b.project) || 0) - (projectLatest.get(a.project) || 0));
236
+
237
+ return result;
238
+ }
239
+
240
+ /** Build flat display items from hierarchical groups */
241
+ function buildInboxDisplay(items: InboxItem[]): { displayItems: InboxDisplayItem[]; flatItems: InboxItem[] } {
242
+ const hierarchical = groupInboxHierarchically(items);
243
+ const displayItems: InboxDisplayItem[] = [];
244
+ const flatItems: InboxItem[] = [];
245
+ let flatIndex = 0;
246
+
247
+ for (const projectGroup of hierarchical) {
248
+ displayItems.push({
249
+ type: 'project-header',
250
+ project: projectGroup.project,
251
+ totalItems: projectGroup.totalItems,
252
+ });
253
+
254
+ projectGroup.workspaces.forEach((wsGroup, wsIdx) => {
255
+ displayItems.push({
256
+ type: 'workspace-header',
257
+ workspace: wsGroup.workspace,
258
+ itemCount: wsGroup.totalItems,
259
+ isFirstWorkspace: wsIdx === 0,
260
+ });
261
+
262
+ wsGroup.sessions.forEach((sessGroup, sessIdx) => {
263
+ displayItems.push({
264
+ type: 'session-header',
265
+ session: sessGroup.session,
266
+ itemCount: sessGroup.items.length,
267
+ isFirstSession: sessIdx === 0,
268
+ });
269
+
270
+ for (const item of sessGroup.items) {
271
+ displayItems.push({ type: 'item', item, flatIndex });
272
+ flatItems.push(item);
273
+ flatIndex++;
274
+ }
275
+ });
276
+ });
277
+ }
278
+
279
+ return { displayItems, flatItems };
280
+ }
281
+
282
+ /** Get items for a specific session thread */
283
+ function getSessionItems(items: InboxItem[], sessionId: string): InboxItem[] {
284
+ return items
285
+ .filter((item) => item.sessionId === sessionId)
286
+ .sort((a, b) => b.timestamp - a.timestamp);
287
+ }
288
+
289
+ // ============================================================================
290
+ // Hook
291
+ // ============================================================================
292
+
293
+ export function useInbox(props: UseInboxProps): UseInboxReturn {
294
+ const {
295
+ items,
296
+ unreadCount,
297
+ onClearItem,
298
+ onClearAll,
299
+ onMarkRead,
300
+ onAttachSession,
301
+ onClose,
302
+ } = props;
303
+
304
+ // Local UI state
305
+ const [selectedIndex, setSelectedIndex] = useState(0);
306
+ const [viewingSessionId, setViewingSessionId] = useState<string | null>(null);
307
+
308
+ // Build display data
309
+ const { displayItems, flatItems } = useMemo(
310
+ () => buildInboxDisplay(items),
311
+ [items]
312
+ );
313
+
314
+ const hierarchical = useMemo(
315
+ () => groupInboxHierarchically(items),
316
+ [items]
317
+ );
318
+
319
+ // Session thread items (when viewing a thread)
320
+ const sessionThreadItems = useMemo(
321
+ () => (viewingSessionId ? getSessionItems(items, viewingSessionId) : []),
322
+ [items, viewingSessionId]
323
+ );
324
+
325
+ // Computed
326
+ const isEmpty = items.length === 0;
327
+ const isViewingThread = viewingSessionId !== null;
328
+
329
+ // Clamp selectedIndex when items change
330
+ useEffect(() => {
331
+ if (flatItems.length === 0) {
332
+ setSelectedIndex(0);
333
+ } else if (selectedIndex >= flatItems.length) {
334
+ setSelectedIndex(Math.max(0, flatItems.length - 1));
335
+ }
336
+ }, [flatItems.length, selectedIndex]);
337
+
338
+ // Actions
339
+ const moveUp = useCallback(() => {
340
+ setSelectedIndex((i) => Math.max(0, i - 1));
341
+ }, []);
342
+
343
+ const moveDown = useCallback(() => {
344
+ if (flatItems.length === 0) return; // Don't move if no items
345
+ setSelectedIndex((i) => Math.min(flatItems.length - 1, i + 1));
346
+ }, [flatItems.length]);
347
+
348
+ const selectIndex = useCallback(
349
+ (index: number) => {
350
+ setSelectedIndex(Math.max(0, Math.min(index, flatItems.length - 1)));
351
+ },
352
+ [flatItems.length]
353
+ );
354
+
355
+ const openThread = useCallback(async () => {
356
+ if (flatItems.length === 0) return;
357
+
358
+ const item = flatItems[selectedIndex];
359
+ if (item) {
360
+ // Mark thread as read
361
+ const threadItems = items.filter((i) => i.sessionId === item.sessionId && !i.read);
362
+ for (const threadItem of threadItems) {
363
+ await onMarkRead(threadItem.id);
364
+ }
365
+ setViewingSessionId(item.sessionId);
366
+ }
367
+ }, [flatItems, selectedIndex, items, onMarkRead]);
368
+
369
+ const closeThread = useCallback(() => {
370
+ setViewingSessionId(null);
371
+ }, []);
372
+
373
+ const deleteSelected = useCallback(async () => {
374
+ if (flatItems.length === 0) return;
375
+
376
+ const item = flatItems[selectedIndex];
377
+ if (item) {
378
+ await onClearItem(item.id);
379
+ // Adjust selection if needed
380
+ if (selectedIndex >= flatItems.length - 1 && flatItems.length > 1) {
381
+ setSelectedIndex(flatItems.length - 2);
382
+ }
383
+ }
384
+ }, [flatItems, selectedIndex, onClearItem]);
385
+
386
+ const deleteThread = useCallback(async () => {
387
+ if (!viewingSessionId) return;
388
+
389
+ for (const item of sessionThreadItems) {
390
+ await onClearItem(item.id);
391
+ }
392
+ // Adjust selection and go back to list
393
+ const newFlatItems = flatItems.filter((i) => i.sessionId !== viewingSessionId);
394
+ const newIndex = selectedIndex >= newFlatItems.length ? Math.max(0, newFlatItems.length - 1) : selectedIndex;
395
+ setSelectedIndex(newIndex);
396
+ setViewingSessionId(null);
397
+ }, [viewingSessionId, sessionThreadItems, flatItems, selectedIndex, onClearItem]);
398
+
399
+ const clearAll = useCallback(async () => {
400
+ await onClearAll();
401
+ setSelectedIndex(0);
402
+ setViewingSessionId(null);
403
+ }, [onClearAll]);
404
+
405
+ const attachToSession = useCallback(async () => {
406
+ const sessionId = viewingSessionId || (flatItems[selectedIndex]?.sessionId);
407
+ if (sessionId) {
408
+ await onAttachSession(sessionId);
409
+ }
410
+ }, [viewingSessionId, flatItems, selectedIndex, onAttachSession]);
411
+
412
+ const close = useCallback(() => {
413
+ if (viewingSessionId) {
414
+ setViewingSessionId(null);
415
+ } else {
416
+ onClose();
417
+ }
418
+ }, [viewingSessionId, onClose]);
419
+
420
+ return {
421
+ // Display data
422
+ displayItems,
423
+ flatItems,
424
+ hierarchical,
425
+ selectedIndex,
426
+ viewingSessionId,
427
+ sessionThreadItems,
428
+ unreadCount,
429
+
430
+ // Computed flags
431
+ isEmpty,
432
+ isViewingThread,
433
+
434
+ // Actions
435
+ moveUp,
436
+ moveDown,
437
+ selectIndex,
438
+ openThread,
439
+ closeThread,
440
+ deleteSelected,
441
+ deleteThread,
442
+ clearAll,
443
+ attachToSession,
444
+ close,
445
+ };
446
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Inbox - TUI Display Component
3
+ *
4
+ * Dumb presentational component for OpenTUI.
5
+ * Receives all state and actions from useInbox hook.
6
+ */
7
+
8
+ import type { UseInboxReturn } from './Inbox.js';
9
+ import {
10
+ parseSessionName,
11
+ getInboxIcon,
12
+ getInboxTypeLabel,
13
+ formatTimeAgo,
14
+ } from './Inbox.js';
15
+
16
+ // ============================================================================
17
+ // Colors
18
+ // ============================================================================
19
+
20
+ const COLORS = {
21
+ border: '#555555',
22
+ borderFocused: '#00AAFF',
23
+ text: '#FFFFFF',
24
+ textDim: '#888888',
25
+ selected: '#00AAFF',
26
+ title: '#00FF88',
27
+ statusBar: '#333333',
28
+ loading: '#FFAA00',
29
+ project: '#00FF88',
30
+ workspace: '#FFAA00',
31
+ session: '#888888',
32
+ unread: '#FFFFFF',
33
+ read: '#666666',
34
+ };
35
+
36
+ // ============================================================================
37
+ // Props
38
+ // ============================================================================
39
+
40
+ interface InboxTUIProps extends UseInboxReturn {
41
+ focused?: boolean;
42
+ }
43
+
44
+ // ============================================================================
45
+ // Component
46
+ // ============================================================================
47
+
48
+ export function InboxTUI(props: InboxTUIProps) {
49
+ const {
50
+ displayItems,
51
+ flatItems,
52
+ selectedIndex,
53
+ viewingSessionId,
54
+ sessionThreadItems,
55
+ unreadCount,
56
+ isEmpty,
57
+ isViewingThread,
58
+ focused = true,
59
+ } = props;
60
+
61
+ // Thread detail view
62
+ if (isViewingThread && viewingSessionId) {
63
+ const sessionName = sessionThreadItems[0]?.sessionName;
64
+ const sessionParts = sessionName ? parseSessionName(sessionName) : null;
65
+ const sessionLabel = sessionParts
66
+ ? `${sessionParts.project} / ${sessionParts.workspace} / ${sessionParts.session}`
67
+ : 'Session';
68
+ const maxLinesPerItem = 8;
69
+
70
+ return (
71
+ <box flexDirection="column" width="100%" height="100%">
72
+ <box
73
+ flexDirection="column"
74
+ border
75
+ borderStyle="single"
76
+ borderColor={focused ? COLORS.borderFocused : COLORS.border}
77
+ flexGrow={1}
78
+ margin={1}
79
+ >
80
+ <text fg={COLORS.title} paddingLeft={1} height={1}>
81
+ {` ๐Ÿ“ฅ ${sessionLabel}${sessionThreadItems.length > 0 ? ` (${sessionThreadItems.length} notification${sessionThreadItems.length > 1 ? 's' : ''})` : ''} `}
82
+ </text>
83
+ <box flexDirection="column" padding={1} flexGrow={1} overflow="scroll">
84
+ {sessionThreadItems.length > 0 ? (
85
+ sessionThreadItems.map((item, itemIdx) => {
86
+ const timeAgo = formatTimeAgo(item.timestamp);
87
+ const typeLabel = getInboxTypeLabel(item);
88
+ const icon = getInboxIcon(item);
89
+ const lines = item.context.split('\n');
90
+ const previewLines = lines.slice(0, maxLinesPerItem);
91
+ const remainingLines = Math.max(0, lines.length - previewLines.length);
92
+
93
+ return (
94
+ <box
95
+ key={item.id}
96
+ flexDirection="column"
97
+ marginBottom={itemIdx === sessionThreadItems.length - 1 ? 0 : 1}
98
+ >
99
+ <text fg={COLORS.title} height={1}>
100
+ {icon} {typeLabel} ยท {timeAgo}
101
+ </text>
102
+ {item.processTitle && (
103
+ <text fg={COLORS.loading} height={1}>
104
+ Process: {item.processTitle}
105
+ </text>
106
+ )}
107
+ <box flexDirection="column" paddingLeft={1} marginTop={1}>
108
+ {previewLines.map((line, lineIdx) => (
109
+ <text key={lineIdx} fg={COLORS.textDim} height={1}>
110
+ {line}
111
+ </text>
112
+ ))}
113
+ {remainingLines > 0 && (
114
+ <text fg={COLORS.textDim} height={1}>
115
+ ... ({remainingLines} more lines)
116
+ </text>
117
+ )}
118
+ </box>
119
+ </box>
120
+ );
121
+ })
122
+ ) : (
123
+ <text fg={COLORS.textDim} height={1}>
124
+ No notifications for this session.
125
+ </text>
126
+ )}
127
+ </box>
128
+ </box>
129
+ <box width="100%" height={1} backgroundColor={COLORS.statusBar}>
130
+ <text fg={COLORS.textDim}>
131
+ {' '}[a] Attach to session [x] Delete [Esc] Back to list
132
+ </text>
133
+ </box>
134
+ </box>
135
+ );
136
+ }
137
+
138
+ // Empty state
139
+ if (isEmpty) {
140
+ return (
141
+ <box flexDirection="column" width="100%" height="100%">
142
+ <box
143
+ flexDirection="column"
144
+ border
145
+ borderStyle="single"
146
+ borderColor={focused ? COLORS.borderFocused : COLORS.border}
147
+ flexGrow={1}
148
+ margin={1}
149
+ >
150
+ <text fg={COLORS.title} paddingLeft={1} height={1}>
151
+ {' '}๐Ÿ“ฅ INBOX{' '}
152
+ </text>
153
+ <box
154
+ flexDirection="column"
155
+ padding={1}
156
+ flexGrow={1}
157
+ justifyContent="center"
158
+ alignItems="center"
159
+ >
160
+ <text fg={COLORS.textDim}>No notifications</text>
161
+ </box>
162
+ </box>
163
+ <box width="100%" height={1} backgroundColor={COLORS.statusBar}>
164
+ <text fg={COLORS.textDim}> [Esc] Back</text>
165
+ </box>
166
+ </box>
167
+ );
168
+ }
169
+
170
+ // List view
171
+ return (
172
+ <box flexDirection="column" width="100%" height="100%">
173
+ <box
174
+ flexDirection="column"
175
+ border
176
+ borderStyle="single"
177
+ borderColor={focused ? COLORS.borderFocused : COLORS.border}
178
+ flexGrow={1}
179
+ margin={1}
180
+ >
181
+ <text fg={COLORS.title} paddingLeft={1} height={1}>
182
+ {` ๐Ÿ“ฅ INBOX ${unreadCount > 0 ? `(${unreadCount} unread)` : ''} `}
183
+ </text>
184
+ <box flexDirection="column" padding={1} flexGrow={1} overflow="scroll">
185
+ {displayItems.map((displayItem, displayIdx) => {
186
+ if (displayItem.type === 'project-header') {
187
+ return (
188
+ <box key={`project-${displayItem.project}`} flexDirection="column">
189
+ {displayIdx > 0 && <text height={1}> </text>}
190
+ <text fg={COLORS.project} height={1}>
191
+ โ”Œโ”€ ๐Ÿ“ {displayItem.project} ({displayItem.totalItems}{' '}
192
+ notification{displayItem.totalItems > 1 ? 's' : ''})
193
+ </text>
194
+ </box>
195
+ );
196
+ }
197
+
198
+ if (displayItem.type === 'workspace-header') {
199
+ return (
200
+ <box key={`workspace-${displayItem.workspace}`} flexDirection="column">
201
+ {!displayItem.isFirstWorkspace && (
202
+ <text fg={COLORS.border} height={1}>
203
+ โ”‚
204
+ </text>
205
+ )}
206
+ <text fg={COLORS.workspace} height={1}>
207
+ โ”‚ โ”Œโ”€ ๐Ÿ“‚ {displayItem.workspace}
208
+ </text>
209
+ </box>
210
+ );
211
+ }
212
+
213
+ if (displayItem.type === 'session-header') {
214
+ return (
215
+ <box key={`session-${displayItem.session}`} flexDirection="column">
216
+ <text fg={COLORS.session} height={1}>
217
+ โ”‚ โ”‚ โ”œโ”€ ๐Ÿ’ป {displayItem.session}
218
+ </text>
219
+ </box>
220
+ );
221
+ }
222
+
223
+ // Item
224
+ const { item } = displayItem;
225
+ const isSelected = displayItem.flatIndex === selectedIndex;
226
+ const timeAgo = formatTimeAgo(item.timestamp);
227
+ const icon = getInboxIcon(item);
228
+ const readIndicator = item.read ? ' ' : 'โ€ข';
229
+ const prefix = isSelected ? 'โ–ถ' : ' ';
230
+ const processInfo = item.processTitle || '';
231
+ const context = item.context.split('\n')[0].substring(0, 40);
232
+
233
+ return (
234
+ <box key={item.id} flexDirection="column">
235
+ <text
236
+ fg={isSelected ? COLORS.selected : item.read ? COLORS.read : COLORS.unread}
237
+ height={1}
238
+ >
239
+ โ”‚ โ”‚ โ”‚ {prefix}
240
+ {readIndicator} {icon} {processInfo}
241
+ {processInfo ? ' ยท ' : ''}
242
+ {timeAgo}
243
+ </text>
244
+ <text
245
+ fg={isSelected ? COLORS.selected : COLORS.textDim}
246
+ height={1}
247
+ >
248
+ โ”‚ โ”‚ โ”‚ {context}
249
+ </text>
250
+ </box>
251
+ );
252
+ })}
253
+ </box>
254
+ </box>
255
+ <box width="100%" height={1} backgroundColor={COLORS.statusBar}>
256
+ <text fg={COLORS.textDim}>
257
+ {' '}[โ†‘โ†“] Navigate [Enter] View [x] Delete [c] Clear all [Esc] Back
258
+ </text>
259
+ </box>
260
+ </box>
261
+ );
262
+ }