Skip to content
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
50 changes: 50 additions & 0 deletions app/e2e/terminal-keys.spec.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>("[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");
});
}
23 changes: 19 additions & 4 deletions app/src/components/workspace/terminal-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
43 changes: 43 additions & 0 deletions app/src/lib/terminal-keys.test.ts
Original file line number Diff line number Diff line change
@@ -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<Omit<TermKey, "key">> = {}): 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);
});
33 changes: 33 additions & 0 deletions app/src/lib/terminal-keys.ts
Original file line number Diff line number Diff line change
@@ -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<KeyboardEvent, "key" | "shiftKey" | "altKey" | "ctrlKey" | "metaKey" | "isComposing">;

const SHIFT_ENTER = "\x1b[13;2u";

const MAC_CMD: Record<string, string> = { ArrowLeft: "\x01", ArrowRight: "\x05", Backspace: "\x15" };
const MAC_OPTION: Record<string, string> = { 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;
}
9 changes: 8 additions & 1 deletion docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/guides/terminals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions internal/box/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
45 changes: 45 additions & 0 deletions internal/box/sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading