Skip to content

Commit 83874ca

Browse files
authored
feat: add clipboard keybinds to detail screens (#231)
## Summary - Add `y` keybind to copy resource name on all detail screens (in addition to existing `c` for Copy ID) - Add `h` keybind to copy endpoint URL on MCP config detail screen (shown in footer alongside Copy ID/Name) - Enhanced clipboard feedback: shows "ID copied!", "Name copied!", "Endpoint copied!" instead of generic message - New `extraKeybinds`/`extraNavTips` props on `ResourceDetailPage` for resource-specific shortcuts - Covers all 14 detail screens via centralized change in `ResourceDetailPage` - TUI equivalent of clipboard buttons added in runloop-fe PR #1825 **Stacked on #230** ## Test plan - [ ] TUI: Any detail screen → press `y` → verify "Name copied!" feedback and correct clipboard content - [ ] TUI: Any detail screen → press `c` → verify "ID copied!" feedback (updated from generic "Copied to clipboard!") - [ ] TUI: MCP config detail → press `h` → verify "Endpoint copied!" feedback and endpoint URL in clipboard - [ ] TUI: MCP config detail → verify `h` shortcut shown in footer navigation tips - [ ] TUI: Devbox detail → verify `n` still triggers "Create Snapshot" (no conflict) - [ ] TUI: Agent detail → verify `n` still triggers "Create Devbox with Agent" (no conflict) - [ ] Type check: `npx tsc --noEmit` passes cleanly 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 7565d45 commit 83874ca

14 files changed

Lines changed: 107 additions & 3 deletions

src/commands/agent/list.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
3131
import { useCursorPagination } from "../../hooks/useCursorPagination.js";
3232
import { useListSearch } from "../../hooks/useListSearch.js";
3333
import { useNavigation } from "../../store/navigationStore.js";
34+
import { openInBrowser } from "../../utils/browser.js";
35+
import { getAgentUrl } from "../../utils/url.js";
3436

3537
interface ListOptions {
3638
full?: boolean;
@@ -532,6 +534,8 @@ export const ListAgentsUI = ({
532534
setSelectedOperation(0);
533535
} else if (input === "c" && activeTab === "private") {
534536
navigate("agent-create");
537+
} else if (input === "o" && selectedAgentItem) {
538+
openInBrowser(getAgentUrl(selectedAgentItem.id));
535539
} else if (input === "/") {
536540
search.enterSearchMode();
537541
} else if (key.escape) {
@@ -771,6 +775,7 @@ export const ListAgentsUI = ({
771775
{ key: "Tab", label: "Switch tab" },
772776
{ key: "Enter", label: "Details" },
773777
{ key: "a", label: "Actions" },
778+
{ key: "o", label: "Browser" },
774779
{ key: "c", label: "Create", condition: activeTab === "private" },
775780
{ key: "/", label: "Search" },
776781
{ key: "Esc", label: "Back" },

src/commands/axon/list.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
2222
import { useCursorPagination } from "../../hooks/useCursorPagination.js";
2323
import { useListSearch } from "../../hooks/useListSearch.js";
2424
import { useNavigation } from "../../store/navigationStore.js";
25+
import { openInBrowser } from "../../utils/browser.js";
26+
import { getAxonUrl } from "../../utils/url.js";
2527

2628
// ─── CLI ─────────────────────────────────────────────────────────────────────
2729

@@ -284,6 +286,8 @@ export const ListAxonsUI = ({
284286
} else if (input === "a" && selectedAxonItem) {
285287
setShowPopup(true);
286288
setSelectedOperation(0);
289+
} else if (input === "o" && selectedAxonItem) {
290+
openInBrowser(getAxonUrl(selectedAxonItem.id));
287291
} else if (input === "/") {
288292
search.enterSearchMode();
289293
} else if (key.escape) {
@@ -430,6 +434,7 @@ export const ListAxonsUI = ({
430434
},
431435
{ key: "Enter", label: "Details" },
432436
{ key: "a", label: "Actions" },
437+
{ key: "o", label: "Browser" },
433438
{ key: "/", label: "Search" },
434439
{ key: "Esc", label: "Back" },
435440
]}

src/components/ResourceDetailPage.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ export function ResourceDetailPage<T>({
152152
onBack,
153153
buildDetailLines,
154154
additionalContent,
155+
extraKeybinds,
156+
extraNavTips,
155157
}: ResourceDetailPageProps<T>) {
156158
const isMounted = React.useRef(true);
157159
const { navigate } = useNavigation();
@@ -167,9 +169,9 @@ export function ResourceDetailPage<T>({
167169
const [copyStatus, setCopyStatus] = React.useState<string | null>(null);
168170

169171
// Copy to clipboard with status feedback
170-
const handleCopy = React.useCallback(async (text: string) => {
172+
const handleCopy = React.useCallback(async (text: string, label?: string) => {
171173
const status = await copyToClipboard(text);
172-
setCopyStatus(status);
174+
setCopyStatus(label ? `${label} copied!` : status);
173175
setTimeout(() => setCopyStatus(null), 2000);
174176
}, []);
175177

@@ -393,7 +395,8 @@ export function ResourceDetailPage<T>({
393395
bindings: {
394396
q: onBack,
395397
escape: onBack,
396-
c: () => handleCopy(getId(resource)),
398+
c: () => handleCopy(getId(resource), "ID"),
399+
y: () => handleCopy(getDisplayName(resource), "Name"),
397400
...(buildDetailLines
398401
? {
399402
i: () => {
@@ -411,6 +414,7 @@ export function ResourceDetailPage<T>({
411414
},
412415
enter: handleEnter,
413416
...(getUrl ? { o: handleOpenInBrowser } : {}),
417+
...(extraKeybinds ? extraKeybinds({ copy: handleCopy }) : {}),
414418
},
415419
onUnmatched: (input) => {
416420
// Operation shortcuts work from anywhere (all ops, including those in "View rest")
@@ -452,6 +456,8 @@ export function ResourceDetailPage<T>({
452456
operationsStartIndex,
453457
onOperation,
454458
getId,
459+
getDisplayName,
460+
extraKeybinds,
455461
],
456462
);
457463

@@ -808,6 +814,8 @@ export function ResourceDetailPage<T>({
808814
: "Execute",
809815
},
810816
{ key: "c", label: "Copy ID" },
817+
{ key: "y", label: "Copy Name" },
818+
...(extraNavTips || []),
811819
{ key: "i", label: "Full Details", condition: !!buildDetailLines },
812820
{ key: "o", label: "Browser", condition: !!getUrl },
813821
{ key: "q/Ctrl+C", label: "Back/Quit" },

src/components/resourceDetailTypes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import React from "react";
55
import type { ScreenName, RouteParams } from "../store/navigationStore.js";
6+
import type { NavigationTip } from "./NavigationTips.js";
67

78
// ---------------------------------------------------------------------------
89
// Detail field types
@@ -119,4 +120,10 @@ export interface ResourceDetailPageProps<T> {
119120
buildDetailLines?: (resource: T) => React.ReactElement[];
120121
/** Optional: Additional content to render after details section */
121122
additionalContent?: React.ReactNode;
123+
/** Optional: Extra keybinds added to the main view. Receives a copy helper for clipboard feedback. */
124+
extraKeybinds?: (helpers: {
125+
copy: (text: string, label?: string) => void;
126+
}) => Record<string, () => void>;
127+
/** Optional: Extra navigation tips shown in the footer alongside the standard ones */
128+
extraNavTips?: NavigationTip[];
122129
}

src/screens/AgentDetailScreen.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { ErrorMessage } from "../components/ErrorMessage.js";
2222
import { Breadcrumb } from "../components/Breadcrumb.js";
2323
import { ConfirmationPrompt } from "../components/ConfirmationPrompt.js";
2424
import { colors } from "../utils/theme.js";
25+
import { getAgentUrl } from "../utils/url.js";
2526

2627
interface AgentDetailScreenProps {
2728
agentId?: string;
@@ -332,6 +333,7 @@ export function AgentDetailScreen({ agentId }: AgentDetailScreenProps) {
332333
resourceType="Agents"
333334
getDisplayName={(a) => a.name}
334335
getId={(a) => a.id}
336+
getUrl={(a) => getAgentUrl(a.id)}
335337
getStatus={() => (agent.is_public ? "public" : "private")}
336338
detailSections={detailSections}
337339
operations={operations}

src/screens/AxonDetailScreen.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { SpinnerComponent } from "../components/Spinner.js";
1616
import { ErrorMessage } from "../components/ErrorMessage.js";
1717
import { Breadcrumb } from "../components/Breadcrumb.js";
1818
import { colors } from "../utils/theme.js";
19+
import { getAxonUrl } from "../utils/url.js";
1920

2021
interface AxonDetailScreenProps {
2122
axonId?: string;
@@ -143,6 +144,7 @@ export function AxonDetailScreen({ axonId }: AxonDetailScreenProps) {
143144
resourceType="Axons"
144145
getDisplayName={(a) => a.name ?? a.id}
145146
getId={(a) => a.id}
147+
getUrl={(a) => getAxonUrl(a.id)}
146148
getStatus={() => "active"}
147149
detailSections={detailSections}
148150
operations={[]}

src/screens/BenchmarkDetailScreen.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { SpinnerComponent } from "../components/Spinner.js";
1818
import { ErrorMessage } from "../components/ErrorMessage.js";
1919
import { Breadcrumb } from "../components/Breadcrumb.js";
2020
import { colors } from "../utils/theme.js";
21+
import { getBenchmarkUrl } from "../utils/url.js";
2122

2223
interface BenchmarkDetailScreenProps {
2324
benchmarkId?: string;
@@ -275,6 +276,7 @@ export function BenchmarkDetailScreen({
275276
resourceType="Benchmark Definitions"
276277
getDisplayName={(b) => b.name || b.id}
277278
getId={(b) => b.id}
279+
getUrl={(b) => getBenchmarkUrl(b.id, !!b.is_public)}
278280
getStatus={(b) => (b as any).status}
279281
detailSections={detailSections}
280282
operations={operations}

src/screens/BenchmarkListScreen.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import {
2929
listPublicBenchmarks,
3030
} from "../services/benchmarkService.js";
3131
import type { Benchmark } from "../store/benchmarkStore.js";
32+
import { openInBrowser } from "../utils/browser.js";
33+
import { getBenchmarkUrl } from "../utils/url.js";
3234

3335
export function BenchmarkListScreen() {
3436
const { exit: inkExit } = useApp();
@@ -281,6 +283,8 @@ export function BenchmarkListScreen() {
281283
} else if (input === "a" && selectedBenchmark) {
282284
setShowPopup(true);
283285
setSelectedOperation(0);
286+
} else if (input === "o" && selectedBenchmark) {
287+
openInBrowser(getBenchmarkUrl(selectedBenchmark.id, showPublic));
284288
} else if (input === "s" && selectedBenchmark) {
285289
// Quick shortcut to create a job
286290
navigate("benchmark-job-create", {
@@ -463,6 +467,7 @@ export function BenchmarkListScreen() {
463467
{ key: "Enter", label: "Details" },
464468
{ key: "s", label: "Create Job" },
465469
{ key: "a", label: "Actions" },
470+
{ key: "o", label: "Browser" },
466471
{ key: "Tab", label: "Switch tab" },
467472
{ key: "/", label: "Search" },
468473
{ key: "Esc", label: "Back" },

src/screens/BenchmarkRunDetailScreen.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
createComponentColumn,
3333
} from "../components/Table.js";
3434
import { colors } from "../utils/theme.js";
35+
import { getBenchmarkRunUrl } from "../utils/url.js";
3536

3637
interface BenchmarkRunDetailScreenProps {
3738
benchmarkRunId?: string;
@@ -621,6 +622,7 @@ export function BenchmarkRunDetailScreen({
621622
resourceType="Benchmark Runs"
622623
getDisplayName={(r) => r.name || r.id}
623624
getId={(r) => r.id}
625+
getUrl={(r) => getBenchmarkRunUrl(r.id, r.benchmark_id)}
624626
getStatus={(r) => r.state}
625627
detailSections={detailSections}
626628
operations={operations}

src/screens/BenchmarkRunListScreen.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import { useCursorPagination } from "../hooks/useCursorPagination.js";
2626
import { useListSearch } from "../hooks/useListSearch.js";
2727
import { listBenchmarkRuns } from "../services/benchmarkService.js";
2828
import type { BenchmarkRun } from "../store/benchmarkStore.js";
29+
import { openInBrowser } from "../utils/browser.js";
30+
import { getBenchmarkRunUrl } from "../utils/url.js";
2931

3032
export function BenchmarkRunListScreen() {
3133
const { exit: inkExit } = useApp();
@@ -272,6 +274,10 @@ export function BenchmarkRunListScreen() {
272274
} else if (input === "a" && selectedRun) {
273275
setShowPopup(true);
274276
setSelectedOperation(0);
277+
} else if (input === "o" && selectedRun) {
278+
openInBrowser(
279+
getBenchmarkRunUrl(selectedRun.id, selectedRun.benchmark_id),
280+
);
275281
} else if (input === "j") {
276282
// Quick shortcut to create a new job
277283
navigate("benchmark-job-create");
@@ -433,6 +439,7 @@ export function BenchmarkRunListScreen() {
433439
{ key: "Enter", label: "Details" },
434440
{ key: "j", label: "New Job" },
435441
{ key: "a", label: "Actions" },
442+
{ key: "o", label: "Browser" },
436443
{ key: "/", label: "Search" },
437444
{ key: "Esc", label: "Back" },
438445
]}

0 commit comments

Comments
 (0)