From 963f4d05ab2bd504141179eb9dd99b72d04257a1 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 23 Jul 2025 15:39:26 -0800 Subject: [PATCH 1/3] feat: support shift+enter in terminal It acts the same alt+enter, but is more familiar to users. --- .../pages/TerminalPage/TerminalPage.test.tsx | 18 ++++++++++++++++++ site/src/pages/TerminalPage/TerminalPage.tsx | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/site/src/pages/TerminalPage/TerminalPage.test.tsx b/site/src/pages/TerminalPage/TerminalPage.test.tsx index 4591190ad9904..7530a45914a85 100644 --- a/site/src/pages/TerminalPage/TerminalPage.test.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.test.tsx @@ -1,5 +1,6 @@ import "jest-canvas-mock"; import { waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { API } from "api/api"; import WS from "jest-websocket-mock"; import { http, HttpResponse } from "msw"; @@ -148,4 +149,21 @@ describe("TerminalPage", () => { ws.send(text); await expectTerminalText(container, text); }); + + it("supports shift+enter", async () => { + const ws = new WS( + `ws://localhost/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty`, + ); + + const { container } = await renderTerminal(); + // Ideally we could use ws.connected but that seems to pause React updates. + // For now, wait for the initial resize message instead. + await ws.nextMessage; + + const msg = ws.nextMessage; + const terminal = container.getElementsByClassName("xterm"); + await userEvent.type(terminal[0], "{Shift>}{Enter}{/Shift}"); + const req = JSON.parse(new TextDecoder().decode((await msg) as Uint8Array)); + expect(req.data).toBe("\x1b\r"); + }); }); diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index 5c13e89c30005..6b9bfb4a695d0 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -148,6 +148,22 @@ const TerminalPage: FC = () => { }), ); + // Make shift+enter send ^[^M (escaped carriage return). Applications + // typically take this to mean to insert a literal newline. There is no way + // to remove this handler, so we must attach it once and rely on a ref to + // send it to the current socket. + terminal.attachCustomKeyEventHandler((ev) => { + if (ev.shiftKey && ev.key === "Enter") { + if (ev.type === "keydown") { + websocketRef.current?.send( + new TextEncoder().encode(JSON.stringify({ data: "\x1b\r" })), + ); + } + return false; + } + return true; + }); + terminal.open(terminalWrapperRef.current); // We have to fit twice here. It's unknown why, but the first fit will @@ -190,6 +206,7 @@ const TerminalPage: FC = () => { }, [navigate, reconnectionToken, searchParams]); // Hook up the terminal through a web socket. + const websocketRef = useRef(); useEffect(() => { if (!terminal) { return; @@ -270,6 +287,7 @@ const TerminalPage: FC = () => { .withBackoff(new ExponentialBackoff(1000, 6)) .build(); websocket.binaryType = "arraybuffer"; + websocketRef.current = websocket; websocket.addEventListener(WebsocketEvent.open, () => { // Now that we are connected, allow user input. terminal.options = { @@ -333,6 +351,7 @@ const TerminalPage: FC = () => { d.dispose(); } websocket?.close(1000); + websocketRef.current = undefined; }; }, [ command, From 77d94ae103802d024dc4f6992888709ec93c3d95 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 24 Jul 2025 11:41:05 -0800 Subject: [PATCH 2/3] Name the escaped carriage return --- site/src/pages/TerminalPage/TerminalPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index 6b9bfb4a695d0..14fe872be1e05 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -152,11 +152,12 @@ const TerminalPage: FC = () => { // typically take this to mean to insert a literal newline. There is no way // to remove this handler, so we must attach it once and rely on a ref to // send it to the current socket. + const escapedCarriageReturn = "\x1b\r"; terminal.attachCustomKeyEventHandler((ev) => { if (ev.shiftKey && ev.key === "Enter") { if (ev.type === "keydown") { websocketRef.current?.send( - new TextEncoder().encode(JSON.stringify({ data: "\x1b\r" })), + new TextEncoder().encode(JSON.stringify({ data: escapedCarriageReturn })), ); } return false; From 33ac7dc91f61181bc2e528c739ed5ed5ce9323ca Mon Sep 17 00:00:00 2001 From: Asher Date: Tue, 29 Jul 2025 09:17:43 -0800 Subject: [PATCH 3/3] fmt more like fml --- site/src/pages/TerminalPage/TerminalPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index 14fe872be1e05..bde7517ef5d19 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -157,7 +157,9 @@ const TerminalPage: FC = () => { if (ev.shiftKey && ev.key === "Enter") { if (ev.type === "keydown") { websocketRef.current?.send( - new TextEncoder().encode(JSON.stringify({ data: escapedCarriageReturn })), + new TextEncoder().encode( + JSON.stringify({ data: escapedCarriageReturn }), + ), ); } return false;