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
22 changes: 15 additions & 7 deletions apps/cli/src/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@
* attach.ts — `fleet client attach`: drive a workspace's webterm terminal from a
* real TTY.
*
* Output direction: the server streams full grid snapshots, which we repaint with
* `renderGrid`. Input direction is trivial — a TTY in raw mode already emits the
* exact byte sequences a PTY expects (arrows, ctrl chars, …), so we forward raw
* stdin straight through as `input` messages. Ctrl-] detaches without killing the
* shell (the tmux session survives for re-attach).
* Output direction: the server streams snapshots and deltas, which a `GridStream`
* folds back into full grids for `renderGrid` to repaint (the renderer always
* paints the whole screen). Input direction is trivial — a TTY in raw mode
* already emits the exact byte sequences a PTY expects (arrows, ctrl chars, …),
* so we forward raw stdin straight through as `input` messages. Ctrl-] detaches
* without killing the shell (the tmux session survives for re-attach).
*/

import type { WorkspaceStatus } from "fleet-protocol";
import {
decodeServerMessage,
GridStream,
splitInput,
TERMINAL_CONFLICT_CLOSE_CODE,
TERMINAL_TAKEOVER_CLOSE_CODE,
Expand Down Expand Up @@ -84,6 +86,7 @@ export async function attachToWorkspace(shipUrl: string, repo: string, name: str
const ws = new WebSocket(terminalWsUrl(shipUrl, repo, name));

let torn = false;
const stream = new GridStream();
const send = (msg: ClientMsg) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
};
Expand Down Expand Up @@ -137,8 +140,13 @@ export async function attachToWorkspace(shipUrl: string, repo: string, name: str

ws.onmessage = (event) => {
const msg = decodeServerMessage(event.data);
if (msg.type === "grid") process.stdout.write(renderGrid(msg));
else teardown(msg.code);
if (msg.type === "exit") {
teardown(msg.code);
return;
}
const { grid, reply } = stream.accept(msg);
if (reply) send(reply);
if (grid) process.stdout.write(renderGrid(grid));
};

ws.onerror = () => {
Expand Down
1 change: 1 addition & 0 deletions apps/cli/tests/render-grid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { renderGrid } from "../src/render-grid";
function grid(cells: WireCell[][], cursor?: Partial<GridMsg["cursor"]>): GridMsg {
return {
type: "grid",
seq: 0,
rows: cells.length,
cols: cells[0]?.length ?? 0,
cursor: { x: 0, y: 0, visible: true, ...cursor },
Expand Down
50 changes: 36 additions & 14 deletions apps/docs/src/content/docs/concepts/terminals.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,15 @@ frames are text; a binary frame closes the connection.
| `init` | `cols`, `rows` | first message: allocate the emulator and spawn the PTY at this size |
| `input` | `data` | keystrokes or pasted bytes to write to the PTY |
| `resize` | `cols`, `rows` | resize both the PTY and the emulator |
| `ack` | `seq` | this frame arrived and was painted |
| `resync` | — | the frame sequence broke; send a full snapshot |

**Server → client**

| Message | Payload | Meaning |
|---|---|---|
| `grid` | `cols`, `rows`, `cursor`, `cells` | a full snapshot of the active screen |
| `grid` | `seq`, `cols`, `rows`, `cursor`, `cells` | a full snapshot of the active screen |
| `patch` | `seq`, `cols`, `rows`, `cursor`, `runs` | the cells that changed since frame `seq - 1` |
| `exit` | `code` | the process exited; the connection is closing |

`init` must be the first message and must be sent exactly once — sending it
Expand All @@ -77,23 +80,42 @@ Sizes are bounded (1–1024 columns, 1–512 rows) and a single `input` is cappe
256 KiB, which the client-side helper handles by splitting large pastes into
chunks on UTF-8 character boundaries.

### Frames are full snapshots

There is no scrollback protocol, no incremental cell diffing, and no escape
sequences on the wire. When the PTY produces output, the ship feeds the raw
bytes to [`bun-vt`](/packages/bun-vt/) — a pure-TypeScript port of libghostty's
VT emulation — and schedules a frame. Frames are coalesced at roughly 16 ms
(~60 fps), so a burst of output produces one snapshot rather than thousands.

To keep those snapshots small, cells use a compact encoding: a blank default
cell — a space, default colors, no styling, which is most of a screen —
### Frames are cells, not escape sequences

There is no scrollback protocol and no escape sequences on the wire. When the PTY
produces output, the ship feeds the raw bytes to [`bun-vt`](/packages/bun-vt/) —
a pure-TypeScript port of libghostty's VT emulation — and schedules a frame.
Frames are coalesced at roughly 16 ms (~60 fps), so a burst of output produces
one frame rather than thousands, and an interval in which nothing changed
produces none at all.

Two frame types carry the screen. A `grid` is a **complete** snapshot: it can be
applied to any state, which makes it self-syncing. A `patch` carries only the
runs of cells that changed, and is valid only against the frame numbered
`seq - 1`. A connection opens with a `grid`, and so does the first frame after a
resize — a patch cannot cross a size change — or after a client asks to `resync`.
Everything else is a patch.

That is safe alongside the coalescing above because the diff baseline is the last
frame the ship actually **sent**, not the last one it computed. Frames the ship
chose to skip are simply folded into the next patch.

Skipping is the other half of the design. The client acks every frame it paints,
and the ship stops producing once too many frames are unacked, so a slow link
makes the terminal coarser rather than putting it further and further behind.
Every WebSocket in the chain also negotiates permessage-deflate, which these
frames — repetitive JSON, mostly blank cells — compress extremely well.

To keep frames small in the first place, cells use a compact encoding: a blank
default cell — a space, default colors, no styling, which is most of a screen —
serializes as the literal number `0`. Anything else is an object carrying only
its non-default fields: character, foreground, background, an attribute bitmask,
underline style, and cell width.

The upshot is that the browser side is genuinely simple: decode, paint cells to
a canvas, encode key events back. The [webterm reference](/packages/webterm/)
has the full table of attribute bits and color forms.
The upshot is that the browser side stays simple: decode, fold the frame into
the current snapshot with the shared `GridStream` helper, paint cells to a
canvas, encode key events back. The [webterm reference](/packages/webterm/) has
the full table of attribute bits and color forms.

### One terminal per workspace

Expand Down
Loading
Loading