diff --git a/docs/adrs/070.webtty.replay-input-modes.md b/docs/adrs/070.webtty.replay-input-modes.md new file mode 100644 index 0000000..a09bf4e --- /dev/null +++ b/docs/adrs/070.webtty.replay-input-modes.md @@ -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. diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index ced6698..aeef74c 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -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 diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index 07f716b..825d31d 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -1,6 +1,6 @@ # SPEC: webtty -**Last Updated:** 2026-10-14 +**Last Updated:** 2026-10-15 --- @@ -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 ` 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) | ✅ | diff --git a/src/server/sessions/input-modes.test.ts b/src/server/sessions/input-modes.test.ts new file mode 100644 index 0000000..95c32e0 --- /dev/null +++ b/src/server/sessions/input-modes.test.ts @@ -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'); + }); +}); diff --git a/src/server/sessions/input-modes.ts b/src/server/sessions/input-modes.ts new file mode 100644 index 0000000..d396e0e --- /dev/null +++ b/src/server/sessions/input-modes.ts @@ -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(); + 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); + } + } +} diff --git a/src/server/sessions/session.test.ts b/src/server/sessions/session.test.ts index 8e7396c..43f284a 100644 --- a/src/server/sessions/session.test.ts +++ b/src/server/sessions/session.test.ts @@ -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); diff --git a/src/server/sessions/session.ts b/src/server/sessions/session.ts index 4abbc44..edf326c 100644 --- a/src/server/sessions/session.ts +++ b/src/server/sessions/session.ts @@ -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. */ @@ -39,6 +40,8 @@ export class Session { readonly subscribers = new Set(); /** 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); @@ -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. @@ -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; } @@ -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));