Skip to content

Commit 634b934

Browse files
ieedanclaude
andauthored
feat(tui): copy log selection to clipboard with Ctrl+C (#43)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 57e8a98 commit 634b934

4 files changed

Lines changed: 148 additions & 5 deletions

File tree

apps/tui/src/components/run-details-panel.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { StatusIndicator } from "./status-indicator";
2020
const LOG_LINE_HEIGHT = 1;
2121
const VIRTUALIZATION_THRESHOLD = 300;
2222
const OVERSCAN_ROWS = 10;
23+
const LOG_SELECTION_BG = "#3a4a78";
24+
const LOG_SELECTION_FG = "#ffffff";
2325

2426
interface ScrollboxRef {
2527
scrollTop: number;
@@ -57,20 +59,31 @@ function LogLine(props: {
5759
return (
5860
<box flexDirection="row">
5961
<box flexShrink={0} width={props.logTimestampWidth + 1}>
60-
<text fg="#666666">
62+
<text
63+
fg="#666666"
64+
selectionBg={LOG_SELECTION_BG}
65+
selectionFg={LOG_SELECTION_FG}
66+
>
6167
{formatLogTimestamp(props.line.timestamp)}{" "}
6268
</text>
6369
</box>
6470
<box flexShrink={0} width={props.logTaskTagWidth}>
65-
<text fg={props.logColorByTaskKey[props.line.task]}>
71+
<text
72+
fg={props.logColorByTaskKey[props.line.task]}
73+
selectionBg={LOG_SELECTION_BG}
74+
selectionFg={LOG_SELECTION_FG}
75+
>
6676
{formatTaskTagForLog(
6777
props.line.task,
6878
props.logTaskTagWidth
6979
)}
7080
</text>
7181
</box>
7282
<box flexGrow={1}>
73-
<text>
83+
<text
84+
selectionBg={LOG_SELECTION_BG}
85+
selectionFg={LOG_SELECTION_FG}
86+
>
7487
<For each={parseAnsiLogSegments(props.line.line)}>
7588
{(segment) => (
7689
<span style={segment.style}>{segment.text}</span>

apps/tui/src/components/status-footer.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { For } from "solid-js";
22

33
interface StatusFooterProps {
44
errorMessage: string | null;
5+
copyToastMessage: string | null;
56
canNavigateTasks: boolean;
67
canJumpParentTasks: boolean;
78
canRunOrRestart: boolean;
@@ -24,6 +25,7 @@ export function StatusFooter(props: StatusFooterProps) {
2425
if (props.canToggleLogMode) {
2526
parts.push({ key: "m", label: `logs: ${props.logMode}` });
2627
}
28+
parts.push({ key: "ctrl+c", label: "copy" });
2729
parts.push({ key: "q", label: "quit" });
2830
return parts;
2931
}
@@ -50,6 +52,16 @@ export function StatusFooter(props: StatusFooterProps) {
5052
</>
5153
)}
5254
</For>
55+
{props.copyToastMessage ? (
56+
<>
57+
<span style={{ fg: "#666666" }}> | </span>
58+
<span style={{ fg: "#7ddc8e" }}>
59+
{props.copyToastMessage}
60+
</span>
61+
</>
62+
) : (
63+
""
64+
)}
5365
{props.errorMessage ? ` | error: ${props.errorMessage}` : ""}
5466
</text>
5567
</box>

apps/tui/src/index.tsx

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
isJumpParentsForwardShortcut,
3030
} from "./lib/keyboard-shortcuts";
3131
import { resolveTaskLogColor } from "./lib/logs";
32+
import { getSelectedTextByRow } from "./lib/selection-copy";
3233
import {
3334
buildDisplayStatusByTaskKey,
3435
canCancelRun,
@@ -69,6 +70,26 @@ function App() {
6970
const [isCancellingBeforeExit, setIsCancellingBeforeExit] =
7071
createSignal(false);
7172
const [errorMessage, setErrorMessage] = createSignal<string | null>(null);
73+
const [copyToastMessage, setCopyToastMessage] = createSignal<string | null>(
74+
null
75+
);
76+
let copyToastTimeoutId: ReturnType<typeof setTimeout> | null = null;
77+
const showCopyToast = (message: string) => {
78+
setCopyToastMessage(message);
79+
if (copyToastTimeoutId !== null) {
80+
clearTimeout(copyToastTimeoutId);
81+
}
82+
copyToastTimeoutId = setTimeout(() => {
83+
setCopyToastMessage(null);
84+
copyToastTimeoutId = null;
85+
}, 2000);
86+
};
87+
onCleanup(() => {
88+
if (copyToastTimeoutId !== null) {
89+
clearTimeout(copyToastTimeoutId);
90+
copyToastTimeoutId = null;
91+
}
92+
});
7293

7394
const taskTree = createMemo(() => buildTaskTree(tasks()));
7495
const taskRows = createMemo(() => flattenTaskRows(taskTree()));
@@ -279,6 +300,46 @@ function App() {
279300
return key.name === "q" || (key.ctrl && key.name === "c");
280301
}
281302

303+
function copySelectionToClipboard(): boolean {
304+
const selection = renderer.getSelection();
305+
if (!selection?.isActive) {
306+
return false;
307+
}
308+
const text = getSelectedTextByRow(selection);
309+
if (text.length === 0) {
310+
return false;
311+
}
312+
const copied = renderer.copyToClipboardOSC52(text);
313+
const lineCount = text.split("\n").length;
314+
const linesLabel = lineCount === 1 ? "1 line" : `${lineCount} lines`;
315+
showCopyToast(
316+
copied
317+
? `Copied ${linesLabel} to clipboard`
318+
: "Copy failed (terminal does not support OSC52)"
319+
);
320+
renderer.clearSelection();
321+
return true;
322+
}
323+
324+
function handleCopySelectionKey(key: {
325+
name: string;
326+
ctrl?: boolean;
327+
meta?: boolean;
328+
super?: boolean;
329+
}) {
330+
if (key.name !== "c") {
331+
return false;
332+
}
333+
// `ctrl` covers Ctrl+C on every platform.
334+
// `super`/`meta` covers Cmd+C in terminals that forward it (Kitty,
335+
// Ghostty, WezTerm with kitty keyboard protocol). Apple Terminal and
336+
// default iTerm2 swallow Cmd+C themselves before it ever reaches us.
337+
if (!(key.ctrl || key.meta || key.super)) {
338+
return false;
339+
}
340+
return copySelectionToClipboard();
341+
}
342+
282343
async function cancelRunningTasksBeforeExit() {
283344
if (isCancellingBeforeExit()) {
284345
return;
@@ -502,6 +563,9 @@ function App() {
502563
if (handleQuitConfirmationKeys()) {
503564
return;
504565
}
566+
if (handleCopySelectionKey(key)) {
567+
return;
568+
}
505569
if (handleQuitKey(key)) {
506570
requestQuit();
507571
return;
@@ -663,6 +727,7 @@ function App() {
663727
canNavigateTasks={canNavigateTasks()}
664728
canRunOrRestart={hasTaskSelection()}
665729
canToggleLogMode={canToggleLogMode()}
730+
copyToastMessage={copyToastMessage()}
666731
errorMessage={errorMessage()}
667732
logMode={logMode()}
668733
runAction={selectedRunAction()}
@@ -672,7 +737,9 @@ function App() {
672737
isCancelling={isCancellingBeforeExit()}
673738
onConfirm={(action) => {
674739
if (action === "cancelAll") {
675-
cancelRunningTasksBeforeExit().catch(() => quit());
740+
cancelRunningTasksBeforeExit().catch(() =>
741+
quit()
742+
);
676743
} else {
677744
quit();
678745
}
@@ -693,7 +760,10 @@ async function main() {
693760

694761
cliOptions = mode.cliOptions;
695762
cwd = cliOptions.cwd;
696-
render(() => <App />);
763+
// We handle Ctrl+C ourselves so it can copy a selection instead of always
764+
// exiting. Without this the renderer would call destroy() on Ctrl+C even
765+
// when our useKeyboard handler returns early.
766+
render(() => <App />, { exitOnCtrlC: false });
697767
}
698768

699769
main().catch((error: unknown) => {

apps/tui/src/lib/selection-copy.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { Renderable, Selection } from "@opentui/core";
2+
3+
/**
4+
* opentui's Selection.getSelectedText() sorts every selected renderable by
5+
* (y, x) and joins them with newlines. That breaks for log views where each
6+
* row contains multiple <text> children (timestamp, task tag, content) — each
7+
* sibling on the same row would become its own line in the copied output.
8+
*
9+
* Group selected renderables by their absolute y position so each visual row
10+
* is one line, then join siblings on that row left-to-right.
11+
*/
12+
export function getSelectedTextByRow(selection: Selection): string {
13+
const renderables = selection.selectedRenderables.filter(
14+
(renderable: Renderable) => !renderable.isDestroyed
15+
);
16+
if (renderables.length === 0) {
17+
return "";
18+
}
19+
20+
const rowByY = new Map<number, Renderable[]>();
21+
for (const renderable of renderables) {
22+
const existing = rowByY.get(renderable.y);
23+
if (existing) {
24+
existing.push(renderable);
25+
} else {
26+
rowByY.set(renderable.y, [renderable]);
27+
}
28+
}
29+
30+
const sortedYs = [...rowByY.keys()].sort((a, b) => a - b);
31+
const lines: string[] = [];
32+
for (const y of sortedYs) {
33+
const row = rowByY.get(y);
34+
if (!row) {
35+
continue;
36+
}
37+
row.sort((a, b) => a.x - b.x);
38+
const rowText = row
39+
.map((renderable) => renderable.getSelectedText())
40+
.filter((text) => text.length > 0)
41+
.join(" ");
42+
if (rowText.length > 0) {
43+
lines.push(rowText);
44+
}
45+
}
46+
47+
return lines.join("\n").replace(/\s+$/g, "");
48+
}

0 commit comments

Comments
 (0)