diff --git a/app/e2e/terminal-keys.spec.ts b/app/e2e/terminal-keys.spec.ts new file mode 100644 index 00000000..174ac60f --- /dev/null +++ b/app/e2e/terminal-keys.spec.ts @@ -0,0 +1,50 @@ +import type { Page } from "@playwright/test"; + +import { expect, mockOnly, test } from "./fixtures"; + +// A terminal's keys send what they would in a Mac terminal (lib/terminal-keys): +// ⇧↵ as Shift and Enter (a newline in Claude Code), ⌥←/→ a word, ⌘←/→ the +// line's ends, ⌘⌫ the line back to its start. The mock records what each +// terminal sent (window.__berthSent). + +async function openShell(page: Page) { + await expect(page.getByRole("heading", { name: "What should your agents work on?" })).toBeVisible(); + await page.keyboard.press("Meta+KeyT"); + const picker = page.getByRole("dialog", { name: "New terminal on a box" }); + await expect(picker).toBeVisible(); + await page.keyboard.type("gpu"); + await page.keyboard.press("Enter"); + await expect(picker).toBeHidden(); + await expect(page.locator("[data-testid=pane][data-pane-kind=terminal]:visible")).toHaveCount(1); + await expect.poll(() => screenText(page)).toContain("me@gpu:~$"); +} + +function screenText(page: Page) { + return page.evaluate(() => { + const pane = [...document.querySelectorAll("[data-testid=pane]")].find((p) => p.offsetParent && p.querySelector("[data-terminal]")); + const host = pane?.querySelector("[data-terminal] > div") as (HTMLElement & { __berthTerm?: { buffer: { active: { length: number; getLine(y: number): { translateToString(trim: boolean): string } | undefined } } } }) | null; + const b = host?.__berthTerm?.buffer.active; + if (!b) return ""; + const lines: string[] = []; + for (let y = 0; y < b.length; y++) lines.push(b.getLine(y)?.translateToString(true) ?? ""); + return lines.join("\n"); + }); +} + +const sent = (page: Page) => page.evaluate(() => ((window as { __berthSent?: string[] }).__berthSent ?? []).join("")); + +for (const renderer of ["ghostty", "xterm"] as const) { + test(`${renderer}: Mac terminal keys send what a Mac terminal sends`, async ({ app }) => { + mockOnly("the mock records what a terminal sent"); + await app.open({ prefs: { terminal: { renderer } } }); + const page = app.page; + await openShell(page); + await page.evaluate(() => { + (window as { __berthSent?: string[] }).__berthSent = []; + }); + + for (const key of ["Shift+Enter", "Alt+ArrowLeft", "Alt+ArrowRight", "Meta+ArrowLeft", "Meta+ArrowRight", "Meta+Backspace", "Enter"]) await page.keyboard.press(key); + + await expect.poll(() => sent(page)).toBe("\x1b[13;2u\x1bb\x1bf\x01\x05\x15\r"); + }); +} diff --git a/app/src/components/workspace/terminal-view.tsx b/app/src/components/workspace/terminal-view.tsx index 038f0837..53dc47c9 100644 --- a/app/src/components/workspace/terminal-view.tsx +++ b/app/src/components/workspace/terminal-view.tsx @@ -9,7 +9,7 @@ import { useActiveTheme } from "@/hooks/use-theme"; import { ApiError, type TerminalConnection } from "@/lib/api"; import { attachable, localPaths, named, onThisComputer, pastedFiles, shrinkImage, uploadAttachment, uploadLocalFile } from "@/lib/attachments"; import { copyText } from "@/lib/clipboard"; -import { IS_LINUX, platformKeys } from "@/lib/platform"; +import { IS_LINUX, IS_MAC, platformKeys } from "@/lib/platform"; import { usePrefs } from "@/lib/prefs"; import { tryNow } from "@/lib/reconnect"; import { useStore } from "@/lib/store"; @@ -18,6 +18,7 @@ import { findPaths, resolveIn } from "@/lib/editor-paths"; import { OVERLAYS } from "@/lib/overlays"; import { EchoPredictor } from "@/lib/predict-overlay"; import { createTerminal, type TermHandle } from "@/lib/terminal"; +import { terminalKeyInput } from "@/lib/terminal-keys"; import { cn } from "@/lib/utils"; import { WheelBatcher, wheelPixels } from "@/lib/wheel"; @@ -117,9 +118,21 @@ export function TerminalView({ box, session, agent, command, wsKey, tab, pane, v let disposed = false; let t: TermHandle | undefined; let echo: EchoPredictor | undefined; + let input: ((d: string) => void) | undefined; const mount = document.createElement("div"); mount.className = "relative h-full w-full"; - host.current!.appendChild(mount); + const el = host.current!; + el.appendChild(mount); + // ⇧↵, and on a Mac ⌘←/→/⌫ and ⌥←/→, send what a Mac terminal sends + // (lib/terminal-keys), ahead of the renderer's own handling. + const onKey = (e: KeyboardEvent) => { + const d = input && terminalKeyInput(e, IS_MAC); + if (d === undefined) return; + e.preventDefault(); + e.stopPropagation(); + input?.(d); + }; + el.addEventListener("keydown", onKey, true); void createTerminal(mount, themeRef.current.terminal, prefs).then((made) => { if (disposed) { made.dispose(); @@ -128,7 +141,7 @@ export function TerminalView({ box, session, agent, command, wsKey, tab, pane, v t = made; echo = new EchoPredictor(made, mount, themeRef.current.terminal, prefsRef.current); predictor.current = echo; - t.onData((d) => { + input = (d) => { if (!open.current) { const h = held.current; if (!h.data.length) h.at = Date.now(); @@ -141,7 +154,8 @@ export function TerminalView({ box, session, agent, command, wsKey, tab, pane, v echo?.typed(d); conn.current?.send(d); if (!typedAt.current || typedAt.current <= heardAt.current) typedAt.current = Date.now(); - }); + }; + t.onData(input); t.onResize(({ cols, rows }) => conn.current?.resize(cols, rows)); t.onLinkHint((at) => { const r = root.current?.getBoundingClientRect(); @@ -170,6 +184,7 @@ export function TerminalView({ box, session, agent, command, wsKey, tab, pane, v }); return () => { disposed = true; + el.removeEventListener("keydown", onKey, true); echo?.dispose(); if (predictor.current === echo) predictor.current = null; t?.dispose(); diff --git a/app/src/lib/mock.ts b/app/src/lib/mock.ts index 795c2291..716c3958 100644 --- a/app/src/lib/mock.ts +++ b/app/src/lib/mock.ts @@ -970,6 +970,8 @@ function mockAttach(box: string, session: string, h: TerminalHandlers) { return { send(data: Uint8Array | string) { const text = typeof data === "string" ? data : new TextDecoder().decode(data); + // A test that sets window.__berthSent = [] gets what each terminal sent. + (window as { __berthSent?: string[] }).__berthSent?.push(text); const echo = text.replace(/\r/g, "\r\n\x1b[2m(mock: nothing runs here)\x1b[0m\r\n\x1b[1m❯\x1b[0m ").replace(/\x7f/g, "\b \b"); if (!ECHO_DELAY) h.onData(echo); else timers.push(window.setTimeout(() => open && h.onData(echo), ECHO_DELAY)); diff --git a/app/src/lib/terminal-keys.test.ts b/app/src/lib/terminal-keys.test.ts new file mode 100644 index 00000000..a31fd5c7 --- /dev/null +++ b/app/src/lib/terminal-keys.test.ts @@ -0,0 +1,43 @@ +// node --experimental-strip-types --test src/lib/terminal-keys.test.ts (pnpm test) +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { type TermKey, terminalKeyInput } from "./terminal-keys.ts"; + +const key = (k: string, mods: Partial> = {}): TermKey => ({ + key: k, + shiftKey: false, + altKey: false, + ctrlKey: false, + metaKey: false, + isComposing: false, + ...mods, +}); + +test("⇧↵ is Shift and Enter on every platform; Enter alone is the renderer's", () => { + assert.equal(terminalKeyInput(key("Enter", { shiftKey: true }), true), "\x1b[13;2u"); + assert.equal(terminalKeyInput(key("Enter", { shiftKey: true }), false), "\x1b[13;2u"); + assert.equal(terminalKeyInput(key("Enter"), true), undefined); + assert.equal(terminalKeyInput(key("Enter", { shiftKey: true, ctrlKey: true }), true), undefined); +}); + +test("on a Mac, ⌘ and ⌥ edit the line as in a Mac terminal", () => { + assert.equal(terminalKeyInput(key("ArrowLeft", { metaKey: true }), true), "\x01"); + assert.equal(terminalKeyInput(key("ArrowRight", { metaKey: true }), true), "\x05"); + assert.equal(terminalKeyInput(key("Backspace", { metaKey: true }), true), "\x15"); + assert.equal(terminalKeyInput(key("ArrowLeft", { altKey: true }), true), "\x1bb"); + assert.equal(terminalKeyInput(key("ArrowRight", { altKey: true }), true), "\x1bf"); +}); + +test("other keys, the app's ⌘⌥ arrows and Linux's keys stay as they were", () => { + assert.equal(terminalKeyInput(key("ArrowLeft", { metaKey: true, altKey: true }), true), undefined); + assert.equal(terminalKeyInput(key("ArrowLeft", { metaKey: true, shiftKey: true }), true), undefined); + assert.equal(terminalKeyInput(key("ArrowUp", { metaKey: true }), true), undefined); + assert.equal(terminalKeyInput(key("Backspace", { altKey: true }), true), undefined); + assert.equal(terminalKeyInput(key("ArrowLeft", { altKey: true }), false), undefined); + assert.equal(terminalKeyInput(key("ArrowLeft", { ctrlKey: true }), false), undefined); +}); + +test("a key being composed (an input method) is left alone", () => { + assert.equal(terminalKeyInput(key("Enter", { shiftKey: true, isComposing: true }), true), undefined); +}); diff --git a/app/src/lib/terminal-keys.ts b/app/src/lib/terminal-keys.ts new file mode 100644 index 00000000..0da9307a --- /dev/null +++ b/app/src/lib/terminal-keys.ts @@ -0,0 +1,33 @@ +// Keys a terminal sends differently from what its renderer would, so they +// do in a terminal here what they do in a Mac terminal (Ghostty's macOS +// defaults, iTerm2's natural text editing). +// +// - ⇧↵ is CSI 13;2u, Shift and Enter. Through berth's tmux (extended-keys, +// internal/box/sessions.go) a program that asked for modified keys gets +// Shift+Enter (a newline in Claude Code); a shell gets a plain Enter. +// Both renderers send a plain Enter for it themselves. Every platform. +// - ⌥← and ⌥→ move a word (ESC b, ESC f), where the renderers send +// CSI 1;3D and CSI 1;3C, which a shell's line editor doesn't take. +// - ⌘← and ⌘→ go to the line's start and end (Ctrl+A, Ctrl+E), and ⌘⌫ +// deletes back to its start (Ctrl+U). Mac only: on Linux, Ctrl stands in +// for ⌘ and its keys stay the shell's. + +export type TermKey = Pick; + +const SHIFT_ENTER = "\x1b[13;2u"; + +const MAC_CMD: Record = { ArrowLeft: "\x01", ArrowRight: "\x05", Backspace: "\x15" }; +const MAC_OPTION: Record = { ArrowLeft: "\x1bb", ArrowRight: "\x1bf" }; + +// terminalKeyInput is what a keydown sends instead of the renderer's own +// bytes, or undefined to leave it to the renderer. +export function terminalKeyInput(e: TermKey, mac: boolean): string | undefined { + if (e.isComposing) return undefined; + const only = (shift: boolean, alt: boolean, ctrl: boolean, meta: boolean) => + e.shiftKey === shift && e.altKey === alt && e.ctrlKey === ctrl && e.metaKey === meta; + if (e.key === "Enter" && only(true, false, false, false)) return SHIFT_ENTER; + if (!mac) return undefined; + if (only(false, false, false, true)) return MAC_CMD[e.key]; + if (only(false, true, false, false)) return MAC_OPTION[e.key]; + return undefined; +} diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 8d9f7323..f9d50dc8 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -9,8 +9,15 @@ upgrades with `berth upgrade BOX`, or by running the [install command](/getting-started/add-a-box#with-the-install-command) again. -## v0.3.22 + +## v0.3.22 +- **Mac terminal keys work in terminals.** ⇧↵ starts a new line in Claude + Code instead of sending, ⌥← and ⌥→ move a word, ⌘← and ⌘→ go to the + start and end of the line, and ⌘⌫ deletes back to its start. ⇧↵ needs + the box upgraded; a session's agent started before then picks it up + when it restarts. + [Terminals](/guides/terminals#keys) - **`/resume` works in a structured agent's chat.** It opens a list of the agent's past conversations in that folder, newest first, including the ones you had in Claude Code's own terminal. Pick one and it carries on in the diff --git a/docs/guides/terminals.mdx b/docs/guides/terminals.mdx index 42fd2f52..bd913d7c 100644 --- a/docs/guides/terminals.mdx +++ b/docs/guides/terminals.mdx @@ -24,6 +24,20 @@ update as you change it: | Copy on select | Selecting text copies it. | | Renderer | Ghostty or xterm.js. If Ghostty can't start, the app says why here. | +## Keys + +Keys do what they do in a Mac terminal (Ghostty's and iTerm2's defaults): + +| Keys | What they do | +|---|---| +| ⇧↵ | A new line in Claude Code instead of sending. A shell gets a plain Enter. Needs the box upgraded. | +| ⌥← ⌥→ | Move back or forward a word. | +| ⌘← ⌘→ | Go to the start or end of the line. | +| ⌘⌫ | Delete back to the start of the line. | + +In the Linux app, Ctrl stands in for ⌘ and its keys go to the shell, so +only ⇧↵ changes there. + ## Typing on a slow link A box reached through a relay can be 80 ms to 2 s away, and a terminal only diff --git a/internal/box/sessions.go b/internal/box/sessions.go index 6a5a1031..8c3eefeb 100644 --- a/internal/box/sessions.go +++ b/internal/box/sessions.go @@ -183,6 +183,12 @@ const tmuxSocket = "berth" // the link's text with no address. Hyperlinks came in tmux 3.4; the %if // leaves the line out of an older one (3.0 has no terminal-features option // at all), and a fixed index keeps it one entry when the file is read again. +// +// Shift+Enter is a newline in Claude Code. The app sends it as CSI 13;2u, +// and tmux passes a modified key on only with extended-keys on and only to +// a program that asked for them (xterm's modifyOtherKeys, as Claude Code +// does); a shell that didn't gets a plain Enter, as in a Mac terminal. +// extended-keys came in tmux 3.2. const tmuxConfig = `set -g remain-on-exit on set -g history-limit 50000 set -g mouse on @@ -192,6 +198,9 @@ set -g focus-events on %if "#{m/r:^(next-|[4-9]|3\.([4-9]|[1-9][0-9])),#{version}}" set -s terminal-features[90] "xterm*:hyperlinks" %endif +%if "#{m/r:^(next-|[4-9]|3\.([2-9]|[1-9][0-9])),#{version}}" +set -s extended-keys on +%endif ` func NewSessions(dir string) (*Sessions, error) { diff --git a/internal/box/sessions_test.go b/internal/box/sessions_test.go index 9d819396..1307872b 100644 --- a/internal/box/sessions_test.go +++ b/internal/box/sessions_test.go @@ -231,6 +231,51 @@ func TestAttachPassesOSC8Hyperlinks(t *testing.T) { } } +// Shift+Enter, which the app sends as CSI 13;2u, reaches a program that +// asked for modified keys (Claude Code, where it is a newline) as Shift and +// Enter, not as the plain Enter tmux made of it before extended-keys. +func TestAttachPassesShiftEnter(t *testing.T) { + out, err := exec.Command("tmux", "-V").Output() + if err != nil { + t.Skip("tmux not installed") + } + var major, minor int + if _, err := fmt.Sscanf(strings.TrimPrefix(strings.TrimSpace(string(out)), "tmux "), "%d.%d", &major, &minor); err == nil && (major < 3 || major == 3 && minor < 2) { + t.Skipf("%s has no extended keys", strings.TrimSpace(string(out))) + } + s := testSessions(t) + ctx := context.Background() + // Asks for modifyOtherKeys, then shows the first 7 bytes it reads in hex. + dump := `printf '\033[>4;2mready\n'; stty raw -echo; head -c 7 | od -An -tx1; sleep 30` + if _, err := s.Create(ctx, "keys", "", t.TempDir(), dump, nil); err != nil { + t.Fatal(err) + } + master, cmd, err := s.Attach(ctx, "keys", 100, 30) + if err != nil { + t.Fatal(err) + } + defer cmd.Process.Kill() + defer master.Close() + var screen syncBuffer + go io.Copy(&screen, master) + deadline := time.Now().Add(5 * time.Second) + for !strings.Contains(screen.String(), "ready") { + if time.Now().After(deadline) { + t.Fatalf("the program never started: %q", screen.String()) + } + time.Sleep(50 * time.Millisecond) + } + master.Write([]byte("\x1b[13;2u.......")) + // tmux writes it in xterm's form (CSI 27;2;13~) or as CSI 13;2u. + xterm, csiu := "1b 5b 32 37 3b 32 3b", "1b 5b 31 33 3b 32 75" + for !strings.Contains(screen.String(), xterm) && !strings.Contains(screen.String(), csiu) { + if time.Now().After(deadline.Add(5 * time.Second)) { + t.Fatalf("Shift+Enter didn't reach the program as Shift+Enter: %q", screen.String()) + } + time.Sleep(50 * time.Millisecond) + } +} + // syncBuffer is a bytes.Buffer safe to poll while another goroutine copies // terminal output into it. type syncBuffer struct {