diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx
index 358072a5..7a50e627 100644
--- a/src/commands/blueprint/list.tsx
+++ b/src/commands/blueprint/list.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { Box, Text, useInput, useApp } from "ink";
+import { Box, Text, useApp } from "ink";
import TextInput from "ink-text-input";
import figures from "figures";
import type { BlueprintsCursorIDPage } from "@runloop/api-client/pagination";
@@ -24,6 +24,11 @@ import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
import { useViewportHeight } from "../../hooks/useViewportHeight.js";
import { useCursorPagination } from "../../hooks/useCursorPagination.js";
import { useListSearch } from "../../hooks/useListSearch.js";
+import { openInBrowser } from "../../utils/browser.js";
+import {
+ useInputHandler,
+ type InputMode,
+} from "../../hooks/useInputHandler.js";
import { useNavigation } from "../../store/navigationStore.js";
import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js";
@@ -426,194 +431,258 @@ const ListBlueprintsUI = ({
})
: allOperations;
- // Handle input for all views
- useInput((input, key) => {
- // Handle search mode input
- if (search.searchMode) {
- if (key.escape) {
- search.cancelSearch();
- }
- return;
- }
+ // --- Callbacks for input modes ---
- // Handle operation input mode
- if (executingOperation && !operationResult && !operationError) {
- // Allow escape/q to cancel any operation, even during loading
- if (input === "q" || key.escape) {
- setExecutingOperation(null);
- setOperationInput("");
- setOperationLoading(false);
- return;
- }
+ const cancelOperation = React.useCallback(() => {
+ setExecutingOperation(null);
+ setOperationInput("");
+ setOperationLoading(false);
+ }, []);
- const currentOp = allOperations.find(
- (op) => op.key === executingOperation,
- );
- if (currentOp?.needsInput) {
- if (key.return) {
- executeOperation();
- }
- }
- return;
- }
+ const dismissResult = React.useCallback(() => {
+ setOperationResult(null);
+ setOperationError(null);
+ setExecutingOperation(null);
+ setOperationInput("");
+ }, []);
- // Handle operation result display
- if (operationResult || operationError) {
- if (input === "q" || key.escape || key.return) {
- setOperationResult(null);
- setOperationError(null);
- setExecutingOperation(null);
- setOperationInput("");
- }
- return;
- }
+ const closePopup = React.useCallback(() => {
+ setShowPopup(false);
+ setSelectedOperation(0);
+ }, []);
- // Handle create devbox view
- if (showCreateDevbox) {
- return;
- }
+ const executePopupSelection = React.useCallback(() => {
+ setShowPopup(false);
+ const operationKey = allOperations[selectedOperation].key;
- // Handle actions popup overlay
- if (showPopup) {
- if (key.upArrow && selectedOperation > 0) {
- setSelectedOperation(selectedOperation - 1);
- } else if (
- key.downArrow &&
- selectedOperation < allOperations.length - 1
- ) {
- setSelectedOperation(selectedOperation + 1);
- } else if (key.return) {
- setShowPopup(false);
- const operationKey = allOperations[selectedOperation].key;
-
- if (operationKey === "view_details") {
- navigate("blueprint-detail", {
- blueprintId: selectedBlueprintItem.id,
- });
- } else if (operationKey === "create_devbox") {
- setSelectedBlueprint(selectedBlueprintItem);
- setShowCreateDevbox(true);
- } else if (operationKey === "delete") {
- // Show delete confirmation
- setSelectedBlueprint(selectedBlueprintItem);
- setShowDeleteConfirm(true);
- } else {
- setSelectedBlueprint(selectedBlueprintItem);
- setExecutingOperation(operationKey as OperationType);
- executeOperation(
- selectedBlueprintItem,
- operationKey as OperationType,
- );
- }
- } else if (input === "v" && selectedBlueprintItem) {
- // View details hotkey
- setShowPopup(false);
- navigate("blueprint-detail", {
- blueprintId: selectedBlueprintItem.id,
- });
- } else if (key.escape || input === "q") {
- setShowPopup(false);
- setSelectedOperation(0);
- } else if (input === "c") {
- if (
- selectedBlueprintItem &&
- (selectedBlueprintItem.status === "build_complete" ||
- selectedBlueprintItem.status === "building_complete")
- ) {
- setShowPopup(false);
- setSelectedBlueprint(selectedBlueprintItem);
- setShowCreateDevbox(true);
- }
- } else if (input === "d") {
- const deleteIndex = allOperations.findIndex(
- (op) => op.key === "delete",
- );
- if (deleteIndex >= 0) {
- // Show delete confirmation
- setShowPopup(false);
- setSelectedBlueprint(selectedBlueprintItem);
- setShowDeleteConfirm(true);
- }
- } else if (input === "l") {
- const logsIndex = allOperations.findIndex(
- (op) => op.key === "view_logs",
- );
- if (logsIndex >= 0) {
- setShowPopup(false);
- setSelectedBlueprint(selectedBlueprintItem);
- setExecutingOperation("view_logs");
- executeOperation(selectedBlueprintItem, "view_logs");
- }
- }
- return;
+ if (operationKey === "view_details") {
+ navigate("blueprint-detail", {
+ blueprintId: selectedBlueprintItem.id,
+ });
+ } else if (operationKey === "create_devbox") {
+ setSelectedBlueprint(selectedBlueprintItem);
+ setShowCreateDevbox(true);
+ } else if (operationKey === "delete") {
+ setSelectedBlueprint(selectedBlueprintItem);
+ setShowDeleteConfirm(true);
+ } else {
+ setSelectedBlueprint(selectedBlueprintItem);
+ setExecutingOperation(operationKey as OperationType);
+ executeOperation(selectedBlueprintItem, operationKey as OperationType);
}
-
- // Handle list navigation
- const pageBlueprints = blueprints.length;
-
- if (key.upArrow && selectedIndex > 0) {
- setSelectedIndex(selectedIndex - 1);
- } else if (key.downArrow && selectedIndex < pageBlueprints - 1) {
- setSelectedIndex(selectedIndex + 1);
- } else if (
- (input === "n" || key.rightArrow) &&
- !loading &&
- !navigating &&
- hasMore
- ) {
+ }, [
+ allOperations,
+ selectedOperation,
+ selectedBlueprintItem,
+ navigate,
+ executeOperation,
+ ]);
+
+ const goToNextPage = React.useCallback(() => {
+ if (!loading && !navigating && hasMore) {
nextPage();
setSelectedIndex(0);
- } else if (
- (input === "p" || key.leftArrow) &&
- !loading &&
- !navigating &&
- hasPrev
- ) {
+ }
+ }, [loading, navigating, hasMore, nextPage]);
+
+ const goToPrevPage = React.useCallback(() => {
+ if (!loading && !navigating && hasPrev) {
prevPage();
setSelectedIndex(0);
- } else if (key.return && selectedBlueprintItem) {
- // Enter key navigates to detail view
- navigate("blueprint-detail", {
- blueprintId: selectedBlueprintItem.id,
- });
- } else if (input === "a") {
- setShowPopup(true);
- setSelectedOperation(0);
- } else if (input === "l" && selectedBlueprintItem) {
- setSelectedBlueprint(selectedBlueprintItem);
- setExecutingOperation("view_logs");
- executeOperation(selectedBlueprintItem, "view_logs");
- } else if (input === "o" && blueprints[selectedIndex]) {
- const url = getBlueprintUrl(blueprints[selectedIndex].id);
- const openBrowser = async () => {
- const { exec } = await import("child_process");
- const platform = process.platform;
- let openCommand: string;
- if (platform === "darwin") {
- openCommand = `open "${url}"`;
- } else if (platform === "win32") {
- openCommand = `start "${url}"`;
- } else {
- openCommand = `xdg-open "${url}"`;
- }
- exec(openCommand);
- };
- openBrowser();
- } else if (input === "/") {
- search.enterSearchMode();
- } else if (key.escape) {
- if (search.handleEscape()) {
- return;
- }
- if (onBack) {
- onBack();
- } else if (onExit) {
- onExit();
- } else {
- inkExit();
- }
}
- });
+ }, [loading, navigating, hasPrev, prevPage]);
+
+ const handleListEscape = React.useCallback(() => {
+ if (search.handleEscape()) return;
+ if (onBack) {
+ onBack();
+ } else if (onExit) {
+ onExit();
+ } else {
+ inkExit();
+ }
+ }, [search, onBack, onExit, inkExit]);
+
+ const handleOpenInBrowser = React.useCallback(() => {
+ const bp = blueprints[selectedIndex];
+ if (!bp) return;
+ openInBrowser(getBlueprintUrl(bp.id));
+ }, [blueprints, selectedIndex]);
+
+ // --- Declarative input modes ---
+
+ const inputModes: InputMode[] = React.useMemo(
+ () => [
+ // Search mode: only escape to cancel, swallow everything else
+ {
+ name: "search",
+ active: () => search.searchMode,
+ bindings: {
+ escape: () => search.cancelSearch(),
+ },
+ captureAll: true,
+ },
+ // Operation input mode: escape/q to cancel, enter to submit
+ {
+ name: "operationInput",
+ active: () =>
+ !!executingOperation && !operationResult && !operationError,
+ bindings: {
+ q: cancelOperation,
+ escape: cancelOperation,
+ enter: () => {
+ const currentOp = allOperations.find(
+ (op) => op.key === executingOperation,
+ );
+ if (currentOp?.needsInput) {
+ executeOperation();
+ }
+ },
+ },
+ captureAll: true,
+ },
+ // Operation result display: any dismiss key
+ {
+ name: "operationResult",
+ active: () => !!operationResult || !!operationError,
+ bindings: {
+ q: dismissResult,
+ escape: dismissResult,
+ enter: dismissResult,
+ },
+ captureAll: true,
+ },
+ // Create devbox subview: swallow all input
+ {
+ name: "createDevbox",
+ active: () => showCreateDevbox,
+ bindings: {},
+ captureAll: true,
+ },
+ // Actions popup overlay
+ {
+ name: "popup",
+ active: () => showPopup,
+ bindings: {
+ up: () => {
+ if (selectedOperation > 0)
+ setSelectedOperation(selectedOperation - 1);
+ },
+ down: () => {
+ if (selectedOperation < allOperations.length - 1)
+ setSelectedOperation(selectedOperation + 1);
+ },
+ enter: executePopupSelection,
+ escape: closePopup,
+ q: closePopup,
+ v: () => {
+ if (selectedBlueprintItem) {
+ setShowPopup(false);
+ navigate("blueprint-detail", {
+ blueprintId: selectedBlueprintItem.id,
+ });
+ }
+ },
+ c: () => {
+ if (
+ selectedBlueprintItem &&
+ (selectedBlueprintItem.status === "build_complete" ||
+ selectedBlueprintItem.status === "building_complete")
+ ) {
+ setShowPopup(false);
+ setSelectedBlueprint(selectedBlueprintItem);
+ setShowCreateDevbox(true);
+ }
+ },
+ d: () => {
+ const deleteIndex = allOperations.findIndex(
+ (op) => op.key === "delete",
+ );
+ if (deleteIndex >= 0) {
+ setShowPopup(false);
+ setSelectedBlueprint(selectedBlueprintItem);
+ setShowDeleteConfirm(true);
+ }
+ },
+ l: () => {
+ const logsIndex = allOperations.findIndex(
+ (op) => op.key === "view_logs",
+ );
+ if (logsIndex >= 0) {
+ setShowPopup(false);
+ setSelectedBlueprint(selectedBlueprintItem);
+ setExecutingOperation("view_logs");
+ executeOperation(selectedBlueprintItem, "view_logs");
+ }
+ },
+ },
+ },
+ // List navigation (default mode)
+ {
+ name: "list",
+ active: () => true,
+ bindings: {
+ up: () => {
+ if (selectedIndex > 0) setSelectedIndex(selectedIndex - 1);
+ },
+ down: () => {
+ if (selectedIndex < blueprints.length - 1)
+ setSelectedIndex(selectedIndex + 1);
+ },
+ n: goToNextPage,
+ right: goToNextPage,
+ p: goToPrevPage,
+ left: goToPrevPage,
+ enter: () => {
+ if (selectedBlueprintItem) {
+ navigate("blueprint-detail", {
+ blueprintId: selectedBlueprintItem.id,
+ });
+ }
+ },
+ a: () => {
+ setShowPopup(true);
+ setSelectedOperation(0);
+ },
+ l: () => {
+ if (selectedBlueprintItem) {
+ setSelectedBlueprint(selectedBlueprintItem);
+ setExecutingOperation("view_logs");
+ executeOperation(selectedBlueprintItem, "view_logs");
+ }
+ },
+ o: handleOpenInBrowser,
+ "/": () => search.enterSearchMode(),
+ escape: handleListEscape,
+ },
+ },
+ ],
+ [
+ search,
+ executingOperation,
+ operationResult,
+ operationError,
+ cancelOperation,
+ allOperations,
+ executeOperation,
+ dismissResult,
+ showCreateDevbox,
+ showPopup,
+ selectedOperation,
+ executePopupSelection,
+ closePopup,
+ selectedBlueprintItem,
+ navigate,
+ selectedIndex,
+ blueprints.length,
+ goToNextPage,
+ goToPrevPage,
+ handleOpenInBrowser,
+ handleListEscape,
+ ],
+ );
+
+ useInputHandler(inputModes);
// Delete confirmation
if (showDeleteConfirm && selectedBlueprint) {
diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx
index 1fd6bf52..c6b5a5bf 100644
--- a/src/commands/devbox/list.tsx
+++ b/src/commands/devbox/list.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { Box, Text, useInput, useApp } from "ink";
+import { Box, Text, useApp } from "ink";
import figures from "figures";
import type { DevboxesCursorIDPage } from "@runloop/api-client/pagination";
import { getClient } from "../../utils/client.js";
@@ -22,6 +22,11 @@ import { useViewportHeight } from "../../hooks/useViewportHeight.js";
import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
import { useCursorPagination } from "../../hooks/useCursorPagination.js";
import { useListSearch } from "../../hooks/useListSearch.js";
+import { openInBrowser } from "../../utils/browser.js";
+import {
+ useInputHandler,
+ type InputMode,
+} from "../../hooks/useInputHandler.js";
import { colors } from "../../utils/theme.js";
import { useDevboxStore, type Devbox } from "../../store/devboxStore.js";
@@ -447,122 +452,143 @@ const ListDevboxesUI = ({
})
: allOperations;
- useInput((input, key) => {
- const pageDevboxes = devboxes.length;
-
- // Skip input handling when in search mode - let TextInput handle it
- if (search.searchMode) {
- if (key.escape) {
- search.cancelSearch();
- }
- return;
- }
-
- // Skip input handling when in details view
- if (showDetails) {
- return;
- }
-
- // Skip input handling when in create view
- if (showCreate) {
- return;
+ const closePopup = React.useCallback(() => {
+ setShowPopup(false);
+ setSelectedOperation(0);
+ }, []);
+
+ const handleListEscape = React.useCallback(() => {
+ if (search.handleEscape()) return;
+ if (onBack) {
+ onBack();
+ } else if (onExit) {
+ onExit();
+ } else {
+ inkExit();
}
+ }, [search, onBack, onExit, inkExit]);
- // Skip input handling when in actions view
- if (showActions) {
- return;
- }
-
- // Handle popup navigation
- if (showPopup) {
- if (key.escape || input === "q") {
- setShowPopup(false);
- setSelectedOperation(0);
- } else if (key.upArrow && selectedOperation > 0) {
- setSelectedOperation(selectedOperation - 1);
- } else if (key.downArrow && selectedOperation < operations.length - 1) {
- setSelectedOperation(selectedOperation + 1);
- } else if (key.return) {
- setShowPopup(false);
- setShowActions(true);
- } else if (input) {
- const matchedOpIndex = operations.findIndex(
- (op) => op.shortcut === input,
- );
- if (matchedOpIndex !== -1) {
- setSelectedOperation(matchedOpIndex);
- setShowPopup(false);
- setShowActions(true);
- }
- }
- return;
- }
+ const handleOpenInBrowser = React.useCallback(() => {
+ if (!selectedDevbox) return;
+ openInBrowser(getDevboxUrl(selectedDevbox.id));
+ }, [selectedDevbox]);
- // Handle list view
- if (key.upArrow && selectedIndex > 0) {
- setSelectedIndex(selectedIndex - 1);
- } else if (key.downArrow && selectedIndex < pageDevboxes - 1) {
- setSelectedIndex(selectedIndex + 1);
- } else if (
- (input === "n" || key.rightArrow) &&
- !loading &&
- !navigating &&
- hasMore
- ) {
+ const goToNextPage = React.useCallback(() => {
+ if (!loading && !navigating && hasMore) {
nextPage();
setSelectedIndex(0);
- } else if (
- (input === "p" || key.leftArrow) &&
- !loading &&
- !navigating &&
- hasPrev
- ) {
+ }
+ }, [loading, navigating, hasMore, nextPage]);
+
+ const goToPrevPage = React.useCallback(() => {
+ if (!loading && !navigating && hasPrev) {
prevPage();
setSelectedIndex(0);
- } else if (key.return) {
- if (onNavigateToDetail && selectedDevbox) {
- onNavigateToDetail(selectedDevbox.id);
- } else {
- setShowDetails(true);
- }
- } else if (input === "a") {
- setShowPopup(true);
- setSelectedOperation(0);
- } else if (input === "c") {
- setShowCreate(true);
- } else if (input === "o" && selectedDevbox) {
- const url = getDevboxUrl(selectedDevbox.id);
- const openBrowser = async () => {
- const { exec } = await import("child_process");
- const platform = process.platform;
-
- let openCommand: string;
- if (platform === "darwin") {
- openCommand = `open "${url}"`;
- } else if (platform === "win32") {
- openCommand = `start "${url}"`;
- } else {
- openCommand = `xdg-open "${url}"`;
- }
-
- exec(openCommand);
- };
- openBrowser();
- } else if (input === "/") {
- search.enterSearchMode();
- } else if (key.escape) {
- if (search.handleEscape()) {
- return;
- }
- if (onBack) {
- onBack();
- } else if (onExit) {
- onExit();
- } else {
- inkExit();
- }
}
- });
+ }, [loading, navigating, hasPrev, prevPage]);
+
+ const inputModes: InputMode[] = React.useMemo(
+ () => [
+ // Search mode: only escape to cancel, swallow everything else
+ {
+ name: "search",
+ active: () => search.searchMode,
+ bindings: {
+ escape: () => search.cancelSearch(),
+ },
+ captureAll: true,
+ },
+ // Subview guards: swallow all input when a child view is active
+ {
+ name: "subviews",
+ active: () => showDetails || showCreate || showActions,
+ bindings: {},
+ captureAll: true,
+ },
+ // Popup navigation
+ {
+ name: "popup",
+ active: () => showPopup,
+ bindings: {
+ escape: closePopup,
+ q: closePopup,
+ up: () => {
+ if (selectedOperation > 0)
+ setSelectedOperation(selectedOperation - 1);
+ },
+ down: () => {
+ if (selectedOperation < operations.length - 1)
+ setSelectedOperation(selectedOperation + 1);
+ },
+ enter: () => {
+ setShowPopup(false);
+ setShowActions(true);
+ },
+ },
+ onUnmatched: (input) => {
+ const idx = operations.findIndex((op) => op.shortcut === input);
+ if (idx !== -1) {
+ setSelectedOperation(idx);
+ setShowPopup(false);
+ setShowActions(true);
+ }
+ },
+ },
+ // List navigation (default mode)
+ {
+ name: "list",
+ active: () => true,
+ bindings: {
+ up: () => {
+ if (selectedIndex > 0) setSelectedIndex(selectedIndex - 1);
+ },
+ down: () => {
+ if (selectedIndex < devboxes.length - 1)
+ setSelectedIndex(selectedIndex + 1);
+ },
+ n: goToNextPage,
+ right: goToNextPage,
+ p: goToPrevPage,
+ left: goToPrevPage,
+ enter: () => {
+ if (onNavigateToDetail && selectedDevbox) {
+ onNavigateToDetail(selectedDevbox.id);
+ } else {
+ setShowDetails(true);
+ }
+ },
+ a: () => {
+ setShowPopup(true);
+ setSelectedOperation(0);
+ },
+ c: () => setShowCreate(true),
+ o: handleOpenInBrowser,
+ "/": () => search.enterSearchMode(),
+ escape: handleListEscape,
+ },
+ },
+ ],
+ [
+ search,
+ showDetails,
+ showCreate,
+ showActions,
+ showPopup,
+ closePopup,
+ selectedOperation,
+ operations,
+ selectedIndex,
+ devboxes.length,
+ goToNextPage,
+ goToPrevPage,
+ onNavigateToDetail,
+ selectedDevbox,
+ handleOpenInBrowser,
+ handleListEscape,
+ ],
+ );
+
+ useInputHandler(inputModes);
// Create view
if (showCreate) {
diff --git a/src/commands/devbox/tunnel.ts b/src/commands/devbox/tunnel.ts
index 0641adac..1a24eca2 100644
--- a/src/commands/devbox/tunnel.ts
+++ b/src/commands/devbox/tunnel.ts
@@ -7,6 +7,7 @@ import { getClient } from "../../utils/client.js";
import { output, outputError } from "../../utils/output.js";
import { processUtils } from "../../utils/processUtils.js";
import { getSSHKey, getProxyCommand, checkSSHTools } from "../../utils/ssh.js";
+import { openInBrowser } from "../../utils/browser.js";
interface TunnelOptions {
ports: string;
@@ -83,25 +84,9 @@ export async function createTunnel(devboxId: string, options: TunnelOptions) {
// Open browser if --open flag is set
if (options.open) {
// Small delay to let the tunnel establish
- setTimeout(async () => {
- const { exec } = await import("child_process");
- const platform = process.platform;
-
- let openCommand: string;
- if (platform === "darwin") {
- openCommand = `open "${tunnelUrl}"`;
- } else if (platform === "win32") {
- openCommand = `start "${tunnelUrl}"`;
- } else {
- openCommand = `xdg-open "${tunnelUrl}"`;
- }
-
- exec(openCommand, (error) => {
- if (error) {
- console.log(`\nCould not open browser: ${error.message}`);
- }
- });
- }, 1000);
+ setTimeout(() => {
+ openInBrowser(tunnelUrl);
+ }, 1000); // TODO: Not going to need this soon with tunnels v2
}
tunnelProcess.on("close", (code) => {
diff --git a/src/components/DetailedInfoView.tsx b/src/components/DetailedInfoView.tsx
new file mode 100644
index 00000000..e839bac3
--- /dev/null
+++ b/src/components/DetailedInfoView.tsx
@@ -0,0 +1,100 @@
+/**
+ * DetailedInfoView - Full-screen scrollable detail view for resources.
+ *
+ * Extracted from ResourceDetailPage to reduce component size.
+ * Displays all resource information in a scrollable, bordered container.
+ */
+import React from "react";
+import { Box, Text } from "ink";
+import figures from "figures";
+import { Header } from "./Header.js";
+import { StatusBadge } from "./StatusBadge.js";
+import { Breadcrumb } from "./Breadcrumb.js";
+import { colors } from "../utils/theme.js";
+
+interface DetailedInfoViewProps {
+ /** Lines of React elements to display */
+ detailLines: React.ReactElement[];
+ /** Current scroll offset */
+ scrollOffset: number;
+ /** Maximum visible lines */
+ viewportHeight: number;
+ /** Display name of the resource */
+ displayName: string;
+ /** Resource ID */
+ resourceId: string;
+ /** Resource status string */
+ status: string;
+ /** Resource type for breadcrumbs (e.g. "Devboxes") */
+ resourceType: string;
+ /** Optional breadcrumb prefix items */
+ breadcrumbPrefix?: Array<{ label: string; active?: boolean }>;
+}
+
+export function DetailedInfoView({
+ detailLines,
+ scrollOffset,
+ viewportHeight,
+ displayName,
+ resourceId,
+ status,
+ resourceType,
+ breadcrumbPrefix = [],
+}: DetailedInfoViewProps) {
+ const maxScroll = Math.max(0, detailLines.length - viewportHeight);
+ const actualScroll = Math.min(scrollOffset, maxScroll);
+ const visibleLines = detailLines.slice(
+ actualScroll,
+ actualScroll + viewportHeight,
+ );
+ const hasMore = actualScroll + viewportHeight < detailLines.length;
+ const hasLess = actualScroll > 0;
+
+ return (
+ <>
+
+
+
+
+
+
+ {resourceId}
+
+
+
+
+ {visibleLines}
+
+
+
+
+ {figures.arrowUp}
+ {figures.arrowDown} Scroll • Line {actualScroll + 1}-
+ {Math.min(actualScroll + viewportHeight, detailLines.length)} of{" "}
+ {detailLines.length}
+
+ {hasLess && {figures.arrowUp}}
+ {hasMore && {figures.arrowDown}}
+
+ {" "}
+ • [q or esc] Back to Details
+
+
+ >
+ );
+}
diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx
index d4d8fb51..e7b33b5c 100644
--- a/src/components/DevboxActionsMenu.tsx
+++ b/src/components/DevboxActionsMenu.tsx
@@ -10,6 +10,8 @@ import { Breadcrumb } from "./Breadcrumb.js";
import { NavigationTips } from "./NavigationTips.js";
import { ConfirmationPrompt } from "./ConfirmationPrompt.js";
import { colors } from "../utils/theme.js";
+import { openInBrowser } from "../utils/browser.js";
+import { copyToClipboard } from "../utils/clipboard.js";
import { useViewportHeight } from "../hooks/useViewportHeight.js";
import { useNavigation } from "../store/navigationStore.js";
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
@@ -476,30 +478,9 @@ export const DevboxActionsMenu = ({
// Open tunnel URL in browser
const tunnelUrl = (operationResult as any).__tunnelUrl;
if (tunnelUrl) {
- const openBrowser = async () => {
- const { exec } = await import("child_process");
- const platform = process.platform;
-
- let openCommand: string;
- if (platform === "darwin") {
- openCommand = `open "${tunnelUrl}"`;
- } else if (platform === "win32") {
- openCommand = `start "${tunnelUrl}"`;
- } else {
- openCommand = `xdg-open "${tunnelUrl}"`;
- }
-
- exec(openCommand, (error) => {
- if (error) {
- setCopyStatus("Could not open browser");
- setTimeout(() => setCopyStatus(null), 2000);
- } else {
- setCopyStatus("Opened in browser!");
- setTimeout(() => setCopyStatus(null), 2000);
- }
- });
- };
- openBrowser();
+ openInBrowser(tunnelUrl);
+ setCopyStatus("Opened in browser!");
+ setTimeout(() => setCopyStatus(null), 2000);
}
} else if (
(key.upArrow || input === "k") &&
@@ -563,45 +544,10 @@ export const DevboxActionsMenu = ({
((operationResult as any).stdout || "") +
((operationResult as any).stderr || "");
- const copyToClipboard = async (text: string) => {
- const { spawn } = await import("child_process");
- const platform = process.platform;
-
- let command: string;
- let args: string[];
-
- if (platform === "darwin") {
- command = "pbcopy";
- args = [];
- } else if (platform === "win32") {
- command = "clip";
- args = [];
- } else {
- command = "xclip";
- args = ["-selection", "clipboard"];
- }
-
- const proc = spawn(command, args);
- proc.stdin.write(text);
- proc.stdin.end();
-
- proc.on("exit", (code) => {
- if (code === 0) {
- setCopyStatus("Copied to clipboard!");
- setTimeout(() => setCopyStatus(null), 2000);
- } else {
- setCopyStatus("Failed to copy");
- setTimeout(() => setCopyStatus(null), 2000);
- }
- });
-
- proc.on("error", () => {
- setCopyStatus("Copy not supported");
- setTimeout(() => setCopyStatus(null), 2000);
- });
- };
-
- copyToClipboard(output);
+ copyToClipboard(output).then((status) => {
+ setCopyStatus(status);
+ setTimeout(() => setCopyStatus(null), 2000);
+ });
}
return;
}
diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx
index 4465f94a..d9dd531d 100644
--- a/src/components/DevboxDetailPage.tsx
+++ b/src/components/DevboxDetailPage.tsx
@@ -225,14 +225,27 @@ export const DevboxDetailPage = ({
}
// Source
- if (devbox.blueprint_id || devbox.snapshot_id) {
+ if (devbox.blueprint_id) {
+ detailFields.push({
+ label: "Source",
+ value: {devbox.blueprint_id},
+ action: {
+ type: "navigate" as const,
+ screen: "blueprint-detail" as const,
+ params: { blueprintId: devbox.blueprint_id },
+ hint: "View Blueprint",
+ },
+ });
+ } else if (devbox.snapshot_id) {
detailFields.push({
label: "Source",
- value: (
-
- {devbox.blueprint_id || devbox.snapshot_id}
-
- ),
+ value: {devbox.snapshot_id},
+ action: {
+ type: "navigate" as const,
+ screen: "snapshot-detail" as const,
+ params: { snapshotId: devbox.snapshot_id },
+ hint: "View Snapshot",
+ },
});
}
@@ -241,6 +254,12 @@ export const DevboxDetailPage = ({
detailFields.push({
label: "Network Policy",
value: {lp.network_policy_id},
+ action: {
+ type: "navigate" as const,
+ screen: "network-policy-detail" as const,
+ params: { networkPolicyId: lp.network_policy_id },
+ hint: "View Policy",
+ },
});
}
diff --git a/src/components/LogsViewer.tsx b/src/components/LogsViewer.tsx
index 534e576f..494f5ef9 100644
--- a/src/components/LogsViewer.tsx
+++ b/src/components/LogsViewer.tsx
@@ -8,6 +8,7 @@ import figures from "figures";
import { Breadcrumb } from "./Breadcrumb.js";
import { NavigationTips } from "./NavigationTips.js";
import { colors } from "../utils/theme.js";
+import { copyToClipboard } from "../utils/clipboard.js";
import { useViewportHeight } from "../hooks/useViewportHeight.js";
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
import { parseAnyLogEntry, type AnyLog } from "../utils/logFormatter.js";
@@ -82,45 +83,10 @@ export const LogsViewer = ({
})
.join("\n");
- const copyToClipboard = async (text: string) => {
- const { spawn } = await import("child_process");
- const platform = process.platform;
-
- let command: string;
- let args: string[];
-
- if (platform === "darwin") {
- command = "pbcopy";
- args = [];
- } else if (platform === "win32") {
- command = "clip";
- args = [];
- } else {
- command = "xclip";
- args = ["-selection", "clipboard"];
- }
-
- const proc = spawn(command, args);
- proc.stdin.write(text);
- proc.stdin.end();
-
- proc.on("exit", (code) => {
- if (code === 0) {
- setCopyStatus("Copied to clipboard!");
- setTimeout(() => setCopyStatus(null), 2000);
- } else {
- setCopyStatus("Failed to copy");
- setTimeout(() => setCopyStatus(null), 2000);
- }
- });
-
- proc.on("error", () => {
- setCopyStatus("Copy not supported");
- setTimeout(() => setCopyStatus(null), 2000);
- });
- };
-
- copyToClipboard(logsText);
+ copyToClipboard(logsText).then((status) => {
+ setCopyStatus(status);
+ setTimeout(() => setCopyStatus(null), 2000);
+ });
} else if (input === "q" || key.escape || key.return) {
onBack();
}
diff --git a/src/components/ResourceDetailPage.tsx b/src/components/ResourceDetailPage.tsx
index 81eb3f66..f83659a7 100644
--- a/src/components/ResourceDetailPage.tsx
+++ b/src/components/ResourceDetailPage.tsx
@@ -3,71 +3,38 @@
* Can be used for devboxes, blueprints, snapshots, etc.
*/
import React from "react";
-import { Box, Text, useInput } from "ink";
+import { Box, Text } from "ink";
import figures from "figures";
-import { Header } from "./Header.js";
import { StatusBadge } from "./StatusBadge.js";
import { Breadcrumb } from "./Breadcrumb.js";
+import { DetailedInfoView } from "./DetailedInfoView.js";
import { NavigationTips } from "./NavigationTips.js";
import { colors } from "../utils/theme.js";
import { useViewportHeight } from "../hooks/useViewportHeight.js";
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
-import { formatTimeAgo } from "../utils/time.js";
-
-// Types for configurable detail sections
-export interface DetailField {
- label: string;
- value: string | React.ReactNode | undefined | null;
- color?: string;
-}
-
-export interface DetailSection {
- title: string;
- icon?: string;
- color?: string;
- fields: DetailField[];
-}
-
-export interface ResourceOperation {
- key: string;
- label: string;
- color: string;
- icon: string;
- shortcut: string;
-}
-
-export interface ResourceDetailPageProps {
- /** The resource being displayed */
- resource: T;
- /** Resource type name for breadcrumbs (e.g., "Blueprints", "Snapshots") */
- resourceType: string;
- /** Get display name for the resource */
- getDisplayName: (resource: T) => string;
- /** Get resource ID */
- getId: (resource: T) => string;
- /** Get resource status */
- getStatus: (resource: T) => string;
- /** Optional: Get URL to open in browser */
- getUrl?: (resource: T) => string;
- /** Breadcrumb items before the resource name */
- breadcrumbPrefix?: Array<{ label: string; active?: boolean }>;
- /** Detail sections to display in main view */
- detailSections: DetailSection[];
- /** Available operations/actions */
- operations: ResourceOperation[];
- /** Callback when operation is selected */
- onOperation: (operation: string, resource: T) => void;
- /** Callback to go back */
- onBack: () => void;
- /** Optional: Build detailed info lines for full details view */
- buildDetailLines?: (resource: T) => React.ReactElement[];
- /** Optional: Additional content to render after details section */
- additionalContent?: React.ReactNode;
- /** Optional: Polling function to refresh resource data */
- pollResource?: () => Promise;
- /** Polling interval in ms (default: 3000) */
- pollInterval?: number;
-}
+import {
+ useInputHandler,
+ scrollBindings,
+ type InputMode,
+} from "../hooks/useInputHandler.js";
+import { useNavigation } from "../store/navigationStore.js";
+import { openInBrowser as openUrlInBrowser } from "../utils/browser.js";
+import { copyToClipboard } from "../utils/clipboard.js";
+import {
+ collectActionableFields,
+ type DetailFieldAction,
+ type ResourceDetailPageProps,
+} from "./resourceDetailTypes.js";
+
+// Re-export all types so existing consumers don't need to change their imports
+export type {
+ DetailFieldAction,
+ DetailField,
+ DetailSection,
+ ResourceOperation,
+ ResourceDetailPageProps,
+} from "./resourceDetailTypes.js";
+export { collectActionableFields } from "./resourceDetailTypes.js";
// Truncate long strings to prevent layout issues
const truncateString = (str: string, maxLength: number): string => {
@@ -93,6 +60,7 @@ export function ResourceDetailPage({
pollInterval = 3000,
}: ResourceDetailPageProps) {
const isMounted = React.useRef(true);
+ const { navigate } = useNavigation();
// Track mounted state
React.useEffect(() => {
@@ -106,48 +74,35 @@ export function ResourceDetailPage({
const [currentResource, setCurrentResource] = React.useState(initialResource);
const [copyStatus, setCopyStatus] = React.useState(null);
- // Copy to clipboard helper
- const copyToClipboard = React.useCallback(async (text: string) => {
- const { spawn } = await import("child_process");
- const platform = process.platform;
-
- let command: string;
- let args: string[];
-
- if (platform === "darwin") {
- command = "pbcopy";
- args = [];
- } else if (platform === "win32") {
- command = "clip";
- args = [];
- } else {
- command = "xclip";
- args = ["-selection", "clipboard"];
- }
-
- const proc = spawn(command, args);
- proc.stdin.write(text);
- proc.stdin.end();
-
- proc.on("exit", (code) => {
- if (code === 0) {
- setCopyStatus("Copied ID to clipboard!");
- setTimeout(() => setCopyStatus(null), 2000);
- } else {
- setCopyStatus("Failed to copy");
- setTimeout(() => setCopyStatus(null), 2000);
- }
- });
-
- proc.on("error", () => {
- setCopyStatus("Copy not supported");
- setTimeout(() => setCopyStatus(null), 2000);
- });
+ // Copy to clipboard with status feedback
+ const handleCopy = React.useCallback(async (text: string) => {
+ const status = await copyToClipboard(text);
+ setCopyStatus(status);
+ setTimeout(() => setCopyStatus(null), 2000);
}, []);
const [showDetailedInfo, setShowDetailedInfo] = React.useState(false);
const [detailScroll, setDetailScroll] = React.useState(0);
- const [selectedOperation, setSelectedOperation] = React.useState(0);
+
+ // Unified selectable items: actionable detail fields followed by operations.
+ // Arrow keys move through the entire list seamlessly.
+ const actionableFields = React.useMemo(
+ () => collectActionableFields(detailSections),
+ [detailSections],
+ );
+ const totalSelectableItems = actionableFields.length + operations.length;
+ // Default selection is the first operation (skip links)
+ const [selectedIndex, setSelectedIndex] = React.useState(
+ actionableFields.length,
+ );
+
+ // Clamp selectedIndex when the number of selectable items shrinks
+ // (e.g. operations list changes due to a status change from polling)
+ React.useEffect(() => {
+ if (totalSelectableItems > 0 && selectedIndex >= totalSelectableItems) {
+ setSelectedIndex(totalSelectableItems - 1);
+ }
+ }, [totalSelectableItems, selectedIndex]);
// Background polling for resource details
React.useEffect(() => {
@@ -176,138 +131,140 @@ export function ResourceDetailPage({
const resourceId = getId(currentResource);
const status = getStatus(currentResource);
+ // Execute a field action
+ const executeFieldAction = React.useCallback(
+ (action: DetailFieldAction) => {
+ if (action.type === "navigate" && action.screen) {
+ navigate(action.screen, action.params || {});
+ } else if (action.type === "callback" && action.handler) {
+ action.handler();
+ }
+ },
+ [navigate],
+ );
+
// Handle Ctrl+C to exit
useExitOnCtrlC();
- useInput((input, key) => {
- if (!isMounted.current) return;
-
- // Handle detailed info mode
- if (showDetailedInfo) {
- if (input === "q" || key.escape) {
- setShowDetailedInfo(false);
- setDetailScroll(0);
- } else if (input === "j" || input === "s" || key.downArrow) {
- setDetailScroll(detailScroll + 1);
- } else if (input === "k" || input === "w" || key.upArrow) {
- setDetailScroll(Math.max(0, detailScroll - 1));
- } else if (key.pageDown) {
- setDetailScroll(detailScroll + 10);
- } else if (key.pageUp) {
- setDetailScroll(Math.max(0, detailScroll - 10));
- }
- return;
- }
+ // Helper: is the current selection on a link or an operation?
+ const isOnLink = selectedIndex < actionableFields.length;
+ const operationIndex = selectedIndex - actionableFields.length;
+
+ const handleOpenInBrowser = React.useCallback(() => {
+ if (!getUrl) return;
+ openUrlInBrowser(getUrl(currentResource));
+ }, [getUrl, currentResource]);
- // Main view input handling
- if (input === "q" || key.escape) {
- onBack();
- } else if (input === "c" && !key.ctrl) {
- // Copy resource ID to clipboard (ignore if Ctrl+C for quit)
- copyToClipboard(getId(currentResource));
- } else if (input === "i" && buildDetailLines) {
- setShowDetailedInfo(true);
- setDetailScroll(0);
- } else if (key.upArrow && selectedOperation > 0) {
- setSelectedOperation(selectedOperation - 1);
- } else if (key.downArrow && selectedOperation < operations.length - 1) {
- setSelectedOperation(selectedOperation + 1);
- } else if (key.return) {
- const op = operations[selectedOperation];
+ const exitDetailedInfo = React.useCallback(() => {
+ setShowDetailedInfo(false);
+ setDetailScroll(0);
+ }, []);
+
+ const handleEnter = React.useCallback(() => {
+ if (isOnLink) {
+ const ref = actionableFields[selectedIndex];
+ if (ref) {
+ executeFieldAction(ref.action);
+ }
+ } else {
+ const op = operations[operationIndex];
if (op) {
onOperation(op.key, currentResource);
}
- } else if (input) {
- // Check if input matches any operation shortcut
- const matchedOpIndex = operations.findIndex(
- (op) => op.shortcut === input,
- );
- if (matchedOpIndex !== -1) {
- setSelectedOperation(matchedOpIndex);
- onOperation(operations[matchedOpIndex].key, currentResource);
- }
}
+ }, [
+ isOnLink,
+ actionableFields,
+ selectedIndex,
+ operationIndex,
+ operations,
+ currentResource,
+ executeFieldAction,
+ onOperation,
+ ]);
+
+ const inputModes: InputMode[] = React.useMemo(
+ () => [
+ {
+ name: "detailedInfo",
+ active: () => showDetailedInfo,
+ bindings: {
+ ...scrollBindings(() => detailScroll, setDetailScroll),
+ q: exitDetailedInfo,
+ escape: exitDetailedInfo,
+ },
+ },
+ {
+ name: "mainView",
+ active: () => true,
+ bindings: {
+ q: onBack,
+ escape: onBack,
+ c: () => handleCopy(getId(currentResource)),
+ ...(buildDetailLines
+ ? {
+ i: () => {
+ setShowDetailedInfo(true);
+ setDetailScroll(0);
+ },
+ }
+ : {}),
+ up: () => {
+ if (selectedIndex > 0) setSelectedIndex(selectedIndex - 1);
+ },
+ down: () => {
+ if (selectedIndex < totalSelectableItems - 1)
+ setSelectedIndex(selectedIndex + 1);
+ },
+ enter: handleEnter,
+ ...(getUrl ? { o: handleOpenInBrowser } : {}),
+ },
+ onUnmatched: (input) => {
+ // Operation shortcuts work from anywhere
+ const matchedOpIndex = operations.findIndex(
+ (op) => op.shortcut === input,
+ );
+ if (matchedOpIndex !== -1) {
+ setSelectedIndex(actionableFields.length + matchedOpIndex);
+ onOperation(operations[matchedOpIndex].key, currentResource);
+ }
+ },
+ },
+ ],
+ [
+ showDetailedInfo,
+ detailScroll,
+ exitDetailedInfo,
+ onBack,
+ currentResource,
+ buildDetailLines,
+ selectedIndex,
+ totalSelectableItems,
+ handleEnter,
+ getUrl,
+ handleOpenInBrowser,
+ operations,
+ actionableFields,
+ onOperation,
+ getId,
+ ],
+ );
- if (input === "o" && getUrl) {
- const url = getUrl(currentResource);
- const openBrowser = async () => {
- const { exec } = await import("child_process");
- const platform = process.platform;
-
- let openCommand: string;
- if (platform === "darwin") {
- openCommand = `open "${url}"`;
- } else if (platform === "win32") {
- openCommand = `start "${url}"`;
- } else {
- openCommand = `xdg-open "${url}"`;
- }
-
- exec(openCommand);
- };
- openBrowser();
- }
- });
+ useInputHandler(inputModes, { isActive: isMounted.current });
// Detailed info mode - full screen
if (showDetailedInfo && buildDetailLines) {
- const detailLines = buildDetailLines(currentResource);
- const viewportHeight = detailViewport.viewportHeight;
- const maxScroll = Math.max(0, detailLines.length - viewportHeight);
- const actualScroll = Math.min(detailScroll, maxScroll);
- const visibleLines = detailLines.slice(
- actualScroll,
- actualScroll + viewportHeight,
- );
- const hasMore = actualScroll + viewportHeight < detailLines.length;
- const hasLess = actualScroll > 0;
-
return (
- <>
-
-
-
-
-
-
- {resourceId}
-
-
-
-
- {visibleLines}
-
-
-
-
- {figures.arrowUp}
- {figures.arrowDown} Scroll • Line {actualScroll + 1}-
- {Math.min(actualScroll + viewportHeight, detailLines.length)} of{" "}
- {detailLines.length}
-
- {hasLess && {figures.arrowUp}}
- {hasMore && {figures.arrowDown}}
-
- {" "}
- • [q or esc] Back to Details
-
-
- >
+
);
}
@@ -352,18 +309,61 @@ export function ResourceDetailPage({
.filter(
(field) => field.value !== undefined && field.value !== null,
)
- .map((field, fieldIndex) => (
-
- {field.label}
- {typeof field.value === "string" ? (
-
- {field.value}
+ .map((field, fieldIndex) => {
+ // Check if this field is an actionable field and whether it's selected
+ const isActionable = !!field.action;
+ const actionableIdx = isActionable
+ ? actionableFields.findIndex(
+ (ref) =>
+ ref.sectionIndex === sectionIndex &&
+ ref.fieldIndex === fieldIndex,
+ )
+ : -1;
+ const isFieldSelected =
+ isActionable && actionableIdx === selectedIndex;
+
+ return (
+
+ {isActionable ? (
+
+ {isFieldSelected ? figures.pointer : " "}{" "}
+
+ ) : null}
+
+ {field.label}
+ {field.label ? " " : ""}
- ) : (
- field.value
- )}
-
- ))}
+ {typeof field.value === "string" ? (
+
+ {field.value}
+
+ ) : (
+ field.value
+ )}
+ {isFieldSelected && field.action?.hint && (
+
+ {" "}
+ [Enter: {field.action.hint}]
+
+ )}
+
+ );
+ })}
))}
@@ -379,7 +379,8 @@ export function ResourceDetailPage({
{operations.map((op, index) => {
- const isSelected = index === selectedOperation;
+ const isSelected =
+ index + actionableFields.length === selectedIndex;
return (
@@ -413,7 +414,7 @@ export function ResourceDetailPage({
({
);
}
-// Helper to format timestamp as "time (ago)"
-export function formatTimestamp(
- timestamp: number | undefined,
-): string | undefined {
- if (!timestamp) return undefined;
- const formatted = new Date(timestamp).toLocaleString();
- const ago = formatTimeAgo(timestamp);
- return `${formatted} (${ago})`;
-}
-
-// Helper to format create time with arrow to end time
-export function formatTimeRange(
- createTime: number | undefined,
- endTime: number | undefined,
-): string | undefined {
- if (!createTime) return undefined;
- const start = new Date(createTime).toLocaleString();
- if (endTime) {
- const end = new Date(endTime).toLocaleString();
- return `${start} → ${end}`;
- }
- return `${start} (${formatTimeAgo(createTime)})`;
-}
+// Re-export format helpers from utils/time for backward compatibility
+export { formatTimestamp, formatTimeRange } from "../utils/time.js";
diff --git a/src/components/StreamingLogsViewer.tsx b/src/components/StreamingLogsViewer.tsx
index 64a59a5f..78711dd8 100644
--- a/src/components/StreamingLogsViewer.tsx
+++ b/src/components/StreamingLogsViewer.tsx
@@ -8,6 +8,7 @@ import figures from "figures";
import { Breadcrumb } from "./Breadcrumb.js";
import { NavigationTips } from "./NavigationTips.js";
import { colors } from "../utils/theme.js";
+import { copyToClipboard } from "../utils/clipboard.js";
import { useViewportHeight } from "../hooks/useViewportHeight.js";
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
import { parseAnyLogEntry, type AnyLog } from "../utils/logFormatter.js";
@@ -184,45 +185,10 @@ export const StreamingLogsViewer = ({
})
.join("\n");
- const copyToClipboard = async (text: string) => {
- const { spawn } = await import("child_process");
- const platform = process.platform;
-
- let command: string;
- let args: string[];
-
- if (platform === "darwin") {
- command = "pbcopy";
- args = [];
- } else if (platform === "win32") {
- command = "clip";
- args = [];
- } else {
- command = "xclip";
- args = ["-selection", "clipboard"];
- }
-
- const proc = spawn(command, args);
- proc.stdin.write(text);
- proc.stdin.end();
-
- proc.on("exit", (code) => {
- if (code === 0) {
- setCopyStatus("Copied!");
- setTimeout(() => setCopyStatus(null), 2000);
- } else {
- setCopyStatus("Failed");
- setTimeout(() => setCopyStatus(null), 2000);
- }
- });
-
- proc.on("error", () => {
- setCopyStatus("Not supported");
- setTimeout(() => setCopyStatus(null), 2000);
- });
- };
-
- copyToClipboard(logsText);
+ copyToClipboard(logsText).then((status) => {
+ setCopyStatus(status);
+ setTimeout(() => setCopyStatus(null), 2000);
+ });
} else if (input === "q" || key.escape || key.return) {
onBack();
}
diff --git a/src/components/resourceDetailTypes.ts b/src/components/resourceDetailTypes.ts
new file mode 100644
index 00000000..dbff0944
--- /dev/null
+++ b/src/components/resourceDetailTypes.ts
@@ -0,0 +1,118 @@
+/**
+ * Shared types for the ResourceDetailPage component and its consumers.
+ */
+import React from "react";
+import type { ScreenName, RouteParams } from "../store/navigationStore.js";
+
+// ---------------------------------------------------------------------------
+// Detail field types
+// ---------------------------------------------------------------------------
+
+/** Action that can be triggered from an actionable detail field. */
+export interface DetailFieldAction {
+ /** Type of action */
+ type: "navigate" | "callback";
+ /** For navigate: screen name to navigate to */
+ screen?: ScreenName;
+ /** For navigate: params to pass */
+ params?: RouteParams;
+ /** For callback: custom function to execute */
+ handler?: () => void;
+ /** Hint text shown next to field, e.g. "View Blueprint" */
+ hint?: string;
+}
+
+/** A single field within a detail section. */
+export interface DetailField {
+ label: string;
+ value: string | React.ReactNode | undefined | null;
+ color?: string;
+ /** Optional action to trigger when this field is selected and Enter is pressed */
+ action?: DetailFieldAction;
+}
+
+/** A group of related fields displayed under a section heading. */
+export interface DetailSection {
+ title: string;
+ icon?: string;
+ color?: string;
+ fields: DetailField[];
+}
+
+/** An operation/action available for the resource (shown in the Actions menu). */
+export interface ResourceOperation {
+ key: string;
+ label: string;
+ color: string;
+ icon: string;
+ shortcut: string;
+}
+
+// ---------------------------------------------------------------------------
+// Actionable field helpers
+// ---------------------------------------------------------------------------
+
+/** Reference to an actionable field by section/field index plus its action. */
+export interface ActionableFieldRef {
+ sectionIndex: number;
+ fieldIndex: number;
+ action: DetailFieldAction;
+}
+
+/**
+ * Walk all sections and collect fields that have an action defined.
+ * Returns a flat list of references preserving section/field indices
+ * so the component can map selections back to the right field.
+ */
+export function collectActionableFields(
+ sections: DetailSection[],
+): ActionableFieldRef[] {
+ const refs: ActionableFieldRef[] = [];
+ sections.forEach((section, sectionIndex) => {
+ section.fields
+ .filter((field) => field.value !== undefined && field.value !== null)
+ .forEach((field, fieldIndex) => {
+ if (field.action) {
+ refs.push({ sectionIndex, fieldIndex, action: field.action });
+ }
+ });
+ });
+ return refs;
+}
+
+// ---------------------------------------------------------------------------
+// Component props
+// ---------------------------------------------------------------------------
+
+export interface ResourceDetailPageProps {
+ /** The resource being displayed */
+ resource: T;
+ /** Resource type name for breadcrumbs (e.g., "Blueprints", "Snapshots") */
+ resourceType: string;
+ /** Get display name for the resource */
+ getDisplayName: (resource: T) => string;
+ /** Get resource ID */
+ getId: (resource: T) => string;
+ /** Get resource status */
+ getStatus: (resource: T) => string;
+ /** Optional: Get URL to open in browser */
+ getUrl?: (resource: T) => string;
+ /** Breadcrumb items before the resource name */
+ breadcrumbPrefix?: Array<{ label: string; active?: boolean }>;
+ /** Detail sections to display in main view */
+ detailSections: DetailSection[];
+ /** Available operations/actions */
+ operations: ResourceOperation[];
+ /** Callback when operation is selected */
+ onOperation: (operation: string, resource: T) => void;
+ /** Callback to go back */
+ onBack: () => void;
+ /** Optional: Build detailed info lines for full details view */
+ buildDetailLines?: (resource: T) => React.ReactElement[];
+ /** Optional: Additional content to render after details section */
+ additionalContent?: React.ReactNode;
+ /** Optional: Polling function to refresh resource data */
+ pollResource?: () => Promise;
+ /** Polling interval in ms (default: 3000) */
+ pollInterval?: number;
+}
diff --git a/src/hooks/useInputHandler.ts b/src/hooks/useInputHandler.ts
new file mode 100644
index 00000000..54917672
--- /dev/null
+++ b/src/hooks/useInputHandler.ts
@@ -0,0 +1,172 @@
+/**
+ * useInputHandler - Declarative, mode-based input handling for Ink components.
+ *
+ * Replaces long imperative if/else chains in useInput callbacks with a
+ * structured system of ordered modes, each with a key-binding map.
+ * The first active mode wins; bindings are looked up by canonical key name.
+ */
+import { useInput, type Key } from "ink";
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+/**
+ * Canonical key names that can appear in a bindings map.
+ *
+ * Special keys use short names ("up", "enter", etc.).
+ * Printable characters use the character itself ("a", "/", etc.).
+ * Ctrl combinations use "ctrl+" (e.g. "ctrl+c").
+ */
+export type KeyName =
+ | "up"
+ | "down"
+ | "left"
+ | "right"
+ | "enter"
+ | "escape"
+ | "tab"
+ | "backspace"
+ | "delete"
+ | "pageUp"
+ | "pageDown"
+ | (string & {}); // any single-char or "ctrl+x" string
+
+/**
+ * A single input mode. Modes are evaluated in order; the first whose
+ * `active()` returns true handles the key event.
+ */
+export interface InputMode {
+ /** Human-readable name (useful for debugging). */
+ name: string;
+
+ /** Return true when this mode should handle input. */
+ active: () => boolean;
+
+ /** Map of canonical key name -> handler. */
+ bindings: Partial void>>;
+
+ /**
+ * If true, keys that don't match any binding are silently swallowed
+ * (the event does not fall through to subsequent modes).
+ * Useful for modal overlays like search-input where you want to
+ * prevent the underlying list from reacting.
+ */
+ captureAll?: boolean;
+
+ /**
+ * Called when no binding matched and captureAll is not set.
+ * Receives the raw Ink `(input, key)` arguments so you can do
+ * dynamic matching (e.g. operation shortcuts).
+ * If provided, the event is consumed and does not fall through.
+ */
+ onUnmatched?: (input: string, key: Key) => void;
+}
+
+export interface UseInputHandlerOptions {
+ /** Forwarded to Ink's useInput `isActive` option. */
+ isActive?: boolean;
+}
+
+// ---------------------------------------------------------------------------
+// Key resolution
+// ---------------------------------------------------------------------------
+
+/**
+ * Normalise Ink's (input, key) pair into a single canonical key name.
+ *
+ * Priority order (first match wins):
+ * 1. Special keys (arrows, enter, escape, etc.)
+ * 2. Ctrl+ combinations
+ * 3. The raw `input` string (printable character)
+ */
+export function resolveKeyName(input: string, key: Key): KeyName {
+ // Special keys
+ if (key.upArrow) return "up";
+ if (key.downArrow) return "down";
+ if (key.leftArrow) return "left";
+ if (key.rightArrow) return "right";
+ if (key.return) return "enter";
+ if (key.escape) return "escape";
+ if (key.tab) return "tab";
+ if (key.backspace) return "backspace";
+ if (key.delete) return "delete";
+ if (key.pageUp) return "pageUp";
+ if (key.pageDown) return "pageDown";
+
+ // Ctrl combinations (e.g. ctrl+c)
+ if (key.ctrl && input) return `ctrl+${input}`;
+
+ // Printable character
+ return input;
+}
+
+// ---------------------------------------------------------------------------
+// Hook
+// ---------------------------------------------------------------------------
+
+/**
+ * Declarative input handler.
+ *
+ * @param modes Ordered array of input modes. The first mode whose `active()`
+ * returns `true` gets to handle the key event.
+ * @param options Optional settings forwarded to Ink's useInput.
+ */
+export function useInputHandler(
+ modes: InputMode[],
+ options?: UseInputHandlerOptions,
+): void {
+ useInput(
+ (input, key) => {
+ const keyName = resolveKeyName(input, key);
+
+ for (const mode of modes) {
+ if (!mode.active()) continue;
+
+ // Try an exact binding match
+ const handler = mode.bindings[keyName];
+ if (handler) {
+ handler();
+ return;
+ }
+
+ // No binding matched — try the dynamic fallback
+ if (mode.onUnmatched) {
+ mode.onUnmatched(input, key);
+ return;
+ }
+
+ // captureAll: swallow the event silently
+ if (mode.captureAll) return;
+
+ // Default: first active mode consumes the event even if nothing matched
+ return;
+ }
+ },
+ { isActive: options?.isActive ?? true },
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Preset binding helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Common scroll bindings (j/k, arrows, page up/down).
+ * Spread into a mode's `bindings` to get standard scrolling behaviour.
+ */
+export function scrollBindings(
+ getScroll: () => number,
+ setScroll: (value: number) => void,
+): Partial void>> {
+ return {
+ down: () => setScroll(getScroll() + 1),
+ up: () => setScroll(Math.max(0, getScroll() - 1)),
+ j: () => setScroll(getScroll() + 1),
+ k: () => setScroll(Math.max(0, getScroll() - 1)),
+ s: () => setScroll(getScroll() + 1),
+ w: () => setScroll(Math.max(0, getScroll() - 1)),
+ pageDown: () => setScroll(getScroll() + 10),
+ pageUp: () => setScroll(Math.max(0, getScroll() - 10)),
+ };
+}
diff --git a/src/screens/BenchmarkJobDetailScreen.tsx b/src/screens/BenchmarkJobDetailScreen.tsx
index add0243d..6823b6be 100644
--- a/src/screens/BenchmarkJobDetailScreen.tsx
+++ b/src/screens/BenchmarkJobDetailScreen.tsx
@@ -447,6 +447,16 @@ export function BenchmarkJobDetailScreen({
return {
label: run.agentName,
value: {parts},
+ ...(run.benchmarkRunId
+ ? {
+ action: {
+ type: "navigate" as const,
+ screen: "benchmark-run-detail" as const,
+ params: { benchmarkRunId: run.benchmarkRunId },
+ hint: "View Run",
+ },
+ }
+ : {}),
};
});
@@ -527,6 +537,12 @@ export function BenchmarkJobDetailScreen({
sourceFields.push({
label: "Benchmark ID",
value: {source.benchmark_id},
+ action: {
+ type: "navigate" as const,
+ screen: "benchmark-detail" as const,
+ params: { benchmarkId: source.benchmark_id as string },
+ hint: "View Benchmark",
+ },
});
}
diff --git a/src/screens/BenchmarkRunDetailScreen.tsx b/src/screens/BenchmarkRunDetailScreen.tsx
index 3ac57931..159d3ac0 100644
--- a/src/screens/BenchmarkRunDetailScreen.tsx
+++ b/src/screens/BenchmarkRunDetailScreen.tsx
@@ -279,6 +279,12 @@ export function BenchmarkRunDetailScreen({
basicFields.push({
label: "Benchmark ID",
value: {run.benchmark_id},
+ action: {
+ type: "navigate" as const,
+ screen: "benchmark-detail" as const,
+ params: { benchmarkId: run.benchmark_id },
+ hint: "View Benchmark",
+ },
});
}
if (run.purpose) {
@@ -441,6 +447,12 @@ export function BenchmarkRunDetailScreen({
([envVar, secretName]) => ({
label: envVar,
value: {secretName} (secret),
+ action: {
+ type: "navigate" as const,
+ screen: "secret-detail" as const,
+ params: { secretId: secretName },
+ hint: "View Secret",
+ },
}),
);
diff --git a/src/screens/DevboxCreateScreen.tsx b/src/screens/DevboxCreateScreen.tsx
index 1ed0b693..4ea1d0cf 100644
--- a/src/screens/DevboxCreateScreen.tsx
+++ b/src/screens/DevboxCreateScreen.tsx
@@ -8,12 +8,19 @@ import { useNavigation } from "../store/navigationStore.js";
import { DevboxCreatePage } from "../components/DevboxCreatePage.js";
export function DevboxCreateScreen() {
- const { goBack, navigate } = useNavigation();
+ const { goBack, navigate, params } = useNavigation();
const handleCreate = (devbox: DevboxView) => {
// After creation, navigate to the devbox detail page
navigate("devbox-detail", { devboxId: devbox.id });
};
- return ;
+ return (
+
+ );
}
diff --git a/src/screens/ScenarioRunDetailScreen.tsx b/src/screens/ScenarioRunDetailScreen.tsx
index e81c95da..5ba67217 100644
--- a/src/screens/ScenarioRunDetailScreen.tsx
+++ b/src/screens/ScenarioRunDetailScreen.tsx
@@ -152,6 +152,12 @@ export function ScenarioRunDetailScreen({
basicFields.push({
label: "Benchmark Run ID",
value: {run.benchmark_run_id},
+ action: {
+ type: "navigate" as const,
+ screen: "benchmark-run-detail" as const,
+ params: { benchmarkRunId: run.benchmark_run_id },
+ hint: "View Run",
+ },
});
}
diff --git a/src/screens/SnapshotDetailScreen.tsx b/src/screens/SnapshotDetailScreen.tsx
index 006a1200..40a8fd8b 100644
--- a/src/screens/SnapshotDetailScreen.tsx
+++ b/src/screens/SnapshotDetailScreen.tsx
@@ -126,6 +126,12 @@ export function SnapshotDetailScreen({
basicFields.push({
label: "Source Devbox",
value: {snapshot.devbox_id},
+ action: {
+ type: "navigate" as const,
+ screen: "devbox-detail" as const,
+ params: { devboxId: snapshot.devbox_id },
+ hint: "View Devbox",
+ },
});
}
if (snapshot.disk_size_bytes) {
diff --git a/src/utils/browser.ts b/src/utils/browser.ts
new file mode 100644
index 00000000..e165ef4b
--- /dev/null
+++ b/src/utils/browser.ts
@@ -0,0 +1,23 @@
+/**
+ * Cross-platform browser-opening utility.
+ */
+
+/**
+ * Open a URL in the system's default browser.
+ * Works on macOS (open), Windows (start), and Linux (xdg-open).
+ */
+export async function openInBrowser(url: string): Promise {
+ const { exec } = await import("child_process");
+ const platform = process.platform;
+
+ let openCommand: string;
+ if (platform === "darwin") {
+ openCommand = `open "${url}"`;
+ } else if (platform === "win32") {
+ openCommand = `start "${url}"`;
+ } else {
+ openCommand = `xdg-open "${url}"`;
+ }
+
+ exec(openCommand);
+}
diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts
new file mode 100644
index 00000000..c97ee226
--- /dev/null
+++ b/src/utils/clipboard.ts
@@ -0,0 +1,44 @@
+/**
+ * Cross-platform clipboard utility.
+ */
+
+/**
+ * Copy text to the system clipboard.
+ * Returns a promise that resolves with a status message.
+ */
+export async function copyToClipboard(text: string): Promise {
+ const { spawn } = await import("child_process");
+ const platform = process.platform;
+
+ let command: string;
+ let args: string[];
+
+ if (platform === "darwin") {
+ command = "pbcopy";
+ args = [];
+ } else if (platform === "win32") {
+ command = "clip";
+ args = [];
+ } else {
+ command = "xclip";
+ args = ["-selection", "clipboard"];
+ }
+
+ return new Promise((resolve) => {
+ const proc = spawn(command, args);
+ proc.stdin.write(text);
+ proc.stdin.end();
+
+ proc.on("exit", (code) => {
+ if (code === 0) {
+ resolve("Copied to clipboard!");
+ } else {
+ resolve("Failed to copy");
+ }
+ });
+
+ proc.on("error", () => {
+ resolve("Copy not supported");
+ });
+ });
+}
diff --git a/src/utils/time.ts b/src/utils/time.ts
index f21511fd..ee47cfe8 100644
--- a/src/utils/time.ts
+++ b/src/utils/time.ts
@@ -102,3 +102,36 @@ export function formatTimeAgoRich(timestampMs: number): string {
// More than 7 days - just date + time
return `${dateStr} ${time}`;
}
+
+/**
+ * Format a timestamp as "locale string (relative ago)".
+ * Example: "1/15/2025, 3:30:00 PM (2h ago)"
+ *
+ * Returns undefined if timestamp is falsy.
+ */
+export function formatTimestamp(
+ timestamp: number | undefined,
+): string | undefined {
+ if (!timestamp) return undefined;
+ const formatted = new Date(timestamp).toLocaleString();
+ const ago = formatTimeAgo(timestamp);
+ return `${formatted} (${ago})`;
+}
+
+/**
+ * Format a time range as "start → end" or "start (relative ago)" if no end.
+ *
+ * Returns undefined if createTime is falsy.
+ */
+export function formatTimeRange(
+ createTime: number | undefined,
+ endTime: number | undefined,
+): string | undefined {
+ if (!createTime) return undefined;
+ const start = new Date(createTime).toLocaleString();
+ if (endTime) {
+ const end = new Date(endTime).toLocaleString();
+ return `${start} → ${end}`;
+ }
+ return `${start} (${formatTimeAgo(createTime)})`;
+}
diff --git a/tests/__tests__/components/DevboxDetailPage.test.tsx b/tests/__tests__/components/DevboxDetailPage.test.tsx
index 5d65ba94..cc5e71d4 100644
--- a/tests/__tests__/components/DevboxDetailPage.test.tsx
+++ b/tests/__tests__/components/DevboxDetailPage.test.tsx
@@ -4,6 +4,10 @@
import React from 'react';
import { render } from 'ink-testing-library';
import { DevboxDetailPage } from '../../../src/components/DevboxDetailPage.js';
+import { NavigationProvider } from '../../../src/store/navigationStore.js';
+
+const renderWithNav = (ui: React.ReactElement) =>
+ render({ui});
describe('DevboxDetailPage', () => {
const mockDevbox = {
@@ -19,7 +23,7 @@ describe('DevboxDetailPage', () => {
};
it('renders without crashing', () => {
- const { lastFrame } = render(
+ const { lastFrame } = renderWithNav(
{}}
@@ -29,7 +33,7 @@ describe('DevboxDetailPage', () => {
});
it('displays devbox name', () => {
- const { lastFrame } = render(
+ const { lastFrame } = renderWithNav(
{}}
@@ -39,7 +43,7 @@ describe('DevboxDetailPage', () => {
});
it('displays devbox id', () => {
- const { lastFrame } = render(
+ const { lastFrame } = renderWithNav(
{}}
@@ -49,7 +53,7 @@ describe('DevboxDetailPage', () => {
});
it('shows status badge', () => {
- const { lastFrame } = render(
+ const { lastFrame } = renderWithNav(
{}}
@@ -59,7 +63,7 @@ describe('DevboxDetailPage', () => {
});
it('shows Actions section', () => {
- const { lastFrame } = render(
+ const { lastFrame } = renderWithNav(
{}}
@@ -69,7 +73,7 @@ describe('DevboxDetailPage', () => {
});
it('shows available operations', () => {
- const { lastFrame } = render(
+ const { lastFrame } = renderWithNav(
{}}
@@ -82,7 +86,7 @@ describe('DevboxDetailPage', () => {
});
it('shows navigation help', () => {
- const { lastFrame } = render(
+ const { lastFrame } = renderWithNav(
{}}
@@ -96,7 +100,7 @@ describe('DevboxDetailPage', () => {
});
it('displays resource information', () => {
- const { lastFrame } = render(
+ const { lastFrame } = renderWithNav(
{}}
@@ -110,7 +114,7 @@ describe('DevboxDetailPage', () => {
});
it('displays capabilities', () => {
- const { lastFrame } = render(
+ const { lastFrame } = renderWithNav(
{}}
diff --git a/tests/__tests__/components/ResourceDetailPage.test.tsx b/tests/__tests__/components/ResourceDetailPage.test.tsx
new file mode 100644
index 00000000..ccfc4d0b
--- /dev/null
+++ b/tests/__tests__/components/ResourceDetailPage.test.tsx
@@ -0,0 +1,387 @@
+/**
+ * Tests for ResourceDetailPage component
+ */
+import React from "react";
+import { Text } from "ink";
+import { jest } from "@jest/globals";
+import { render } from "ink-testing-library";
+import {
+ ResourceDetailPage,
+ type DetailSection,
+ type ResourceOperation,
+} from "../../../src/components/ResourceDetailPage.js";
+import { NavigationProvider } from "../../../src/store/navigationStore.js";
+
+// Helper to wrap components with NavigationProvider
+const renderWithNav = (ui: React.ReactElement) =>
+ render({ui});
+
+// Shared test data
+interface TestResource {
+ id: string;
+ name: string;
+ status: string;
+}
+
+const mockResource: TestResource = {
+ id: "res_test_123",
+ name: "test-resource",
+ status: "running",
+};
+
+const mockOperations: ResourceOperation[] = [
+ {
+ key: "view-logs",
+ label: "View Logs",
+ color: "#00ff00",
+ icon: "ℹ",
+ shortcut: "l",
+ },
+ {
+ key: "delete",
+ label: "Delete Resource",
+ color: "#ff0000",
+ icon: "✖",
+ shortcut: "d",
+ },
+];
+
+const mockSections: DetailSection[] = [
+ {
+ title: "Details",
+ icon: "■",
+ color: "#ffaa00",
+ fields: [
+ { label: "Created", value: "2024-01-01" },
+ { label: "Region", value: "us-east-1" },
+ ],
+ },
+];
+
+const createDefaultProps = () => ({
+ resource: mockResource,
+ resourceType: "Resources",
+ getDisplayName: (r: TestResource) => r.name,
+ getId: (r: TestResource) => r.id,
+ getStatus: (r: TestResource) => r.status,
+ detailSections: mockSections,
+ operations: mockOperations,
+ onOperation: jest.fn(),
+ onBack: jest.fn(),
+});
+
+describe("ResourceDetailPage", () => {
+ // --- Rendering ---
+
+ it("renders without crashing", () => {
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ expect(lastFrame()).toBeTruthy();
+ });
+
+ it("displays the resource name", () => {
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ expect(lastFrame()).toContain("test-resource");
+ });
+
+ it("displays the resource ID when different from name", () => {
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ expect(lastFrame()).toContain("res_test_123");
+ });
+
+ it("does not show separate ID when name equals ID", () => {
+ const props = {
+ ...createDefaultProps(),
+ resource: { ...mockResource, name: "res_test_123" },
+ getDisplayName: (r: TestResource) => r.id,
+ };
+ const { lastFrame } = renderWithNav();
+ const frame = lastFrame() || "";
+ // Should NOT contain the "• res_test_123" separator pattern (i.e. ID shown separately from name)
+ expect(frame).not.toContain("• res_test_123");
+ });
+
+ it("displays the status badge", () => {
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ expect(lastFrame()).toContain("Running");
+ });
+
+ // --- Detail Sections ---
+
+ it("renders section titles", () => {
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ expect(lastFrame()).toContain("Details");
+ });
+
+ it("renders field labels and values", () => {
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ const frame = lastFrame() || "";
+ expect(frame).toContain("Created");
+ expect(frame).toContain("2024-01-01");
+ expect(frame).toContain("Region");
+ expect(frame).toContain("us-east-1");
+ });
+
+ it("filters out fields with undefined values", () => {
+ const sections: DetailSection[] = [
+ {
+ title: "Info",
+ fields: [
+ { label: "Present", value: "yes" },
+ { label: "Missing", value: undefined },
+ { label: "Null", value: null },
+ ],
+ },
+ ];
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ const frame = lastFrame() || "";
+ expect(frame).toContain("Present");
+ expect(frame).not.toContain("Missing");
+ expect(frame).not.toContain("Null");
+ });
+
+ it("renders multiple sections", () => {
+ const sections: DetailSection[] = [
+ { title: "Section A", fields: [{ label: "A1", value: "val1" }] },
+ { title: "Section B", fields: [{ label: "B1", value: "val2" }] },
+ ];
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ const frame = lastFrame() || "";
+ expect(frame).toContain("Section A");
+ expect(frame).toContain("Section B");
+ });
+
+ // --- Operations ---
+
+ it("renders the Actions section", () => {
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ expect(lastFrame()).toContain("Actions");
+ });
+
+ it("displays all operations with labels", () => {
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ const frame = lastFrame() || "";
+ expect(frame).toContain("View Logs");
+ expect(frame).toContain("Delete Resource");
+ });
+
+ it("displays operation shortcuts", () => {
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ const frame = lastFrame() || "";
+ expect(frame).toContain("[l]");
+ expect(frame).toContain("[d]");
+ });
+
+ it("does not render Actions section when operations is empty", () => {
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ const frame = lastFrame() || "";
+ expect(frame).not.toContain("Actions");
+ });
+
+ // --- Actionable Fields ---
+
+ it("renders actionable fields with label and value", () => {
+ const sections: DetailSection[] = [
+ {
+ title: "Info",
+ fields: [
+ {
+ label: "Blueprint",
+ value: "bpt_123",
+ action: {
+ type: "navigate" as const,
+ screen: "blueprint-detail" as const,
+ params: { blueprintId: "bpt_123" },
+ hint: "View Blueprint",
+ },
+ },
+ ],
+ },
+ ];
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ const frame = lastFrame() || "";
+ expect(frame).toContain("Blueprint");
+ expect(frame).toContain("bpt_123");
+ });
+
+ // --- Navigation Tips ---
+
+ it("shows navigation tips", () => {
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ const frame = lastFrame() || "";
+ expect(frame).toContain("Execute");
+ expect(frame).toContain("Copy ID");
+ expect(frame).toContain("Back");
+ });
+
+ it("shows Full Details tip when buildDetailLines is provided", () => {
+ const { lastFrame } = renderWithNav(
+ []}
+ />,
+ );
+ const frame = lastFrame() || "";
+ expect(frame).toContain("Full Details");
+ });
+
+ it("shows Browser tip when getUrl is provided", () => {
+ const { lastFrame } = renderWithNav(
+ "https://example.com"}
+ />,
+ );
+ const frame = lastFrame() || "";
+ expect(frame).toContain("Browser");
+ });
+
+ // --- Breadcrumbs ---
+
+ it("shows resource type in breadcrumbs", () => {
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ expect(lastFrame()).toContain("Resources");
+ });
+
+ it("shows custom breadcrumb prefix", () => {
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ expect(lastFrame()).toContain("Home");
+ });
+
+ // --- Additional Content ---
+
+ it("renders additional content", () => {
+ const { lastFrame } = renderWithNav(
+ Extra content here}
+ />,
+ );
+ expect(lastFrame()).toContain("Extra content here");
+ });
+
+ // --- Selection state ---
+
+ it("defaults selection to first operation (not links)", () => {
+ const sections: DetailSection[] = [
+ {
+ title: "Info",
+ fields: [
+ {
+ label: "Source",
+ value: "bpt_123",
+ action: {
+ type: "navigate" as const,
+ screen: "blueprint-detail" as const,
+ params: { blueprintId: "bpt_123" },
+ hint: "View Blueprint",
+ },
+ },
+ ],
+ },
+ ];
+ const { lastFrame } = renderWithNav(
+ ,
+ );
+ const frame = lastFrame() || "";
+ // The link hint should NOT be visible since it's not selected
+ expect(frame).not.toContain("View Blueprint");
+ // The first operation should be rendered
+ expect(frame).toContain("View Logs");
+ });
+
+ // --- Keyboard interaction ---
+
+ it("calls onBack when escape is pressed", () => {
+ const props = createDefaultProps();
+ const { stdin } = renderWithNav(
+ ,
+ );
+ stdin.write("\u001B"); // escape
+ expect(props.onBack).toHaveBeenCalled();
+ });
+
+ it("calls onBack when q is pressed", () => {
+ const props = createDefaultProps();
+ const { stdin } = renderWithNav(
+ ,
+ );
+ stdin.write("q");
+ expect(props.onBack).toHaveBeenCalled();
+ });
+
+ it("calls onOperation with correct key when Enter is pressed on an operation", () => {
+ const props = createDefaultProps();
+ const { stdin } = renderWithNav(
+ ,
+ );
+ // Default selection is the first operation, press Enter
+ stdin.write("\r");
+ expect(props.onOperation).toHaveBeenCalledWith("view-logs", mockResource);
+ });
+
+ it("calls onOperation via shortcut key", () => {
+ const props = createDefaultProps();
+ const { stdin } = renderWithNav(
+ ,
+ );
+ // Press 'd' shortcut for delete
+ stdin.write("d");
+ expect(props.onOperation).toHaveBeenCalledWith("delete", mockResource);
+ });
+
+ it("triggers different operations via different shortcuts", () => {
+ const props = createDefaultProps();
+ const { stdin } = renderWithNav(
+ ,
+ );
+ stdin.write("l");
+ expect(props.onOperation).toHaveBeenCalledWith("view-logs", mockResource);
+ });
+
+ it("does not trigger operations for non-shortcut keys", () => {
+ const props = createDefaultProps();
+ const { stdin } = renderWithNav(
+ ,
+ );
+ stdin.write("x"); // not a shortcut
+ expect(props.onOperation).not.toHaveBeenCalled();
+ });
+});