Compare commits

...

10 Commits

Author SHA1 Message Date
alex 7603b739cb fix(terminal): prioritize selected copy menu
Handle selected-output context menus during capture so xterm and the browser cannot open a competing menu.
2026-07-22 14:25:20 +02:00
alex ff8efdd887 merge: add terminal right click copy action 2026-07-22 14:15:47 +02:00
alex b289edf5a9 fix(terminal): add right click copy action
Replace the conflicting keyboard copy shortcut with an xterm-aware Copy action shown for selected output.
2026-07-22 14:15:46 +02:00
alex 75b67f2a6f merge: use ctrl shift c for terminal output copy 2026-07-22 13:01:32 +02:00
alex bcc7486b59 fix(terminal): use ctrl shift c for output copy
Capture Ctrl+Shift+C before xterm input handling and copy selected output through a browser-compatible fallback. Preserve Ctrl+C as terminal input.
2026-07-22 13:01:31 +02:00
alex 29e765b2be merge: support terminal browser clipboard shortcuts 2026-07-22 12:52:20 +02:00
alex d14cdc1151 fix(terminal): support browser clipboard shortcuts
Copy selected terminal output with Ctrl/Cmd+C without sending an interrupt, and route browser paste consistently through the terminal transport.
2026-07-22 12:52:19 +02:00
alex c80dbf9737 merge: preserve mounted profile file updates 2026-07-22 11:52:43 +02:00
alex 5331a0f110 fix(config-profiles): preserve mounted file inodes
Update files within profile directory mounts in place so editor saves reach running containers.
2026-07-22 11:52:42 +02:00
alex b995521e22 merge: preserve profile file bind mount updates 2026-07-22 11:37:54 +02:00
6 changed files with 131 additions and 2 deletions
@@ -569,7 +569,7 @@ def apply_resolved_profile(
expanded_target = os.path.normpath(expand_container_path(mount.target, home_dir))
mount_dir = mounts_dir / expanded_target.lstrip("/").replace("/", "_")
for file_path, content in mount.files.items():
write_canonical_file(mount_dir, file_path, content)
write_canonical_file(mount_dir, file_path, content, preserve_inode=True)
volume_mounts.append(
{
@@ -584,6 +584,37 @@ class TestApplyResolvedProfile:
assert canonical_file.stat().st_ino == original_inode
assert canonical_file.read_text() == "value = 2"
def test_mounted_file_update_preserves_bind_mount_inode(self, tmp_path) -> None:
"""A file inside a profile directory mount must update in place."""
profile_id = uuid.uuid4()
instance_root = tmp_path / "instances"
resolved = ResolvedProfile(
profile_id=profile_id,
profile_name="test",
mounts={
"/etc/tool": ResolvedMount(
target="/etc/tool", mode="rw", files={"settings.toml": "value = 1"}
)
},
)
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
canonical_file = (
instance_root
/ "config-profiles"
/ str(profile_id)
/ "mounts"
/ "etc_tool"
/ "settings.toml"
)
original_inode = canonical_file.stat().st_ino
resolved.mounts["/etc/tool"].files["settings.toml"] = "value = 2"
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
assert canonical_file.stat().st_ino == original_inode
assert canonical_file.read_text() == "value = 2"
def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None:
"""Different instance paths resolve a profile to one canonical source."""
profile_id = uuid.uuid4()
@@ -4,6 +4,7 @@ import {
getTerminalScrollbackLimit,
isCurrentWebSocket,
shouldRetryWebSocketClose,
shouldShowTerminalCopyMenu,
} from "./terminal.tsx";
describe("getTerminalScrollbackLimit", () => {
@@ -30,3 +31,13 @@ describe("getTerminalScrollbackLimit", () => {
);
});
});
describe("shouldShowTerminalCopyMenu", () => {
it("shows Copy for selected terminal output", () => {
expect(shouldShowTerminalCopyMenu("selected output")).toBe(true);
});
it("keeps the native context menu when no output is selected", () => {
expect(shouldShowTerminalCopyMenu("")).toBe(false);
});
});
@@ -68,6 +68,22 @@ export function shouldRetryWebSocketClose(code: number, reason: string): boolean
return code !== 1000 && !(code === 4000 && reason === "New connection established");
}
export function shouldShowTerminalCopyMenu(selection: string): boolean {
return selection.length > 0;
}
function copyTextWithFallback(text: string): void {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
}
function matchesByteSequence(
data: Uint8Array,
start: number,
@@ -107,6 +123,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
>("connecting");
const [error, setError] = useState<string | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [copyMenu, setCopyMenu] = useState<{
x: number;
y: number;
text: string;
} | null>(null);
const activeModifierRef = useRef(activeModifier);
activeModifierRef.current = activeModifier;
const [fontSize, setFontSize] = useState(() => {
@@ -462,8 +483,25 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
term.paste(text);
};
const handleBrowserCopy = (event: ClipboardEvent) => {
const selection = term.getSelection();
if (!selection) return;
event.preventDefault();
event.clipboardData?.setData("text/plain", selection);
};
const handleTerminalContextMenu = (event: MouseEvent) => {
const selection = term.getSelection();
if (!shouldShowTerminalCopyMenu(selection)) {
setCopyMenu(null);
return;
}
event.preventDefault();
setCopyMenu({ x: event.clientX, y: event.clientY, text: selection });
};
const handleBrowserPaste = (event: ClipboardEvent) => {
if (!bracketedPasteEnabledRef.current) return;
const text = event.clipboardData?.getData("text/plain");
if (text === undefined || wsRef.current?.readyState !== WebSocket.OPEN) {
return;
@@ -473,6 +511,8 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
event.stopImmediatePropagation();
pasteTextRef.current(text);
};
container.addEventListener("copy", handleBrowserCopy, true);
container.addEventListener("contextmenu", handleTerminalContextMenu, true);
container.addEventListener("paste", handleBrowserPaste, true);
// Mobile touch scroll.
@@ -720,6 +760,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
handleVisibilityChange,
);
if (touchCleanup) touchCleanup();
container.removeEventListener("copy", handleBrowserCopy, true);
container.removeEventListener(
"contextmenu",
handleTerminalContextMenu,
true,
);
container.removeEventListener("paste", handleBrowserPaste, true);
pasteTextRef.current = () => {};
bracketedPasteEnabledRef.current = false;
@@ -854,6 +900,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Focus terminal on mobile to keep keyboard open
const handleTerminalClick = () => {
setCopyMenu(null);
if (isMobile && termRef.current) {
termRef.current.focus();
}
@@ -968,6 +1015,26 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
</div>
</div>
)}
{copyMenu && (
<button
aria-label="Copy selected terminal text"
className="terminal-context-copy"
onMouseDown={(event) => {
event.preventDefault();
copyTextWithFallback(copyMenu.text);
setCopyMenu(null);
}}
style={{
left: copyMenu.x,
position: "fixed",
top: copyMenu.y,
zIndex: 1000,
}}
type="button"
>
Copy
</button>
)}
{error && (
<div className="terminal-error">
{error}
@@ -0,0 +1,14 @@
# Fix Web Terminal Clipboard
## Problem
Users cannot reliably copy terminal output from the browser terminal. Browser copy shortcuts may be forwarded to the terminal as input instead of copying the xterm selection, and paste behavior differs by bracketed-paste mode.
## Required behavior
- Right-clicking a selected terminal region presents an xterm-aware Copy action that writes the selection to the system clipboard.
- Browser native context menus remain available when no terminal output is selected.
- Terminal keyboard input, including `Ctrl+C`, remains unchanged.
- Copy requests expose the xterm selection as plain text.
- Pasting plain text is handled once and follows bracketed-paste mode when enabled.
- Normal terminal interrupts still work when there is no active selection.
@@ -0,0 +1,6 @@
# Web Terminal Clipboard Tasks
- [x] Add selected-text copy interception without suppressing unselected terminal interrupts.
- [x] Normalize browser paste handling through the existing paste transport.
- [x] Add focused frontend coverage for copy shortcut decisions.
- [x] Run frontend tests, typecheck/build, lint, and diagnostics.