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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Hook for Remote Machine Connections
|
|
3
|
+
*
|
|
4
|
+
* Manages relay connection and provides a unified interface for
|
|
5
|
+
* both local and remote machine operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
9
|
+
import type { MachineInfo } from '../../shared/components/index.js';
|
|
10
|
+
import type { MachineProvider } from '../../shared/providers/index.js';
|
|
11
|
+
import type WS from 'ws';
|
|
12
|
+
|
|
13
|
+
export interface RelayConfig {
|
|
14
|
+
url: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UseRemoteMachinesOptions {
|
|
18
|
+
relayConfig?: RelayConfig;
|
|
19
|
+
onError?: (error: Error) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
|
23
|
+
|
|
24
|
+
export interface UseRemoteMachinesReturn {
|
|
25
|
+
// Connection state
|
|
26
|
+
status: ConnectionStatus;
|
|
27
|
+
error: string | null;
|
|
28
|
+
|
|
29
|
+
// Machine list
|
|
30
|
+
machines: MachineInfo[];
|
|
31
|
+
selectedMachine: MachineInfo | null;
|
|
32
|
+
|
|
33
|
+
// Actions
|
|
34
|
+
connect: () => Promise<void>;
|
|
35
|
+
disconnect: () => void;
|
|
36
|
+
selectMachine: (machine: MachineInfo) => Promise<MachineProvider | null>;
|
|
37
|
+
refreshMachines: () => Promise<void>;
|
|
38
|
+
|
|
39
|
+
// Mode
|
|
40
|
+
isRemoteMode: boolean;
|
|
41
|
+
isLocal: (machine: MachineInfo) => boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useRemoteMachines(options: UseRemoteMachinesOptions = {}): UseRemoteMachinesReturn {
|
|
45
|
+
const { relayConfig, onError } = options;
|
|
46
|
+
|
|
47
|
+
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
|
48
|
+
const [error, setError] = useState<string | null>(null);
|
|
49
|
+
const [machines, setMachines] = useState<MachineInfo[]>([]);
|
|
50
|
+
const [selectedMachine, setSelectedMachine] = useState<MachineInfo | null>(null);
|
|
51
|
+
const wsRef = useRef<WS | null>(null);
|
|
52
|
+
|
|
53
|
+
const isRemoteMode = !!relayConfig;
|
|
54
|
+
|
|
55
|
+
// Local machine info
|
|
56
|
+
const localMachine: MachineInfo = {
|
|
57
|
+
machineId: 'local',
|
|
58
|
+
label: 'This Machine',
|
|
59
|
+
online: true,
|
|
60
|
+
isAuthorized: true,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Check if machine is local
|
|
64
|
+
const isLocal = useCallback((machine: MachineInfo) => {
|
|
65
|
+
return machine.machineId === 'local';
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
// Connect to relay
|
|
69
|
+
const connect = useCallback(async () => {
|
|
70
|
+
if (!relayConfig) {
|
|
71
|
+
// No relay config - just use local machine
|
|
72
|
+
setMachines([localMachine]);
|
|
73
|
+
setStatus('connected');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setStatus('connecting');
|
|
78
|
+
setError(null);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const { default: WebSocket } = await import('ws');
|
|
82
|
+
const socket = new WebSocket(relayConfig.url);
|
|
83
|
+
|
|
84
|
+
socket.on('open', () => {
|
|
85
|
+
// Request machine list (auth happens via challenge-response)
|
|
86
|
+
socket.send(JSON.stringify({ type: 'list-machines' }));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
socket.on('message', (data: Buffer) => {
|
|
90
|
+
try {
|
|
91
|
+
const message = JSON.parse(data.toString());
|
|
92
|
+
|
|
93
|
+
if (message.type === 'machines') {
|
|
94
|
+
setStatus('connected');
|
|
95
|
+
// Combine local with remote machines
|
|
96
|
+
const remoteMachines: MachineInfo[] = message.machines.map((m: any) => ({
|
|
97
|
+
machineId: m.machineId,
|
|
98
|
+
label: m.label,
|
|
99
|
+
online: m.online,
|
|
100
|
+
hasAccess: m.hasAccess,
|
|
101
|
+
lastConnectedAt: m.lastConnectedAt,
|
|
102
|
+
}));
|
|
103
|
+
setMachines([localMachine, ...remoteMachines]);
|
|
104
|
+
} else if (message.type === 'error') {
|
|
105
|
+
setError(message.message);
|
|
106
|
+
if (onError) onError(new Error(message.message));
|
|
107
|
+
}
|
|
108
|
+
} catch (e) {
|
|
109
|
+
// Ignore parse errors
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
socket.on('error', (err) => {
|
|
114
|
+
setError(err.message);
|
|
115
|
+
setStatus('error');
|
|
116
|
+
if (onError) onError(err);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
socket.on('close', () => {
|
|
120
|
+
setStatus('disconnected');
|
|
121
|
+
wsRef.current = null;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
wsRef.current = socket;
|
|
125
|
+
} catch (err) {
|
|
126
|
+
const error = err instanceof Error ? err : new Error('Failed to connect');
|
|
127
|
+
setError(error.message);
|
|
128
|
+
setStatus('error');
|
|
129
|
+
if (onError) onError(error);
|
|
130
|
+
}
|
|
131
|
+
}, [relayConfig, localMachine, onError]);
|
|
132
|
+
|
|
133
|
+
// Disconnect from relay
|
|
134
|
+
const disconnect = useCallback(() => {
|
|
135
|
+
if (wsRef.current) {
|
|
136
|
+
wsRef.current.close();
|
|
137
|
+
wsRef.current = null;
|
|
138
|
+
}
|
|
139
|
+
setStatus('disconnected');
|
|
140
|
+
setMachines([]);
|
|
141
|
+
setSelectedMachine(null);
|
|
142
|
+
}, []);
|
|
143
|
+
|
|
144
|
+
// Select a machine and get its provider
|
|
145
|
+
const selectMachine = useCallback(async (machine: MachineInfo): Promise<MachineProvider | null> => {
|
|
146
|
+
setSelectedMachine(machine);
|
|
147
|
+
|
|
148
|
+
if (isLocal(machine)) {
|
|
149
|
+
// Return local provider
|
|
150
|
+
const { getLocalMachineProvider } = await import('../../shared/providers/index.js');
|
|
151
|
+
return getLocalMachineProvider();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Return remote provider
|
|
155
|
+
if (!relayConfig || !wsRef.current) {
|
|
156
|
+
setError('Not connected to relay');
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { createRemoteMachineProvider } = await import('../../shared/providers/index.js');
|
|
161
|
+
return createRemoteMachineProvider({
|
|
162
|
+
relayUrl: relayConfig.url,
|
|
163
|
+
machineId: machine.machineId,
|
|
164
|
+
});
|
|
165
|
+
}, [isLocal, relayConfig]);
|
|
166
|
+
|
|
167
|
+
// Refresh machine list
|
|
168
|
+
const refreshMachines = useCallback(async () => {
|
|
169
|
+
if (!relayConfig) {
|
|
170
|
+
setMachines([localMachine]);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const ws = wsRef.current;
|
|
175
|
+
if (ws && ws.readyState === ws.OPEN) {
|
|
176
|
+
ws.send(JSON.stringify({ type: 'list-machines' }));
|
|
177
|
+
}
|
|
178
|
+
}, [relayConfig, localMachine]);
|
|
179
|
+
|
|
180
|
+
// Auto-connect on mount if relay config is provided
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (relayConfig) {
|
|
183
|
+
connect();
|
|
184
|
+
} else {
|
|
185
|
+
// Just show local machine
|
|
186
|
+
setMachines([localMachine]);
|
|
187
|
+
setStatus('connected');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return () => {
|
|
191
|
+
if (wsRef.current) {
|
|
192
|
+
wsRef.current.close();
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}, []); // Only run on mount
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
status,
|
|
199
|
+
error,
|
|
200
|
+
machines,
|
|
201
|
+
selectedMachine,
|
|
202
|
+
connect,
|
|
203
|
+
disconnect,
|
|
204
|
+
selectMachine,
|
|
205
|
+
refreshMachines,
|
|
206
|
+
isRemoteMode,
|
|
207
|
+
isLocal,
|
|
208
|
+
};
|
|
209
|
+
}
|
package/src/tui/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI entry point
|
|
3
|
+
* Exports the main TUI launcher
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { launchTUI, type TUIRelayConfig } from './app.jsx';
|
|
7
|
+
export * from './state.js';
|
|
8
|
+
// Note: hooks/index exports AppState which conflicts with state.js
|
|
9
|
+
// Explicitly re-export without the duplicate
|
|
10
|
+
export {
|
|
11
|
+
useRemoteMachines,
|
|
12
|
+
type RelayConfig,
|
|
13
|
+
type UseRemoteMachinesOptions,
|
|
14
|
+
type ConnectionStatus,
|
|
15
|
+
type UseRemoteMachinesReturn,
|
|
16
|
+
useAppState,
|
|
17
|
+
type AppView,
|
|
18
|
+
type PanelFocus,
|
|
19
|
+
type AppAction,
|
|
20
|
+
type UseAppStateOptions,
|
|
21
|
+
useTUIInbox,
|
|
22
|
+
} from './hooks/index.js';
|
|
23
|
+
// Export hook's AppState with a distinct name
|
|
24
|
+
export type { AppState as HookAppState } from './hooks/index.js';
|
package/src/tui/state.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI state management
|
|
3
|
+
* Handles application state for the terminal user interface
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
getAllProjectNames,
|
|
8
|
+
readProjectConfig,
|
|
9
|
+
getCurrentProject,
|
|
10
|
+
setCurrentProject,
|
|
11
|
+
getProjectWorkspacesDir,
|
|
12
|
+
} from '../core/config.js';
|
|
13
|
+
import { getWorktreeInfo } from '../core/git.js';
|
|
14
|
+
import { existsSync, readdirSync } from 'fs';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
import type { WorktreeInfo } from '../types/workspace.js';
|
|
17
|
+
import {
|
|
18
|
+
listSessions,
|
|
19
|
+
getInbox,
|
|
20
|
+
type Session,
|
|
21
|
+
type InboxItem,
|
|
22
|
+
} from '../lib/tmux-lite/cli.js';
|
|
23
|
+
|
|
24
|
+
export interface ProjectState {
|
|
25
|
+
name: string;
|
|
26
|
+
repository: string;
|
|
27
|
+
workspaceCount: number;
|
|
28
|
+
isCurrent: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface WorkspaceSession {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
attached: boolean;
|
|
35
|
+
createdAt: number;
|
|
36
|
+
processTitle?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WorkspaceState extends WorktreeInfo {
|
|
40
|
+
isStale: boolean;
|
|
41
|
+
sessions: WorkspaceSession[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Tree item for flat list rendering
|
|
45
|
+
export type TreeItem =
|
|
46
|
+
| { type: 'workspace'; workspace: WorkspaceState }
|
|
47
|
+
| { type: 'session'; workspace: WorkspaceState; session: WorkspaceSession }
|
|
48
|
+
| { type: 'new-session'; workspace: WorkspaceState };
|
|
49
|
+
|
|
50
|
+
export interface AppState {
|
|
51
|
+
projects: ProjectState[];
|
|
52
|
+
workspaces: WorkspaceState[];
|
|
53
|
+
selectedProjectIndex: number;
|
|
54
|
+
selectedTreeIndex: number; // Index into flattened tree
|
|
55
|
+
expandedWorkspaces: Set<string>; // Set of expanded workspace names
|
|
56
|
+
activePanel: 'projects' | 'workspaces';
|
|
57
|
+
currentProject: string | null;
|
|
58
|
+
isLoading: boolean;
|
|
59
|
+
error: string | null;
|
|
60
|
+
inbox: InboxItem[];
|
|
61
|
+
unreadCount: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Build flat tree from workspaces and expanded state
|
|
65
|
+
export function buildTree(workspaces: WorkspaceState[], expanded: Set<string>): TreeItem[] {
|
|
66
|
+
const items: TreeItem[] = [];
|
|
67
|
+
for (const ws of workspaces) {
|
|
68
|
+
items.push({ type: 'workspace', workspace: ws });
|
|
69
|
+
if (expanded.has(ws.name)) {
|
|
70
|
+
// Add sessions
|
|
71
|
+
for (const session of ws.sessions) {
|
|
72
|
+
items.push({ type: 'session', workspace: ws, session });
|
|
73
|
+
}
|
|
74
|
+
// Add "new session" option
|
|
75
|
+
items.push({ type: 'new-session', workspace: ws });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return items;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const STALE_DAYS = 30;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create initial app state
|
|
85
|
+
*/
|
|
86
|
+
export function createInitialState(): AppState {
|
|
87
|
+
return {
|
|
88
|
+
projects: [],
|
|
89
|
+
workspaces: [],
|
|
90
|
+
selectedProjectIndex: 0,
|
|
91
|
+
selectedTreeIndex: 0,
|
|
92
|
+
expandedWorkspaces: new Set(),
|
|
93
|
+
activePanel: 'projects',
|
|
94
|
+
currentProject: null,
|
|
95
|
+
isLoading: true,
|
|
96
|
+
error: null,
|
|
97
|
+
inbox: [],
|
|
98
|
+
unreadCount: 0,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Load projects from config
|
|
104
|
+
*/
|
|
105
|
+
export function loadProjects(): ProjectState[] {
|
|
106
|
+
const projectNames = getAllProjectNames();
|
|
107
|
+
const currentProject = getCurrentProject();
|
|
108
|
+
|
|
109
|
+
return projectNames.map((name) => {
|
|
110
|
+
const config = readProjectConfig(name);
|
|
111
|
+
const workspacesDir = getProjectWorkspacesDir(name);
|
|
112
|
+
let workspaceCount = 0;
|
|
113
|
+
|
|
114
|
+
if (existsSync(workspacesDir)) {
|
|
115
|
+
workspaceCount = readdirSync(workspacesDir).filter((entry) => {
|
|
116
|
+
const path = join(workspacesDir, entry);
|
|
117
|
+
return existsSync(path) && readdirSync(path).length > 0;
|
|
118
|
+
}).length;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
name,
|
|
123
|
+
repository: config.repository,
|
|
124
|
+
workspaceCount,
|
|
125
|
+
isCurrent: name === currentProject,
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Load workspaces for a project with session info
|
|
132
|
+
*/
|
|
133
|
+
export async function loadWorkspaces(projectName: string): Promise<WorkspaceState[]> {
|
|
134
|
+
const workspacesDir = getProjectWorkspacesDir(projectName);
|
|
135
|
+
|
|
136
|
+
if (!existsSync(workspacesDir)) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const workspaceNames = readdirSync(workspacesDir).filter((entry) => {
|
|
141
|
+
const path = join(workspacesDir, entry);
|
|
142
|
+
return existsSync(path) && readdirSync(path).length > 0;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Get all tmux-lite sessions
|
|
146
|
+
let allSessions: Session[] = [];
|
|
147
|
+
try {
|
|
148
|
+
allSessions = await listSessions();
|
|
149
|
+
} catch {
|
|
150
|
+
// Server might not be running, that's fine
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const workspaces: WorkspaceState[] = [];
|
|
154
|
+
const now = new Date();
|
|
155
|
+
|
|
156
|
+
for (const name of workspaceNames) {
|
|
157
|
+
const workspacePath = join(workspacesDir, name);
|
|
158
|
+
const info = await getWorktreeInfo(workspacePath);
|
|
159
|
+
|
|
160
|
+
if (info) {
|
|
161
|
+
const daysSinceCommit = Math.floor(
|
|
162
|
+
(now.getTime() - info.lastCommitDate.getTime()) / (1000 * 60 * 60 * 24)
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Find sessions for this workspace by matching cwd.
|
|
166
|
+
// Note: Session cwd is set once at creation time and does NOT change
|
|
167
|
+
// as users navigate within the shell. This is intentional - we want to
|
|
168
|
+
// show sessions that were *created for* this workspace, not sessions
|
|
169
|
+
// that happen to currently be in this directory.
|
|
170
|
+
const workspaceSessions = allSessions
|
|
171
|
+
.filter(s => s.cwd === workspacePath)
|
|
172
|
+
.map(s => ({
|
|
173
|
+
id: s.id,
|
|
174
|
+
name: s.name,
|
|
175
|
+
attached: s.attached,
|
|
176
|
+
createdAt: s.createdAt,
|
|
177
|
+
processTitle: s.processTitle,
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
workspaces.push({
|
|
181
|
+
...info,
|
|
182
|
+
isStale: daysSinceCommit > STALE_DAYS,
|
|
183
|
+
sessions: workspaceSessions,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return workspaces;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Load inbox items
|
|
193
|
+
*/
|
|
194
|
+
export async function loadInbox(): Promise<{ items: InboxItem[]; unreadCount: number }> {
|
|
195
|
+
try {
|
|
196
|
+
const items = await getInbox();
|
|
197
|
+
const unreadCount = items.filter(i => !i.read).length;
|
|
198
|
+
return { items, unreadCount };
|
|
199
|
+
} catch {
|
|
200
|
+
// Server might not be running
|
|
201
|
+
return { items: [], unreadCount: 0 };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* State update actions
|
|
207
|
+
*/
|
|
208
|
+
export type StateAction =
|
|
209
|
+
| { type: 'SET_LOADING'; loading: boolean }
|
|
210
|
+
| { type: 'SET_ERROR'; error: string | null }
|
|
211
|
+
| { type: 'SET_PROJECTS'; projects: ProjectState[] }
|
|
212
|
+
| { type: 'SET_WORKSPACES'; workspaces: WorkspaceState[] }
|
|
213
|
+
| { type: 'SELECT_PROJECT'; index: number }
|
|
214
|
+
| { type: 'SELECT_TREE_ITEM'; index: number }
|
|
215
|
+
| { type: 'TOGGLE_WORKSPACE'; workspaceName: string }
|
|
216
|
+
| { type: 'SET_ACTIVE_PANEL'; panel: 'projects' | 'workspaces' }
|
|
217
|
+
| { type: 'SET_CURRENT_PROJECT'; project: string | null }
|
|
218
|
+
| { type: 'SET_INBOX'; inbox: InboxItem[]; unreadCount: number }
|
|
219
|
+
| { type: 'MOVE_UP' }
|
|
220
|
+
| { type: 'MOVE_DOWN' }
|
|
221
|
+
| { type: 'SWITCH_PANEL' };
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* State reducer
|
|
225
|
+
*/
|
|
226
|
+
export function stateReducer(state: AppState, action: StateAction): AppState {
|
|
227
|
+
switch (action.type) {
|
|
228
|
+
case 'SET_LOADING':
|
|
229
|
+
return { ...state, isLoading: action.loading };
|
|
230
|
+
|
|
231
|
+
case 'SET_ERROR':
|
|
232
|
+
return { ...state, error: action.error };
|
|
233
|
+
|
|
234
|
+
case 'SET_PROJECTS':
|
|
235
|
+
return { ...state, projects: action.projects };
|
|
236
|
+
|
|
237
|
+
case 'SET_WORKSPACES':
|
|
238
|
+
return { ...state, workspaces: action.workspaces, selectedTreeIndex: 0 };
|
|
239
|
+
|
|
240
|
+
case 'SELECT_PROJECT': {
|
|
241
|
+
const index = Math.max(0, Math.min(action.index, state.projects.length - 1));
|
|
242
|
+
return { ...state, selectedProjectIndex: index };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
case 'SELECT_TREE_ITEM': {
|
|
246
|
+
const tree = buildTree(state.workspaces, state.expandedWorkspaces);
|
|
247
|
+
const index = Math.max(0, Math.min(action.index, tree.length - 1));
|
|
248
|
+
return { ...state, selectedTreeIndex: index };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
case 'TOGGLE_WORKSPACE': {
|
|
252
|
+
const newExpanded = new Set(state.expandedWorkspaces);
|
|
253
|
+
if (newExpanded.has(action.workspaceName)) {
|
|
254
|
+
newExpanded.delete(action.workspaceName);
|
|
255
|
+
} else {
|
|
256
|
+
newExpanded.add(action.workspaceName);
|
|
257
|
+
}
|
|
258
|
+
return { ...state, expandedWorkspaces: newExpanded };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
case 'SET_ACTIVE_PANEL':
|
|
262
|
+
return { ...state, activePanel: action.panel };
|
|
263
|
+
|
|
264
|
+
case 'SET_CURRENT_PROJECT':
|
|
265
|
+
return { ...state, currentProject: action.project };
|
|
266
|
+
|
|
267
|
+
case 'SET_INBOX':
|
|
268
|
+
return { ...state, inbox: action.inbox, unreadCount: action.unreadCount };
|
|
269
|
+
|
|
270
|
+
case 'MOVE_UP':
|
|
271
|
+
if (state.activePanel === 'projects') {
|
|
272
|
+
const index = Math.max(0, state.selectedProjectIndex - 1);
|
|
273
|
+
return { ...state, selectedProjectIndex: index };
|
|
274
|
+
} else {
|
|
275
|
+
const tree = buildTree(state.workspaces, state.expandedWorkspaces);
|
|
276
|
+
const index = Math.max(0, state.selectedTreeIndex - 1);
|
|
277
|
+
return { ...state, selectedTreeIndex: index };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
case 'MOVE_DOWN':
|
|
281
|
+
if (state.activePanel === 'projects') {
|
|
282
|
+
const index = Math.min(state.projects.length - 1, state.selectedProjectIndex + 1);
|
|
283
|
+
return { ...state, selectedProjectIndex: index };
|
|
284
|
+
} else {
|
|
285
|
+
const tree = buildTree(state.workspaces, state.expandedWorkspaces);
|
|
286
|
+
const index = Math.min(tree.length - 1, state.selectedTreeIndex + 1);
|
|
287
|
+
return { ...state, selectedTreeIndex: index };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
case 'SWITCH_PANEL':
|
|
291
|
+
return {
|
|
292
|
+
...state,
|
|
293
|
+
activePanel: state.activePanel === 'projects' ? 'workspaces' : 'projects',
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
default:
|
|
297
|
+
return state;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { BracketedPasteModeTracker, wrapPaste } from "./terminal-bracketed-paste";
|
|
3
|
+
|
|
4
|
+
describe("BracketedPasteModeTracker", () => {
|
|
5
|
+
test("defaults to disabled", () => {
|
|
6
|
+
const t = new BracketedPasteModeTracker();
|
|
7
|
+
expect(t.isEnabled).toBe(false);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("enables on CSI ? 2004 h and disables on CSI ? 2004 l", () => {
|
|
11
|
+
const t = new BracketedPasteModeTracker();
|
|
12
|
+
t.update(Buffer.from("\x1b[?2004h"));
|
|
13
|
+
expect(t.isEnabled).toBe(true);
|
|
14
|
+
t.update(Buffer.from("\x1b[?2004l"));
|
|
15
|
+
expect(t.isEnabled).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("handles sequences split across chunks", () => {
|
|
19
|
+
const t = new BracketedPasteModeTracker();
|
|
20
|
+
t.update(Buffer.from("\x1b[?20"));
|
|
21
|
+
expect(t.isEnabled).toBe(false);
|
|
22
|
+
t.update(Buffer.from("04h"));
|
|
23
|
+
expect(t.isEnabled).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("last occurrence wins when both appear", () => {
|
|
27
|
+
const t = new BracketedPasteModeTracker();
|
|
28
|
+
t.update(Buffer.from("\x1b[?2004h...\x1b[?2004l"));
|
|
29
|
+
expect(t.isEnabled).toBe(false);
|
|
30
|
+
t.update(Buffer.from("\x1b[?2004l...\x1b[?2004h"));
|
|
31
|
+
expect(t.isEnabled).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("wrapPaste", () => {
|
|
36
|
+
test("wraps when enabled", () => {
|
|
37
|
+
expect(wrapPaste("hi", true)).toBe("\x1b[200~hi\x1b[201~");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("passes through when disabled", () => {
|
|
41
|
+
expect(wrapPaste("hi", false)).toBe("hi");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bracketed paste mode tracking (DECSET 2004).
|
|
3
|
+
*
|
|
4
|
+
* Remote programs can enable bracketed paste by emitting CSI ? 2004 h and
|
|
5
|
+
* disable it with CSI ? 2004 l. When enabled, pasted content should be wrapped
|
|
6
|
+
* in ESC[200~ ... ESC[201~ so shells/editors can treat it as a paste.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const ENABLE = Buffer.from("\x1b[?2004h");
|
|
10
|
+
const DISABLE = Buffer.from("\x1b[?2004l");
|
|
11
|
+
|
|
12
|
+
export class BracketedPasteModeTracker {
|
|
13
|
+
private enabled = false;
|
|
14
|
+
private tail = Buffer.alloc(0);
|
|
15
|
+
|
|
16
|
+
get isEnabled(): boolean {
|
|
17
|
+
return this.enabled;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Update mode based on an output chunk from the remote PTY.
|
|
22
|
+
* Handles sequences split across chunks by keeping a short tail.
|
|
23
|
+
*/
|
|
24
|
+
update(chunk: Buffer): void {
|
|
25
|
+
if (chunk.length === 0) return;
|
|
26
|
+
|
|
27
|
+
const scanBuf = this.tail.length > 0 ? Buffer.concat([this.tail, chunk]) : chunk;
|
|
28
|
+
|
|
29
|
+
const lastEnable = scanBuf.lastIndexOf(ENABLE);
|
|
30
|
+
const lastDisable = scanBuf.lastIndexOf(DISABLE);
|
|
31
|
+
if (lastEnable !== -1 || lastDisable !== -1) {
|
|
32
|
+
this.enabled = lastEnable > lastDisable;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const keep = Math.max(ENABLE.length, DISABLE.length) - 1;
|
|
36
|
+
this.tail = scanBuf.length > keep
|
|
37
|
+
? Buffer.from(scanBuf.subarray(scanBuf.length - keep))
|
|
38
|
+
: Buffer.from(scanBuf);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function wrapPaste(text: string, bracketedPasteEnabled: boolean): string {
|
|
43
|
+
if (!bracketedPasteEnabled) return text;
|
|
44
|
+
return `\x1b[200~${text}\x1b[201~`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|