diff --git a/frontend/src/renderer/components/XtermTerminal.test.tsx b/frontend/src/renderer/components/XtermTerminal.test.tsx index 97e2ad5994..2476a1234d 100644 --- a/frontend/src/renderer/components/XtermTerminal.test.tsx +++ b/frontend/src/renderer/components/XtermTerminal.test.tsx @@ -12,7 +12,7 @@ const state = vi.hoisted(() => ({ modes: { bracketedPasteMode: boolean; mouseTrackingMode: string }; buffer: { active: { type: string } }; scrollLines: ReturnType; - clear: ReturnType; + write: ReturnType; focus: ReturnType; selectAll: ReturnType; dataListeners: Set<(data: string) => void>; @@ -39,7 +39,7 @@ vi.mock("@xterm/xterm", () => ({ modes = { bracketedPasteMode: false, mouseTrackingMode: "vt200" }; buffer = { active: { type: "normal" } }; scrollLines = vi.fn(); - clear = vi.fn(); + write = vi.fn(); focus = vi.fn(); selectAll = vi.fn(); dataListeners = new Set<(data: string) => void>(); @@ -62,7 +62,6 @@ vi.mock("@xterm/xterm", () => ({ open(host: HTMLElement) { host.appendChild(document.createElement("textarea")); } - write() {} writeln() {} dispose() {} onData(listener: (data: string) => void) { @@ -217,6 +216,29 @@ describe("XtermTerminal", () => { expect(trigger.style.top).toBe("88px"); }); + it("enables Copy on the context menu when the terminal has a selection", async () => { + const { container } = render(); + state.lastTerminal!.selection = "selected text"; + + fireEvent.contextMenu(container.firstElementChild!); + + expect(await screen.findByText("Copy")).not.toHaveAttribute("data-disabled"); + }); + + it("restores terminal focus when the context menu is dismissed with Escape", async () => { + const { container } = render(); + const host = container.firstElementChild!; + + fireEvent.contextMenu(host); + expect(await screen.findByText("Paste")).toBeInTheDocument(); + state.lastTerminal!.focus.mockClear(); + + fireEvent.keyDown(document, { key: "Escape" }); + + await waitFor(() => expect(screen.queryByText("Paste")).not.toBeInTheDocument()); + expect(state.lastTerminal!.focus).toHaveBeenCalled(); + }); + it("runs context menu copy, select all, and clear against the xterm instance", async () => { const { container } = render(); const host = container.firstElementChild!; @@ -233,7 +255,8 @@ describe("XtermTerminal", () => { fireEvent.contextMenu(host); fireEvent.click(await screen.findByText("Clear")); - expect(state.lastTerminal!.clear).toHaveBeenCalled(); + // Same local wipe as AttachableTerminal.clear — not term.clear(). + expect(state.lastTerminal!.write).toHaveBeenCalledWith("\x1b[3J\x1b[2J\x1b[H"); }); it("pastes from the context menu through the terminal paste path", async () => { diff --git a/frontend/src/renderer/components/XtermTerminal.tsx b/frontend/src/renderer/components/XtermTerminal.tsx index 7f4aabc22c..452f3cba74 100644 --- a/frontend/src/renderer/components/XtermTerminal.tsx +++ b/frontend/src/renderer/components/XtermTerminal.tsx @@ -237,10 +237,29 @@ export function XtermTerminal(props: XtermTerminalProps) { // changed between renders. const callbacksRef = useRef(props); - const setContextMenuOpen = useCallback((open: boolean) => { - setContextMenu((current) => ({ ...current, open })); + // Prefer termRef over a mount-effect closure so dismiss paths outside the + // effect (Escape, outside click, onOpenChange) can restore focus too. + const focusTerminal = useCallback(() => { + try { + termRef.current?.focus(); + } catch { + // Terminal is being torn down or its hidden textarea is unavailable. + } }, []); + const setContextMenuOpen = useCallback( + (open: boolean) => { + setContextMenu((current) => ({ ...current, open })); + if (!open) { + // Radix moves focus into the menu content. Always return it to the + // xterm helper textarea on dismiss — item select, Escape, or outside + // click — otherwise typing silently stops until the user clicks. + focusTerminal(); + } + }, + [focusTerminal], + ); + const runContextMenuAction = useCallback( (action: TerminalContextMenuAction) => { contextMenuActionsRef.current?.[action](); @@ -388,29 +407,21 @@ export function XtermTerminal(props: XtermTerminalProps) { console.warn("Unable to paste terminal clipboard text", error); }); }; - const focusTerminal = () => { - try { - term.focus(); - } catch { - // Terminal is being torn down or its hidden textarea is unavailable. - } - }; + // Actions only mutate selection/clipboard/buffer; focus is restored by + // setContextMenuOpen(false) so Escape/outside dismiss share the same path. + // Clear matches AttachableTerminal.clear (CLEAR_SEQUENCE), not term.clear(). contextMenuActionsRef.current = { clear: () => { - term.clear(); - focusTerminal(); + term.write(CLEAR_SEQUENCE); }, copy: () => { copySelection(); - focusTerminal(); }, paste: () => { pasteFromClipboard(); - focusTerminal(); }, selectAll: () => { term.selectAll(); - focusTerminal(); }, }; const openContextMenu = (event: MouseEvent) => {