Skip to content
This repository was archived by the owner on Mar 10, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ import {
import { Toggle } from "./ui/toggle";
import { SidebarTrigger } from "./ui/sidebar";
import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
import { copyTextToClipboard } from "~/lib/clipboard";
import { readNativeApi } from "~/nativeApi";
import {
getAppModelOptions,
Expand Down Expand Up @@ -1417,6 +1418,10 @@ export default function ChatView({ threadId, splitPaneCount = 1 }: ChatViewProps
() => shortcutLabelForCommand(keybindings, "terminal.split"),
[keybindings],
);
const terminalToggleShortcutLabel = useMemo(
() => shortcutLabelForCommand(keybindings, "terminal.toggle"),
[keybindings],
);
const newTerminalShortcutLabel = useMemo(
() => shortcutLabelForCommand(keybindings, "terminal.new"),
[keybindings],
Expand All @@ -1425,6 +1430,10 @@ export default function ChatView({ threadId, splitPaneCount = 1 }: ChatViewProps
() => shortcutLabelForCommand(keybindings, "terminal.close"),
[keybindings],
);
const terminalToggleActionLabel = useMemo(() => {
const action = terminalState.terminalOpen ? "Hide Terminal" : "Open Terminal";
return terminalToggleShortcutLabel ? `${action} (${terminalToggleShortcutLabel})` : action;
}, [terminalState.terminalOpen, terminalToggleShortcutLabel]);
const diffPanelShortcutLabel = useMemo(
() => shortcutLabelForCommand(keybindings, "diff.toggle"),
[keybindings],
Expand Down Expand Up @@ -3833,6 +3842,30 @@ export default function ChatView({ threadId, splitPaneCount = 1 }: ChatViewProps
{runtimeMode === "full-access" ? "Full access" : "Supervised"}
</span>
</Button>

{/* Divider */}
<Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />

{/* Terminal toggle */}
<Button
variant={terminalState.terminalOpen ? "outline" : "ghost"}
className={cn(
"shrink-0 rounded whitespace-nowrap px-2 sm:px-3",
terminalState.terminalOpen
? "border-border/70 text-foreground"
: "text-muted-foreground/70 hover:text-foreground/80",
)}
size="sm"
type="button"
onClick={toggleTerminalVisibility}
title={activeProject ? terminalToggleActionLabel : "Terminal unavailable"}
aria-label={activeProject ? terminalToggleActionLabel : "Terminal unavailable"}
aria-pressed={terminalState.terminalOpen}
disabled={!activeProject}
>
<TerminalIcon />
<span className="sr-only sm:not-sr-only">Terminal</span>
</Button>
</div>

{/* Right side: send / stop button */}
Expand Down Expand Up @@ -4614,9 +4647,12 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri
const [copied, setCopied] = useState(false);

const handleCopy = useCallback(() => {
void navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
void copyTextToClipboard(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => undefined);
}, [text]);

return (
Expand Down
8 changes: 1 addition & 7 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraft
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
import { useProjectNavigation } from "../hooks/useProjectNavigation";
import { getTerminalStatusIndicator, getThreadStatusPill } from "../lib/threadStatus";
import { copyTextToClipboard } from "../lib/clipboard";
import { toastManager } from "./ui/toast";
import {
AlertDialog,
Expand Down Expand Up @@ -152,13 +153,6 @@ const PROJECT_CONTEXT_MENU_ENTRIES: readonly SidebarContextMenuEntry<ProjectCont
{ type: "item", id: "delete", label: "Delete", icon: Trash2Icon },
];

async function copyTextToClipboard(text: string): Promise<void> {
if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) {
throw new Error("Clipboard API unavailable.");
}
await navigator.clipboard.writeText(text);
}

function formatRelativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60_000);
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import {
useState,
} from "react";
import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
import { copyTextToClipboard } from "~/lib/clipboard";
import {
extractTerminalLinks,
isTerminalLinkActivation,
preferredTerminalEditor,
resolvePathLinkTarget,
} from "../terminal-links";
import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings";
import { isTerminalSelectionCopyShortcut } from "../terminalKeyboard";
import {
DEFAULT_THREAD_TERMINAL_HEIGHT,
DEFAULT_THREAD_TERMINAL_ID,
Expand Down Expand Up @@ -176,6 +178,28 @@ function TerminalViewport({
};

terminal.attachCustomKeyEventHandler((event) => {
const activeTerminal = terminalRef.current;
if (!activeTerminal) {
return true;
}

if (
isTerminalSelectionCopyShortcut(event, {
hasSelection: activeTerminal.hasSelection(),
})
) {
const selection = activeTerminal.getSelection();
event.preventDefault();
event.stopPropagation();
void copyTextToClipboard(selection).catch((error) => {
writeSystemMessage(
activeTerminal,
error instanceof Error ? error.message : "Failed to copy terminal selection",
);
});
return false;
}

const navigationData = terminalNavigationShortcutData(event);
if (navigationData !== null) {
event.preventDefault();
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export async function copyTextToClipboard(text: string): Promise<void> {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText !== undefined) {
await navigator.clipboard.writeText(text);
return;
}

if (typeof document === "undefined") {
throw new Error("Clipboard API unavailable.");
}

const previousActiveElement = document.activeElement;
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "0";
textarea.style.opacity = "0";

document.body.append(textarea);
textarea.focus();
textarea.select();

try {
const copied = document.execCommand("copy");
if (!copied) {
throw new Error("Clipboard API unavailable.");
}
} finally {
textarea.remove();
if (previousActiveElement instanceof HTMLElement) {
previousActiveElement.focus();
}
}
}
65 changes: 65 additions & 0 deletions apps/web/src/terminalKeyboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";

import type { ShortcutEventLike } from "./keybindings";
import { isTerminalSelectionCopyShortcut } from "./terminalKeyboard";

function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
return {
key: "c",
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
...overrides,
};
}

describe("isTerminalSelectionCopyShortcut", () => {
it("requires a selection before treating Cmd+C as copy on macOS", () => {
expect(
isTerminalSelectionCopyShortcut(event({ metaKey: true }), {
hasSelection: true,
platform: "MacIntel",
}),
).toBe(true);
expect(
isTerminalSelectionCopyShortcut(event({ metaKey: true }), {
hasSelection: false,
platform: "MacIntel",
}),
).toBe(false);
});

it("treats Ctrl+C as copy on non-macOS when text is selected", () => {
expect(
isTerminalSelectionCopyShortcut(event({ ctrlKey: true }), {
hasSelection: true,
platform: "Linux",
}),
).toBe(true);
});

it("allows Ctrl+Shift+C for terminals on non-macOS", () => {
expect(
isTerminalSelectionCopyShortcut(event({ ctrlKey: true, shiftKey: true }), {
hasSelection: true,
platform: "Win32",
}),
).toBe(true);
});

it("rejects unrelated modifiers", () => {
expect(
isTerminalSelectionCopyShortcut(event({ ctrlKey: true, altKey: true }), {
hasSelection: true,
platform: "Linux",
}),
).toBe(false);
expect(
isTerminalSelectionCopyShortcut(event({ metaKey: true, shiftKey: true }), {
hasSelection: true,
platform: "MacIntel",
}),
).toBe(false);
});
});
31 changes: 31 additions & 0 deletions apps/web/src/terminalKeyboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { ShortcutEventLike } from "./keybindings";
import { isMacPlatform } from "./lib/utils";

function normalizeKey(key: string): string {
return key.toLowerCase();
}

export function isTerminalSelectionCopyShortcut(
event: ShortcutEventLike,
{
hasSelection,
platform = typeof navigator === "undefined" ? "" : navigator.platform,
}: {
hasSelection: boolean;
platform?: string;
},
): boolean {
if (!hasSelection || normalizeKey(event.key) !== "c") {
return false;
}

if (isMacPlatform(platform)) {
return event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey;
}

if (event.metaKey || event.altKey) {
return false;
}

return event.ctrlKey;
}
Loading