Skip to content

Shift+Enter adds a line, and a session says what it is before it starts - #61

Merged
karngyan merged 3 commits into
mainfrom
fix/shift-enter-newline
Aug 12, 2026
Merged

Shift+Enter adds a line, and a session says what it is before it starts#61
karngyan merged 3 commits into
mainfrom
fix/shift-enter-newline

Conversation

@karngyan

Copy link
Copy Markdown
Contributor

Three fixes, all in web/.

Shift+Enter sends a newline instead of the message

xterm.js encodes Enter as a bare carriage return whatever Shift is doing (case 13 of its key encoder) — faithful to the hardware, and useless in front of an agent CLI. Claude Code and Codex both read a bare CR as "send this", so in a browser terminal there was no way to put a second line in a prompt.

Shift+Enter now sends ESC CR. That is not a sequence flue invented: a line editor reads it as Alt+Enter, claude /terminal-setup writes exactly those two bytes into VS Code's keybindings.json for shift+enter, and iTerm2, Ghostty, WezTerm, Kitty, Warp and Windows Terminal already send it — which is what "Shift+Enter is natively supported" means in those tools.

It lives in the emulator rather than the view because the view cannot put bytes on the wire without reaching past the seam. term.input routes them through onData, so the key bar's latched Ctrl and the replay mute gate still apply. Ctrl+Shift+Enter is left alone — the view takes that one on the way down for focus mode.

Verified against a real pty, not only in jsdom: cat -v in a live session records ^[ and then the CR.

A session row opens in a tab of its own

The sessions list is what people come back to between sessions, and opening a terminal over it made the way back the browser's back button — which drops the terminal and its scrollback with it. The rows were already real anchors, so this is a target; the router hands the click straight to the browser once one is set.

The + in a terminal no longer goes via the dashboard

It pointed at /?cwd=, which is the sessions dashboard — so a session started from a terminal came up behind the whole list, page and shell and rows, for as long as the daemon took to answer.

There is a page for it now. /new takes the request in its address, spawns, applies the name and tags the moment there is an id to apply them to, and replaces itself with the terminal — one pill on the terminal's own ground, no chrome in between.

A page rather than a click handler because starting a session takes a round trip: spawn carries no id, the daemon invents one. A screen that spawned and then called window.open from the reply would be opening a popup from a continuation, which Safari refuses outright and Chrome refuses once the gesture has aged out. A link opens on the click itself. A blocked popup falls back to the current tab rather than to nothing at all.

And every way of asking goes through one dialog

spawn carries no metadata, so a name and a tag could only ever be applied after the session existed — which meant starting it, watching a terminal come up, going back to the list and renaming the row. Nobody does that, so sessions stayed called after whatever shell they ran.

The toolbar button, its machine menu, every group heading's +, and the terminal's own + now open one form: name, directory, machine, tags — prefilled from whatever the press implied, and all of it optional. Opening it and pressing Start is the old one-click behaviour exactly.

flue open's ?cwd= handover is untouched: it still spawns from the sessions screen, which is the one caller adopt has left.

Checks

  • pnpm vitest run — 67 files, 1319 tests, green
  • pnpm run lint — clean
  • Driven end to end in a real browser against a dev daemon: Shift+Enter's bytes at the pty, the dialog on both screens, the address it builds, and the session that comes out of it with its name and tag attached.

🤖 Generated with Claude Code

karngyan and others added 2 commits August 12, 2026 15:52
xterm.js encodes Enter as a bare carriage return whatever Shift is
doing — faithful to the hardware, and useless in front of an agent CLI.
Claude Code and Codex both read a bare CR as "send this", so in a
browser terminal there was no way to put a second line in a prompt.

Send ESC CR for Shift+Enter. That is not a sequence flue invented: a
line editor reads it as Alt+Enter, `claude /terminal-setup` writes
exactly those two bytes into VS Code's keybindings.json for
`shift+enter`, and it is what iTerm2, Ghostty, WezTerm, Kitty, Warp
and Windows Terminal already send.

It goes in the emulator rather than the view because the view cannot
put bytes on the wire without reaching past the seam. `term.input`
routes them through onData, so the key bar's latched Ctrl and the
replay mute gate still apply. Ctrl+Shift+Enter is left alone — the
view takes that one on the way down for focus mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…its own tab

Three things that were one thing.

A session row now opens in a tab of its own. This list is what people
come back to between sessions, and opening a terminal over it made the
way back the browser's back button — which drops the terminal and its
scrollback with it.

The `+` inside a terminal pointed at `/?cwd=`, which is the sessions
dashboard, so a session started from a terminal came up behind the whole
list — page, shell, rows and all — for as long as the daemon took to
answer. There is a page for it now: /new spawns, applies the metadata,
and replaces itself with the terminal, on the terminal's own ground.

And every way of starting a session goes through one dialog first: name,
directory, machine, tags, prefilled from whatever the press implied.
`spawn` carries no metadata, so a name and a tag could only ever be
applied after the session existed — which meant going back to the list to
do it, which nobody does. Asked here, they ride the address to the page
that starts the session and land on the first frame that has an id.

The page is a page and not a click handler because starting a session
takes a round trip: a screen that spawned and then called window.open
from the reply would be opening a popup from a continuation, which Safari
refuses outright and Chrome refuses once the gesture has aged out. A link
opens on the click itself. A blocked popup falls back to this tab.

`flue open`'s `?cwd=` handover is untouched — it still spawns from the
sessions screen, which is the one caller `adopt` has left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… bet on the runner

Both cap tests in test/directory.test.ts timed out at vitest's five
seconds on CI while passing on a laptop, and the difference was never
anything but machine speed: the only way to test a cap is to reach it,
and reaching these means 512 sequential PUTs through a Durable Object, or
256 WebSockets opened one at a time and all held open.

So the caps join the deadlines that are already test seams, read the same
way `handshakeTimeout` and `pairTimeout` read theirs: the binding when
there is one, the constant when there is not. Production binds neither
and gets 512 and 256, and the arithmetic that picks those numbers stays
where it was written down.

vitest binds 64 and 8. The entry bound has headroom on purpose — the
shared directory that nearly every other test in the file writes into
holds ten entries by the end of the run, and a bound it could reach would
start refusing PUTs in tests that are not about the cap at all, which
reads as "the push never came" nowhere near the number that caused it.

The file drops from around 29 seconds to under 7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@karngyan
karngyan merged commit e8ef462 into main Aug 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant