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