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
34 changes: 34 additions & 0 deletions docs/adrs/070.webtty.replay-input-modes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# ADR 070: webtty — Restore the input modes ahead of the scrollback replay

**SPEC:** [webtty](../specs/webtty.md)
**Status:** Accepted
**Date:** 2026-10-15

---

## Context

A client that joins a running session (a reloaded or second tab, or one that fell behind and reconnected, [ADR 048](048.webtty.output-flow-control.md)) starts a fresh terminal and is sent the scrollback: the last `config.scrollback` characters of output (256 Ki by default, [ADR 007](007.webtty.session-client.md)). Its terminal state is whatever that replay sets.

A full-screen app sets its input modes once, at start. herdr writes `ESC[?2004h` (bracketed paste) when its client attaches and then redraws its whole screen, about 35 KB at a time ([ADR 069](069.client.scroll-ticks-wait-for-the-app.md)), so the sequence leaves the ring after a few redraws. The new tab's terminal then has bracketed paste off while herdr and the agent inside it (pi) still expect it on. A paste goes out raw, each newline as `\r`, and pi submits the first line and queues every other line as a separate message.

Reproduced with a shell that sets mode 2004, writes 400 KB and then records its input: after a reload, 1.x's replay starts with `xxxx…` and a paste of three lines arrives as `line one^Mline two^Mline three`. Mouse reporting (1000–1006), focus reporting (1004) and application cursor keys (1) are lost the same way.

## Decision

- **The session follows the input modes through its output.** `sessions/input-modes.ts` (`InputModes`) scans each PTY chunk for DECSET/DECRST (`ESC[?…h`, `ESC[?…l`) and records the modes in `INPUT_MODES`, the DEC private modes that change what the client sends: 1, 9, 1000, 1002, 1003, 1004, 1005, 1006, 1007, 1015, 1016, 2004, 2031 and 2048. RIS (`ESC c`) clears them all; DECSTR (`ESC[!p`) clears mode 1. A sequence split across chunks is carried (at most 64 characters) to the next one. A chunk with no ESC returns at once.
- **A joining client is sent one DECSET for the modes that are on, then the scrollback**, in the same message. The replay that follows can only set or reset modes in the order the app did, so the client ends in the app's current state either way. With no mode on, the replay is unchanged.
- **Display modes are left to the replay.** Cursor visibility, the alternate screen and the like change what the tab shows, not what it sends, and an app restores them with its next redraw.

## Considered Options

- **Send the restore after the scrollback.** Rejected: the ring is cut at a chunk boundary and can end inside a sequence, which the appended DECSET would then corrupt.
- **Serialize the full terminal state on the server** (a server-side VT emulator). Rejected for this fix: it is the complete answer for a reconnect but a large change to the server's hot path; this ADR covers the modes whose loss breaks input.
- **A larger scrollback.** Rejected: it only postpones the loss, and a redrawing app fills any size.
- **Ask the app to resend its modes** (detach and reattach herdr after each reload). The workaround, not a fix.

## Consequences

- A reloaded or reconnected tab pastes into herdr and pi as a bracketed paste, and keeps mouse and focus reporting. Checked end to end with headless Chrome against the reproduction above: `ESC[200~line one\nline two\nline three ESC[201~`.
- The per-chunk path gains a `String.includes` and, for a chunk with ESC, one regex scan.
- The kitty keyboard protocol's flags (`CSI > … u`) are a per-screen stack and are not followed; an app that pushes them is in the same state as before this change after a reconnect.
1 change: 1 addition & 0 deletions docs/specs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ src/
session.ts object: Session (PTY lifecycle, scrollback ring, close, broadcast)
session-id.ts util: ID_RE, isValidId, generateId
session-banner.ts util (pure): sessionBanner(runner)
input-modes.ts object: InputModes (input modes the output set, restored before the replay)
ws/
close-codes.ts constants: WS_CLOSE
ws-upgrade.ts wiring: noServer upgrade router
Expand Down
3 changes: 2 additions & 1 deletion docs/specs/webtty.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# SPEC: webtty

**Last Updated:** 2026-10-14
**Last Updated:** 2026-10-15

---

