diff --git a/package-lock.json b/package-lock.json
index 207b68f..b8ce643 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,8 @@
"@inkjs/ui": "^2.0.0",
"@sentry/node": "^9.29.0",
"@tanstack/react-query": "^5.76.1",
+ "chalk": "^5.4.1",
+ "date-fns": "^4.1.0",
"ink": "^5.2.1",
"isbinaryfile": "^5.0.4",
"isomorphic-ws": "^5.0.0",
@@ -793,18 +795,6 @@
"ink": ">=5"
}
},
- "node_modules/@inkjs/ui/node_modules/chalk": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
- "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
- "license": "MIT",
- "engines": {
- "node": "^12.17.0 || ^14.13 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
- }
- },
"node_modules/@inkjs/ui/node_modules/cli-spinners": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.2.0.tgz",
@@ -2304,6 +2294,23 @@
"x256": ">=0.0.1"
}
},
+ "node_modules/blessed-contrib/node_modules/chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^2.2.1",
+ "escape-string-regexp": "^1.0.2",
+ "has-ansi": "^2.0.0",
+ "strip-ansi": "^3.0.0",
+ "supports-color": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/blessed-contrib/node_modules/strip-ansi": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
@@ -2590,33 +2597,15 @@
}
},
"node_modules/chalk": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
- "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==",
- "dev": true,
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
+ "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
"license": "MIT",
- "dependencies": {
- "ansi-styles": "^2.2.1",
- "escape-string-regexp": "^1.0.2",
- "has-ansi": "^2.0.0",
- "strip-ansi": "^3.0.0",
- "supports-color": "^2.0.0"
- },
"engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/chalk/node_modules/strip-ansi": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
- "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^2.0.0"
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
},
- "engines": {
- "node": ">=0.10.0"
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/charm": {
@@ -3087,6 +3076,15 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@@ -4099,18 +4097,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/ink/node_modules/chalk": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
- "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
- "license": "MIT",
- "engines": {
- "node": "^12.17.0 || ^14.13 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
- }
- },
"node_modules/ink/node_modules/cli-cursor": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
@@ -4528,18 +4514,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/log-symbols/node_modules/chalk": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
- "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
- "license": "MIT",
- "engines": {
- "node": "^12.17.0 || ^14.13 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
- }
- },
"node_modules/log-symbols/node_modules/is-unicode-supported": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
@@ -4626,19 +4600,6 @@
"marked": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0"
}
},
- "node_modules/marked-terminal/node_modules/chalk": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
- "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.17.0 || ^14.13 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
- }
- },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5061,18 +5022,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/ora/node_modules/chalk": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
- "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
- "license": "MIT",
- "engines": {
- "node": "^12.17.0 || ^14.13 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
- }
- },
"node_modules/ora/node_modules/emoji-regex": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
diff --git a/package.json b/package.json
index 96a62e8..ca7b386 100644
--- a/package.json
+++ b/package.json
@@ -98,6 +98,8 @@
"@inkjs/ui": "^2.0.0",
"@sentry/node": "^9.29.0",
"@tanstack/react-query": "^5.76.1",
+ "chalk": "^5.4.1",
+ "date-fns": "^4.1.0",
"ink": "^5.2.1",
"isbinaryfile": "^5.0.4",
"isomorphic-ws": "^5.0.0",
diff --git a/src/bin/main.tsx b/src/bin/main.tsx
index 0595e3c..944de98 100644
--- a/src/bin/main.tsx
+++ b/src/bin/main.tsx
@@ -6,13 +6,14 @@ import { buildCommand } from "./commands/build";
import { sandboxesCommand } from "./commands/sandbox";
import { previewHostsCommand } from "./commands/previewHosts";
import { hostTokensCommand } from "./commands/hostTokens";
-import { Dashboard } from "./ui/Dashboard";
+import { App } from "./ui/App";
import React from "react";
import { SDKProvider } from "./ui/sdkContext";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ViewProvider } from "./ui/viewContext";
if (process.argv.length === 2) {
- // Clear the screen before rendering the dashboard
+ // Clear the screen before rendering the App
process.stdout.write("\x1Bc");
const queryClient = new QueryClient();
@@ -20,7 +21,9 @@ if (process.argv.length === 2) {
render(
-
+
+
+
,
{
diff --git a/src/bin/ui/App.tsx b/src/bin/ui/App.tsx
new file mode 100644
index 0000000..fb0a43e
--- /dev/null
+++ b/src/bin/ui/App.tsx
@@ -0,0 +1,21 @@
+import React from "react";
+import { Box, Text } from "ink";
+import { Dashboard } from "./views/Dashboard";
+import { useView } from "./viewContext";
+import { Sandbox } from "./views/Sandbox";
+import { useTerminalSize } from "./hooks/useTerminalSize";
+
+export function App() {
+ const [stdoutWidth, stdoutHeight] = useTerminalSize();
+ const { view } = useView();
+
+ return (
+
+
+ □ CodeSandbox SDK
+
+ {view.name === "dashboard" && }
+ {view.name === "sandbox" && }
+
+ );
+}
diff --git a/src/bin/ui/Dashboard.tsx b/src/bin/ui/Dashboard.tsx
deleted file mode 100644
index c8f5ad8..0000000
--- a/src/bin/ui/Dashboard.tsx
+++ /dev/null
@@ -1,260 +0,0 @@
-import React, { memo, useEffect, useRef, useState } from "react";
-import { Box, Text, useInput, useStdout } from "ink";
-import { useSDK } from "./sdkContext";
-import { useQuery } from "@tanstack/react-query";
-import { getSandbox, getRunningVms } from "./api";
-
-// Custom hook to get terminal size
-function useTerminalSize() {
- const { stdout } = useStdout();
- const [size, setSize] = useState([stdout?.columns || 80, stdout?.rows || 24]);
- useEffect(() => {
- if (!stdout) return undefined;
- const handler = () => setSize([stdout.columns, stdout.rows]);
- stdout.on("resize", handler);
- return () => {
- stdout.off("resize", handler);
- };
- }, [stdout]);
- return size;
-}
-
-// Component to open a sandbox by ID
-export function Dashboard() {
- const { apiClient } = useSDK();
-
- // Poll getRunningVms API every 2 seconds
- const runningVmsQuery = useQuery({
- queryKey: ["runningVms"],
- queryFn: () => getRunningVms(apiClient),
- });
-
- const [sandboxId, setSandboxId] = useState("");
- const [showSandbox, setShowSandbox] = useState(false);
- const [isFocused, setIsFocused] = useState(true);
- const [stdoutWidth, stdoutHeight] = useTerminalSize();
-
- useEffect(() => {
- // have to manually do this because of environment
- const interval = setInterval(() => {
- runningVmsQuery.refetch();
- }, 2000);
-
- return () => {
- clearInterval(interval);
- };
- }, []);
-
- useInput((input, key) => {
- if (!showSandbox) {
- if (key.return) {
- if (sandboxId.trim()) {
- setShowSandbox(true);
- }
- } else if (key.backspace || key.delete) {
- setSandboxId((prev) => prev.slice(0, -1));
- } else if (input && !key.ctrl && !key.meta && !key.shift) {
- // Only add printable characters
- setSandboxId((prev) => prev + input);
- }
- }
- });
-
- if (showSandbox) {
- const state = runningVmsQuery.isLoading
- ? "PENDING"
- : runningVmsQuery.data?.vms.find((vm) => vm.id === sandboxId)
- ? "RUNNING"
- : "IDLE";
-
- return (
- setShowSandbox(false)}
- />
- );
- }
-
- return (
-
-
- CodeSandbox
-
-
- Enter Sandbox ID:
- {sandboxId || "_"}
-
-
- Type to input ID, press ENTER to open
-
-
- );
-}
-
-// Component to display a sandbox
-const Sandbox = memo(
- ({
- id,
- runningState,
- onBack,
- }: {
- id: string;
- runningState: "RUNNING" | "IDLE" | "PENDING";
- onBack: () => void;
- }) => {
- const sandboxQuery = useQuery({
- queryKey: ["sandbox", id],
- queryFn: () => getSandbox(apiClient, id),
- });
- const runningStateRef = useRef(runningState);
-
- const { sdk, apiClient } = useSDK();
-
- // Only two states: RUNNING or IDLE
- const [sandboxState, setSandboxState] = useState<
- "RUNNING" | "IDLE" | "PENDING"
- >(runningState);
- const [selectedOption, setSelectedOption] = useState(0);
- const [stdoutWidth, stdoutHeight] = useTerminalSize();
-
- // We only want to update the state when the
- // running state has ACTUALLY changed (Reconciliation sucks)
- useEffect(() => {
- if (
- sandboxState !== "PENDING" &&
- runningStateRef.current !== runningState
- ) {
- runningStateRef.current = runningState;
- setSandboxState(runningState);
- }
- }, [runningState, sandboxState]);
-
- // Define menu options based on state
- const getMenuOptions = () => {
- switch (sandboxState) {
- case "RUNNING":
- return ["Hibernate", "Shutdown", "Restart"];
- case "IDLE":
- return ["Start"];
- default:
- return [];
- }
- };
-
- const menuOptions = getMenuOptions();
-
- // Handle menu options
- const handleAction = async (action: string) => {
- switch (action) {
- case "Hibernate":
- case "Shutdown":
- setSandboxState("PENDING");
- await sdk.sandboxes.shutdown(id);
- setSandboxState("IDLE");
- setSelectedOption(0);
- break;
- case "Restart":
- setSandboxState("PENDING");
- await sdk.sandboxes.restart(id);
- setSandboxState("RUNNING");
- setSelectedOption(0);
- break;
- case "Start":
- setSandboxState("PENDING");
- await sdk.sandboxes.resume(id);
- setSandboxState("RUNNING");
- setSelectedOption(0);
- break;
- }
- };
-
- // Handle keyboard navigation
- useInput((input, key) => {
- if (key.escape) {
- onBack();
- } else if (menuOptions.length > 0) {
- if (key.upArrow) {
- setSelectedOption((prev) => (prev > 0 ? prev - 1 : prev));
- } else if (key.downArrow) {
- setSelectedOption((prev) =>
- prev < menuOptions.length - 1 ? prev + 1 : prev
- );
- } else if (key.return) {
- handleAction(menuOptions[selectedOption]);
- }
- }
- });
-
- return (
-
- {/* Handle query states */}
- {sandboxQuery.isLoading && (
-
- Loading sandbox information...
-
- )}
-
- {sandboxQuery.error && (
-
-
- Error loading sandbox: {(sandboxQuery.error as Error).message}
-
-
- )}
-
- {sandboxQuery.data && (
-
-
- {sandboxQuery.data.title} - {id}
-
-
- {sandboxQuery.data.description && (
-
- {sandboxQuery.data.description}
-
- )}
-
- )}
-
- {/* Status display - moved above title and description */}
-
- Status:
-
- {sandboxState}
-
-
-
- {menuOptions.length > 0 && (
-
- Actions:
- {menuOptions.map((option, index) => (
-
-
- {selectedOption === index ? "> " : " "}
- {option}
-
-
- ))}
-
- )}
-
-
-
- {menuOptions.length > 0
- ? "Use arrow keys to navigate, Enter to select, ESC to go back"
- : "Press ESC to go back"}
-
-
-
- );
- }
-);
diff --git a/src/bin/ui/components/Table.tsx b/src/bin/ui/components/Table.tsx
new file mode 100644
index 0000000..0a0788f
--- /dev/null
+++ b/src/bin/ui/components/Table.tsx
@@ -0,0 +1,108 @@
+import React from "react";
+import { Box, Text } from "ink";
+
+interface TableProps {
+ marginTop?: number;
+ renderHeader: () => React.ReactNode;
+ renderBody: (totalWidth: number) => React.ReactNode;
+}
+
+export const Table = ({ renderHeader, renderBody }: TableProps) => {
+ // Calculate total width from TableHeader children
+ const calculateTotalWidth = () => {
+ let totalWidth = 0;
+
+ const headerElement = renderHeader();
+
+ if (
+ React.isValidElement(headerElement) &&
+ headerElement.type === TableHeader
+ ) {
+ React.Children.forEach(
+ (headerElement.props as any).children,
+ (headerChild) => {
+ if (
+ React.isValidElement(headerChild) &&
+ headerChild.type === TableColumn
+ ) {
+ totalWidth += (headerChild.props as any).width || 0;
+ }
+ }
+ );
+ }
+
+ return totalWidth;
+ };
+
+ const totalWidth = calculateTotalWidth();
+
+ return (
+
+ {renderHeader()}
+ {renderBody(totalWidth)}
+
+ );
+};
+
+interface TableHeaderProps {
+ children: React.ReactNode;
+}
+
+export const TableHeader = ({ children }: TableHeaderProps) => (
+ {children}
+);
+
+interface TableBodyProps {
+ children: React.ReactNode;
+ totalWidth: number;
+}
+
+export const TableBody = ({ children, totalWidth }: TableBodyProps) => (
+
+
+ {"─".repeat(Math.max(totalWidth, 50))}
+
+ {children}
+
+);
+
+interface TableRowProps {
+ children: React.ReactNode;
+ isSelected?: boolean;
+}
+
+export const TableRow = ({ children, isSelected = false }: TableRowProps) => (
+
+ {React.Children.map(children, (child) => {
+ if (React.isValidElement(child) && child.type === TableColumn) {
+ return React.cloneElement(child, { inverse: isSelected } as any);
+ }
+ return child;
+ })}
+
+);
+
+interface TableColumnProps {
+ children: React.ReactNode;
+ width?: number;
+ bold?: boolean;
+ inverse?: boolean;
+}
+
+export const TableColumn = ({
+ children,
+ width,
+ bold = false,
+ inverse = false,
+}: TableColumnProps) => (
+
+
+ {children}
+
+
+);
diff --git a/src/bin/ui/components/TextInput.tsx b/src/bin/ui/components/TextInput.tsx
new file mode 100644
index 0000000..7da9777
--- /dev/null
+++ b/src/bin/ui/components/TextInput.tsx
@@ -0,0 +1,130 @@
+import React, { useState, useEffect, useRef } from "react";
+import { Box, Text, useInput, type Key } from "ink";
+import chalk from "chalk";
+
+interface TextInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ onSubmit?: () => void;
+ isFocused?: boolean;
+ showCursor?: boolean;
+}
+
+// Helper function to check if a key is a regular printable character
+const isRegularKey = (key: Key) => {
+ return (
+ !key.ctrl &&
+ !key.meta &&
+ !key.shift &&
+ !key.upArrow &&
+ !key.downArrow &&
+ !key.return &&
+ !key.backspace &&
+ !key.delete &&
+ !key.tab &&
+ !key.escape &&
+ !key.leftArrow &&
+ !key.rightArrow
+ );
+};
+
+export const TextInput: React.FC = ({
+ value,
+ onChange,
+ onSubmit,
+ isFocused = true,
+ showCursor = true,
+}) => {
+ const [cursorPosition, setCursorPosition] = useState(value.length);
+ const isInternalChange = useRef(false);
+
+ // Only update cursor position when value changes externally
+ useEffect(() => {
+ if (!isInternalChange.current) {
+ setCursorPosition(value.length);
+ }
+ isInternalChange.current = false;
+ }, [value]);
+
+ useInput(
+ (input, key) => {
+ if (key.return && onSubmit) {
+ onSubmit();
+ } else if (key.leftArrow) {
+ setCursorPosition(Math.max(0, cursorPosition - 1));
+ } else if (key.rightArrow) {
+ setCursorPosition(Math.min(value.length, cursorPosition + 1));
+ } else if (key.backspace || key.delete) {
+ // Both backspace and delete remove the character before the cursor
+ if (cursorPosition > 0) {
+ const newValue =
+ value.slice(0, cursorPosition - 1) + value.slice(cursorPosition);
+ const newCursorPosition = cursorPosition - 1;
+ isInternalChange.current = true;
+
+ onChange(newValue);
+ setCursorPosition(newCursorPosition);
+ }
+ } else if (input && isRegularKey(key)) {
+ // Insert character(s) at cursor position
+ const newValue =
+ value.slice(0, cursorPosition) + input + value.slice(cursorPosition);
+ let newCursorPosition = cursorPosition + input.length;
+
+ // Ensure cursor stays within bounds
+ if (newCursorPosition < 0) {
+ newCursorPosition = 0;
+ }
+ if (newCursorPosition > newValue.length) {
+ newCursorPosition = newValue.length;
+ }
+
+ isInternalChange.current = true;
+ onChange(newValue);
+ setCursorPosition(newCursorPosition);
+ }
+ },
+ { isActive: isFocused }
+ );
+
+ const renderText = () => {
+ const displayValue = value || "";
+
+ if (!showCursor || !isFocused) {
+ // When cursor is hidden, just show the text
+ return {displayValue};
+ }
+
+ let renderedValue = "";
+
+ if (displayValue.length === 0) {
+ // Show cursor when there's no text
+ renderedValue = chalk.inverse(" ");
+ } else {
+ // Show value with cursor
+ renderedValue = "";
+
+ for (let i = 0; i < displayValue.length; i++) {
+ const char = displayValue[i];
+ if (i === cursorPosition) {
+ renderedValue += chalk.inverse(char);
+ } else {
+ renderedValue += char;
+ }
+ }
+
+ // Add cursor at the end if position is at the end
+ if (cursorPosition === displayValue.length) {
+ renderedValue += chalk.inverse(" ");
+ }
+ }
+
+ return {renderedValue};
+ };
+
+ return (
+
+ {renderText()}
+
+ );
+};
diff --git a/src/bin/ui/components/VmTable.tsx b/src/bin/ui/components/VmTable.tsx
new file mode 100644
index 0000000..6ff3f4e
--- /dev/null
+++ b/src/bin/ui/components/VmTable.tsx
@@ -0,0 +1,123 @@
+import React from "react";
+import { Box, Text } from "ink";
+import { Table, TableHeader, TableBody, TableRow, TableColumn } from "./Table";
+import { format, parseISO } from "date-fns";
+
+const formatDate = (dateString: string | undefined): string => {
+ if (!dateString) return "N/A";
+
+ try {
+ const date = parseISO(dateString);
+ return format(date, "d MMMM yyyy 'at' HH:mm 'UTC'");
+ } catch (error) {
+ return "Invalid date";
+ }
+};
+
+const calculateRuntime = (startedAt: string | undefined, lastActiveAt: string | undefined): string => {
+ if (!startedAt || !lastActiveAt) {
+ return "N/A"
+ };
+
+ try {
+ const startDate = parseISO(startedAt);
+ const lastActiveDate = parseISO(lastActiveAt);
+
+ // Calculate difference in milliseconds
+ const diffMs = lastActiveDate.getTime() - startDate.getTime();
+
+ if (diffMs < 0) return "N/A";
+
+ // Convert to seconds, minutes, hours
+ const totalSeconds = Math.floor(diffMs / 1000);
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+
+ // Format output
+ let result = "";
+
+ if (hours > 0) {
+ result += `${hours}h `;
+ }
+
+ if (minutes > 0) {
+ result += `${minutes}m `;
+ }
+
+ if (seconds > 0 || result === "") {
+ result += `${seconds}s`;
+ }
+
+ return result.trim();
+ } catch (error) {
+ return "N/A";
+ }
+};
+
+interface VmData {
+ id?: string;
+ credit_basis?: string;
+ last_active_at?: string;
+ session_started_at?: string;
+ specs?: {
+ cpu?: number;
+ memory?: number;
+ storage?: number;
+ };
+}
+
+interface VmTableProps {
+ vms: VmData[];
+ selectedIndex: number;
+ onSelect: (index: number, vmId: string) => void;
+}
+
+export const VmTable = ({ vms, selectedIndex, onSelect }: VmTableProps) => {
+ const columnWidths = {
+ id: 20,
+ lastActive: 28,
+ startedAt: 28,
+ runtime: 14,
+ creditBasis: 20,
+ };
+
+ if (vms.length === 0) {
+ return (
+
+ Running VMs
+ No running VMs found.
+
+ );
+ }
+
+ return (
+
+ Running VMs
+ (
+
+ VM ID
+ Last Active
+ Started At
+ Runtime
+ Credit Basis
+
+ )}
+ renderBody={(totalWidth) => (
+
+ {vms.map((vm, index) => (
+
+ {vm.id || "N/A"}
+ {formatDate(vm.last_active_at)}
+ {formatDate(vm.session_started_at)}
+ {calculateRuntime(vm.session_started_at, vm.last_active_at)}
+ {vm.credit_basis || "N/A"} credits / hour
+
+ ))}
+
+ )}
+ />
+
+ );
+};
\ No newline at end of file
diff --git a/src/bin/ui/hooks/useTerminalSize.ts b/src/bin/ui/hooks/useTerminalSize.ts
new file mode 100644
index 0000000..27e9ba3
--- /dev/null
+++ b/src/bin/ui/hooks/useTerminalSize.ts
@@ -0,0 +1,16 @@
+import { useStdout } from "ink";
+import { useEffect, useState } from "react";
+
+export function useTerminalSize() {
+ const { stdout } = useStdout();
+ const [size, setSize] = useState([stdout?.columns || 80, stdout?.rows || 24]);
+ useEffect(() => {
+ if (!stdout) return undefined;
+ const handler = () => setSize([stdout.columns, stdout.rows]);
+ stdout.on("resize", handler);
+ return () => {
+ stdout.off("resize", handler);
+ };
+ }, [stdout]);
+ return size;
+}
diff --git a/src/bin/ui/hooks/useVmInput.ts b/src/bin/ui/hooks/useVmInput.ts
new file mode 100644
index 0000000..0a24981
--- /dev/null
+++ b/src/bin/ui/hooks/useVmInput.ts
@@ -0,0 +1,73 @@
+import { useState } from "react";
+import { useInput } from "ink";
+
+interface VmData {
+ id?: string;
+ [key: string]: any;
+}
+
+interface UseVmInputOptions {
+ vms?: VmData[];
+ onSubmit: (id: string) => void;
+}
+
+export const useVmInput = ({ vms, onSubmit }: UseVmInputOptions) => {
+ const [sandboxId, setSandboxId] = useState("");
+ const [selectedVm, setSelectedVm] = useState(null);
+ const [selectedVmIndex, setSelectedVmIndex] = useState(-1);
+
+ const handleInputChange = (value: string) => {
+ setSandboxId(value);
+
+ // Clear VM selection when user types manually
+ if (selectedVm) {
+ setSelectedVm(null);
+ setSelectedVmIndex(-1);
+ }
+ };
+
+ const handleInputSubmit = () => {
+ if (selectedVm) {
+ onSubmit(selectedVm);
+ } else if (sandboxId.trim()) {
+ onSubmit(sandboxId);
+ }
+ };
+
+ const handleVmSelect = (index: number, vmId: string) => {
+ setSelectedVmIndex(index);
+ setSelectedVm(vmId);
+ };
+
+ useInput((_input, key) => {
+ if (key.upArrow || key.downArrow) {
+ if (vms && vms.length > 0) {
+ let newIndex = selectedVmIndex;
+
+ if (key.upArrow) {
+ newIndex = selectedVmIndex <= 0 ? vms.length - 1 : selectedVmIndex - 1;
+ } else if (key.downArrow) {
+ newIndex = selectedVmIndex >= vms.length - 1 ? 0 : selectedVmIndex + 1;
+ }
+
+ setSelectedVmIndex(newIndex);
+ const vmId = vms[newIndex]?.id || null;
+ setSelectedVm(vmId);
+
+ // Set the selected VM ID in the text input
+ if (vmId) {
+ setSandboxId(vmId);
+ }
+ }
+ }
+ });
+
+ return {
+ sandboxId,
+ selectedVm,
+ selectedVmIndex,
+ handleInputChange,
+ handleInputSubmit,
+ handleVmSelect,
+ };
+};
\ No newline at end of file
diff --git a/src/bin/ui/viewContext.tsx b/src/bin/ui/viewContext.tsx
new file mode 100644
index 0000000..7283766
--- /dev/null
+++ b/src/bin/ui/viewContext.tsx
@@ -0,0 +1,35 @@
+import React, { createContext, useContext, useState } from "react";
+
+type ViewState =
+ | { name: "dashboard" }
+ | { name: "sandbox"; params: { id: string } };
+
+export const ViewContext = createContext<{
+ view: ViewState;
+ setView: (view: ViewState) => void;
+}>({
+ view: { name: "dashboard" },
+ setView: () => {},
+});
+
+export const ViewProvider = ({ children }: { children: React.ReactNode }) => {
+ const [view, setView] = useState({
+ name: "dashboard",
+ });
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useView = () => {
+ const { view, setView } = useContext(ViewContext);
+ const typedView = view as Extract;
+
+ return {
+ view: typedView,
+ setView,
+ };
+};
diff --git a/src/bin/ui/views/Dashboard.tsx b/src/bin/ui/views/Dashboard.tsx
new file mode 100644
index 0000000..d7c9b10
--- /dev/null
+++ b/src/bin/ui/views/Dashboard.tsx
@@ -0,0 +1,92 @@
+import React from "react";
+import { Box, Text } from "ink";
+import { useView } from "../viewContext";
+import { getRunningVms } from "../api";
+import { useQuery } from "@tanstack/react-query";
+import { useSDK } from "../sdkContext";
+import { TextInput } from "../components/TextInput";
+import { VmTable } from "../components/VmTable";
+import { useVmInput } from "../hooks/useVmInput";
+
+export const Dashboard = () => {
+ const { apiClient } = useSDK();
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["runningVms"],
+ queryFn: () => getRunningVms(apiClient),
+ });
+
+ const { setView } = useView();
+
+ const {
+ sandboxId,
+ selectedVm,
+ selectedVmIndex,
+ handleInputChange,
+ handleInputSubmit,
+ handleVmSelect,
+ } = useVmInput({
+ vms: data?.vms,
+ onSubmit: (id: string) => {
+ setView({ name: "sandbox", params: { id } });
+ },
+ });
+
+ // Cursor is shown when no VM is selected (user typed manually)
+ const showCursor = selectedVm === null;
+
+ const renderVmSection = () => {
+ if (isLoading) {
+ return (
+
+ Running VMs
+
+ Loading VM data...
+
+
+ );
+ }
+
+ if (data?.vms && data.vms.length > 0) {
+ // TODO: Fix type mismatch - API types are being updated
+ return (
+
+ );
+ }
+
+ return (
+
+ Running VMs
+
+ No running VMs found.
+
+
+ );
+ };
+
+ return (
+
+
+ Start typing to input an ID or use ↑/↓ arrows to select from running VMs. Press ENTER to view VM details.
+
+
+
+ Sandbox ID
+
+
+
+
+
+ {renderVmSection()}
+
+ );
+};
diff --git a/src/bin/ui/views/Sandbox.tsx b/src/bin/ui/views/Sandbox.tsx
new file mode 100644
index 0000000..62e48fd
--- /dev/null
+++ b/src/bin/ui/views/Sandbox.tsx
@@ -0,0 +1,192 @@
+import React, { useEffect, useRef, useState } from "react";
+import { Box, Text, useInput } from "ink";
+import { useView } from "../viewContext";
+import { useQuery } from "@tanstack/react-query";
+import { useSDK } from "../sdkContext";
+import { getRunningVms, getSandbox } from "../api";
+
+export const Sandbox = () => {
+ const { view, setView } = useView<"sandbox">();
+
+ // Poll getRunningVms API every 2 seconds
+ const runningVmsQuery = useQuery({
+ queryKey: ["runningVms"],
+ queryFn: () => getRunningVms(apiClient),
+ });
+
+ useEffect(() => {
+ // have to manually do this because of environment
+ const interval = setInterval(() => {
+ runningVmsQuery.refetch();
+ }, 2000);
+
+ return () => {
+ clearInterval(interval);
+ };
+ }, []);
+
+ const sandboxQuery = useQuery({
+ queryKey: ["sandbox", view.params.id],
+ queryFn: () => getSandbox(apiClient, view.params.id),
+ enabled: !!view.params.id,
+ });
+
+ const runningState = runningVmsQuery.isLoading
+ ? "PENDING"
+ : runningVmsQuery.data?.vms.find((vm) => vm.id === view.params.id)
+ ? "RUNNING"
+ : "IDLE";
+
+ const runningStateRef = useRef(runningState);
+
+ const { sdk, apiClient } = useSDK();
+
+ // Only two states: RUNNING or IDLE
+ const [sandboxState, setSandboxState] = useState<
+ "RUNNING" | "IDLE" | "PENDING"
+ >(runningState);
+ const [selectedOption, setSelectedOption] = useState(0);
+
+ // We only want to update the state when the
+ // running state has ACTUALLY changed (Reconciliation sucks)
+ useEffect(() => {
+ if (
+ sandboxState !== "PENDING" &&
+ runningStateRef.current !== runningState
+ ) {
+ runningStateRef.current = runningState;
+ setSandboxState(runningState);
+ }
+ }, [runningState, sandboxState]);
+
+ // Define menu options based on state
+ const getMenuOptions = () => {
+ switch (sandboxState) {
+ case "RUNNING":
+ return ["Hibernate", "Shutdown", "Restart"];
+ case "IDLE":
+ return ["Start"];
+ default:
+ return [];
+ }
+ };
+
+ const menuOptions = getMenuOptions();
+
+ // Handle menu options
+ const handleAction = async (action: string) => {
+ switch (action) {
+ case "Hibernate":
+ case "Shutdown":
+ setSandboxState("PENDING");
+ await sdk.sandboxes.shutdown(view.params.id);
+ setSandboxState("IDLE");
+ setSelectedOption(0);
+ break;
+ case "Restart":
+ setSandboxState("PENDING");
+ await sdk.sandboxes.restart(view.params.id);
+ setSandboxState("RUNNING");
+ setSelectedOption(0);
+ break;
+ case "Start":
+ setSandboxState("PENDING");
+ await sdk.sandboxes.resume(view.params.id);
+ setSandboxState("RUNNING");
+ setSelectedOption(0);
+ break;
+ }
+ };
+
+ // Handle keyboard navigation
+ useInput((input, key) => {
+ if (key.escape) {
+ setView({ name: "dashboard" });
+ } else if (menuOptions.length > 0) {
+ if (key.upArrow) {
+ setSelectedOption((prev) => (prev > 0 ? prev - 1 : prev));
+ } else if (key.downArrow) {
+ setSelectedOption((prev) =>
+ prev < menuOptions.length - 1 ? prev + 1 : prev
+ );
+ } else if (key.return) {
+ handleAction(menuOptions[selectedOption]);
+ }
+ }
+ });
+
+ if (!view.params.id) {
+ return No sandbox ID provided. Press escape to go back.;
+ }
+
+ return (
+
+ {/* Handle query states */}
+ {sandboxQuery.isLoading && (
+
+ Loading sandbox information...
+
+ )}
+
+ {sandboxQuery.error && (
+
+
+ Error loading sandbox: {(sandboxQuery.error as Error).message}
+
+
+ )}
+
+ {sandboxQuery.data && (
+
+
+ {sandboxQuery.data.title} - {view.params.id}
+
+
+ {sandboxQuery.data.description && (
+
+ {sandboxQuery.data.description}
+
+ )}
+
+ )}
+
+ {/* Status display - moved above title and description */}
+
+ Status:
+
+ {sandboxState}
+
+
+
+ {menuOptions.length > 0 && (
+
+ Actions:
+ {menuOptions.map((option, index) => (
+
+
+ {selectedOption === index ? "> " : " "}
+ {option}
+
+
+ ))}
+
+ )}
+
+
+
+ {menuOptions.length > 0
+ ? "Use arrow keys to navigate, Enter to select, ESC to go back"
+ : "Press ESC to go back"}
+
+
+
+ );
+};