Expand Down Expand Up @@ -102,3 +102,4 @@ The server, PTY, CLI and config code is organised as the [architecture SPEC](arc
| Client integration (CLI → Web) | `POST /s/:id/publish` (one-shot or streaming JSON) + `ws /ws/:id/events` subscribe; channel active only while PTY is running; no extra process or port | [ADR 025](../adrs/025.server.channel.md) | ✅ |
| Session base directory | `POST /api/sessions` accepts `baseDir` (absolute path); PTY shell spawns there instead of `homedir()`; CLI `webtty [go] [id] --dir <path>` resolves and forwards it; ignored when attaching to an existing session | [ADR 028](../adrs/028.session.base-dir.md) | ✅ |
| Output flow control | Output paced per client by acknowledgements: in order at any client speed, bounded server buffers, PTY paused while every client is behind (node-pty), a client that falls 16 Mi characters behind is closed with `4003` and reconnects to the replay; avoids Bun's `ws` shim reordering and duplicating messages past 16 MB | [ADR 048](../adrs/048.webtty.output-flow-control.md) | ✅ |
| Input modes on replay | A joining client is sent the input modes the output last set (bracketed paste, mouse and focus reporting, application cursor keys) ahead of the scrollback, so a reloaded tab still pastes into a full-screen app as one bracketed paste | [ADR 070](../adrs/070.webtty.replay-input-modes.md) | ✅ |
71 changes: 71 additions & 0 deletions src/server/sessions/input-modes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Tests for InputModes: which DEC private modes it follows through the output, across chunk
// boundaries and resets, and the one DECSET it restores them with (ADR 070).
import { describe, expect, test } from 'bun:test';
import { INPUT_MODES, InputModes } from './input-modes';

/** An InputModes that has read `chunks` in order. */
function after(...chunks: string[]): InputModes {
const modes = new InputModes();
for (const chunk of chunks) modes.observe(chunk);
return modes;
}

describe('InputModes', () => {
test('restores nothing before any mode is set', () => {
expect(after().restore()).toBe('');
expect(after('plain text\r\n').restore()).toBe('');
});

test('restores bracketed paste once the output sets it', () => {
expect(after('\x1b[?2004h').restore()).toBe('\x1b[?2004h');
});

test('a later reset wins over an earlier set', () => {
expect(after('\x1b[?2004h', 'text', '\x1b[?2004l').restore()).toBe('');
expect(after('\x1b[?2004l\x1b[?2004h').restore()).toBe('\x1b[?2004h');
});

test('reads every mode in a multi-parameter sequence, and restores them in table order', () => {
expect(after('\x1b[?2004;1006;1000;1004h').restore()).toBe('\x1b[?1000;1004;1006;2004h');
expect(after('\x1b[?1000;1006h', '\x1b[?1000l').restore()).toBe('\x1b[?1006h');
});

test('ignores display modes such as the alternate screen and cursor visibility', () => {
expect(after('\x1b[?1049h\x1b[?25l\x1b[?7h').restore()).toBe('');
});

test('ignores sequences that are not DEC private modes', () => {
expect(after('\x1b[2004h\x1b[4h\x1b[1;2H\x1b[31m').restore()).toBe('');
});

test('follows every tracked mode', () => {
const all = INPUT_MODES.join(';');
expect(after(`\x1b[?${all}h`).restore()).toBe(`\x1b[?${all}h`);
});

test('a sequence split across chunks is still read', () => {
expect(after('abc\x1b', '[?2004h').restore()).toBe('\x1b[?2004h');
expect(after('\x1b[', '?20', '04h').restore()).toBe('\x1b[?2004h');
expect(after('\x1b[?2004h', 'x\x1b[?20', '04l').restore()).toBe('');
});

test('a lone ESC that is not a partial sequence is not carried', () => {
expect(after('\x1b[31', '?2004h').restore()).toBe('');
expect(after('\x1bO', '[?2004h').restore()).toBe('');
});

test('RIS clears every mode', () => {
expect(after('\x1b[?2004;1000h', '\x1bc').restore()).toBe('');
expect(after('\x1b[?2004h\x1bc\x1b[?1004h').restore()).toBe('\x1b[?1004h');
});

test('DECSTR resets application cursor keys only', () => {
expect(after('\x1b[?1;2004h', '\x1b[!p').restore()).toBe('\x1b[?2004h');
});

test('a chunk with no ESC leaves the modes as they were', () => {
const modes = after('\x1b[?2004h');
modes.observe('x'.repeat(1000));
expect(modes.restore()).toBe('\x1b[?2004h');
});
});
77 changes: 77 additions & 0 deletions src/server/sessions/input-modes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// The terminal modes that change what the client sends, as the session's output last set them,
// so a client that joins later can be put in them before the scrollback replay (ADR 070).
// The replay is only the last `config.scrollback` characters: a full-screen app sets these
// once at start, its redraws push that out of the ring, and a new tab would then send a paste
// without bracketed-paste markers (each newline an Enter) and mouse or focus events not at all.

/**
* DEC private modes that change the client's input encoding, in the order they are restored.
* Display modes (cursor visibility, the alternate screen) are left to the replay itself.
*/
export const INPUT_MODES: readonly number[] = [
1, // DECCKM: application cursor keys
9, // X10 mouse reporting
1000, // mouse button reporting
1002, // mouse button-event (drag) tracking
1003, // mouse any-event tracking
1004, // focus in/out reporting
1005, // UTF-8 mouse encoding
1006, // SGR mouse encoding
1007, // alternate scroll (wheel as arrow keys on the alternate screen)
1015, // urxvt mouse encoding
1016, // SGR-pixels mouse encoding
2004, // bracketed paste
2031, // color scheme change reports
2048, // in-band resize reports
];

const TRACKED = new Set(INPUT_MODES);

/** A sequence this tracker acts on: DECSET/DECRST, RIS, or DECSTR. */
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC (\x1b) starts every matched sequence
const SEQUENCE = /\x1b\[\?([0-9;]*)([hl])|\x1bc|\x1b\[!p/g;

/** The unfinished start of a sequence above, held until the next chunk completes it. */
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC (\x1b) starts every matched sequence
const PARTIAL = /^\x1b(?:\[(?:\?[0-9;]*|!)?)?$/;

/** Longest partial sequence carried to the next chunk; anything longer is not one of ours. */
const MAX_PARTIAL = 64;

/** Follows the input modes through a stream of output chunks; one per session. */
export class InputModes {
private readonly set = new Set<number>();
private partial = '';

/** Reads one chunk of output, in order. */
observe(data: string): void {
if (!this.partial && !data.includes('\x1b')) return;
const text = this.partial + data;
this.partial = '';
for (const match of text.matchAll(SEQUENCE)) {
const [whole, params, final] = match;
if (whole === '\x1bc') this.set.clear();
else if (whole === '\x1b[!p') this.set.delete(1);
else this.apply(params ?? '', final === 'h');
}
const last = text.lastIndexOf('\x1b');
if (last !== -1 && text.length - last <= MAX_PARTIAL && PARTIAL.test(text.slice(last))) {
this.partial = text.slice(last);
}
}

/** The DECSET that puts a fresh terminal in the modes now set, or `''` if none is. */
restore(): string {
const on = INPUT_MODES.filter((mode) => this.set.has(mode));
return on.length ? `\x1b[?${on.join(';')}h` : '';
}

private apply(params: string, enable: boolean): void {
for (const param of params.split(';')) {
const mode = Number(param);
if (!TRACKED.has(mode)) continue;
if (enable) this.set.add(mode);
else this.set.delete(mode);
}
}
}
15 changes: 15 additions & 0 deletions src/server/sessions/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,21 @@ describe('Session.ensurePty', () => {
expect(second.sent).toEqual([`${sessionBanner('bunx')}hello`]);
});

test('a later client is put back in the input modes the trimmed scrollback no longer sets (ADR 070)', () => {
const { deps, host, session, pty } = fixture(8);
const first = new FakeSocket();
session.attach(first, false);
session.ensurePty(first, 80, 24, deps, host);
pty.emitData('\x1b[?2004h\x1b[?1000;1006h');
pty.emitData('0123456789');
expect(session.scrollback).toBe('23456789');

const second = new FakeSocket();
session.attach(second, false);
session.ensurePty(second, 80, 24, deps, host);
expect(second.sent).toEqual(['\x1b[?1000;1006;2004h23456789']);
});

test('a later client with no scrollback is only resized', () => {
const { log, deps, host, session } = fixture();
session.pty = new FakePty(log);
Expand Down
13 changes: 10 additions & 3 deletions src/server/sessions/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Config } from '../../config';
import type { PtyProcess, SpawnPty } from '../../pty';
import { type FlowSocket, OutputPacer } from '../output-pacer';
import { WS_CLOSE } from '../ws/close-codes';
import { InputModes } from './input-modes';
import { sessionBanner } from './session-banner';

/** What {@link Session.ensurePty} needs to start a shell, injected so tests need no real PTY. */
Expand Down Expand Up @@ -39,6 +40,8 @@ export class Session {
readonly subscribers = new Set<FlowSocket>();
/** The last `config.scrollback` characters of output, replayed when a client joins. */
scrollback = '';
/** The input modes the output last set, restored ahead of the replay (ADR 070). */
readonly inputModes = new InputModes();
/** Paces PTY output to the clients by their acknowledgements (ADR 048). */
readonly output = new OutputPacer(() => this.pty);

Expand Down Expand Up @@ -70,7 +73,8 @@ export class Session {
/**
* Greets a newly attached client. The first client spawns the shell and is sent the
* banner, which starts the scrollback, then the shell is sent a newline so it prints a
* prompt. A later client is sent the scrollback first and only then resizes the PTY.
* prompt. A later client is sent the input modes the output has set (ADR 070), then the
* scrollback, and only then resizes the PTY.
*
* @param ws - The client just passed to {@link attach}.
* @param cols - The client's width, used for the spawn or the resize.
Expand All @@ -86,7 +90,8 @@ export class Session {
host: SessionExitHost,
): void {
if (this.pty) {
if (this.scrollback) this.output.send(ws, this.scrollback);
const replay = this.inputModes.restore() + this.scrollback;
if (replay) this.output.send(ws, replay);
this.pty.resize(cols, rows);
return;
}
Expand All @@ -102,9 +107,11 @@ export class Session {
env: config.env,
});
this.pty = pty;
// The per-chunk hot path: append to the ring and hand the chunk to the pacer, nothing more.
// The per-chunk hot path: append to the ring, note the input modes (a scan that returns
// at once for a chunk with no ESC), and hand the chunk to the pacer.
pty.onData((data: string) => {
this.scrollback = (this.scrollback + data).slice(-limit);
this.inputModes.observe(data);
this.output.broadcast(data);
});
pty.onExit(() => this.handleExit(host));
Expand Down
Loading