Skip to content

Commit 6513cf1

Browse files
feat(tap): terminal session manager with multiplayer attach (#329)
## What Brings the `tap` terminal session manager (formerly `andrewgazelka/tap`) into this workspace as `nix run .#tap`, as a ground-up daemon/client rewrite. `tap` gives you tmux-style session persistence without the in-terminal tiling layer: start a command, detach, reattach later from any terminal, and share a session with others. It is meant for tiling-WM users whose WM already handles window layout. ## Why a rewrite The original embedded the server inside the foreground process. That made three things impossible, all of which this PR fixes and tests: - **Attaching to a running full-screen TUI** repainted nothing useful (it dumped colorless plain text with no cursor or alternate-screen state). Now the daemon hands every new client an escape-sequence resync snapshot of the live screen, joined to the output stream with no gap. - **Resizing while attached** was dropped entirely (no client `SIGWINCH` handler). Now the attach client forwards resizes; the session renegotiates. - **Multiplayer** was hard-rejected (one attached client max). Now any number of clients can attach; the session is sized to the smallest one (element-wise min of every client's rows and cols), and larger clients get a dim bottom-row warning that their extra space is unused. Also fixed: line-framed protocol (the old one dropped coalesced reads), real child exit codes, stale-session cleanup via PID liveness checks, and proper daemonization. ## Shape Three composable crates under `packages/tap/`: - `tap-pty`: the reusable PTY session engine. Spawns a child on a real PTY via `pty-process`, mirrors it with `vt100`, and fans raw output out to many subscribers. A subscriber's resync snapshot and live stream are handed out under one emulator lock, so a late attach joins exactly once (no missed or duplicated bytes). This is the bug a session manager has to get right. - `tap-protocol`: wire types and runtime paths, no I/O. - `tap`: the CLI, daemon, and attach client. Commands: `start` / `attach` / `list` / `kill` / `scrollback` / `cursor` / `size` / `inject` / `subscribe`. Detach with `Ctrl-\`, open scrollback in `$EDITOR` with `Alt-e`. ## Tests Integration tests in `packages/tap/tests/integration.rs` drive the real `tap` binary on a PTY using this repo's [`tui`](../tree/main/packages/tui) driver and assert on the rendered grid: input round-trip, full-screen resync on a second attach, multiplayer min-size negotiation, and resize-while-attached. They run in the nix sandbox (`bash` registered as a test input). ## Validation - `nix build .#tap` - `nix run .#tap -- --help` - `nix run .#lint` (all 5 tasks) - `nix build .#tap.passthru.tests.{clippy,tap-all,cargoMachete,unusedCrateDependencies}` (workspace clippy covers all three crates) - `nix build '.#tap.passthru.tests."integration-*-all"'` (the four PTY integration tests pass in the sandbox) ## Notes Unix only (PTYs, `SIGWINCH`, `setsid`); sessions are local to one machine and user. macOS detail: the daemon `setsid`s after spawning its PTY child, because macOS rejects the child's `TIOCSCTTY` when its parent is already a leaderless session. Made with AI (Anthropic Claude, model `claude-opus-4-8`).
1 parent 1a36e6f commit 6513cf1

24 files changed

Lines changed: 3091 additions & 2 deletions

Cargo.lock

Lines changed: 53 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ members = [
1515
"packages/nix-web-monitor/server",
1616
"packages/oci-image-builder",
1717
"packages/repo-walker",
18+
"packages/tap",
19+
"packages/tap/protocol",
20+
"packages/tap/pty",
1821
"packages/tui",
1922
"packages/tui-dashboard",
2023
"packages/tui-node",
@@ -61,6 +64,7 @@ napi = { version = "3", default-features = false, features = ["napi6", "tokio_rt
6164
napi-build = "2"
6265
napi-derive = "3"
6366
ndarray = "0.16"
67+
nix = "0.30"
6468
nix-web-monitor-parser = { path = "packages/nix-web-monitor/parser" }
6569
numpy = "0.28"
6670
object = { version = "0.37", default-features = false, features = [
@@ -88,6 +92,8 @@ snafu = "0.9"
8892
strip-ansi-escapes = "0.2"
8993
tango-bench = "0.7"
9094
tantivy = "0.26"
95+
tap-protocol = { path = "packages/tap/protocol" }
96+
tap-pty = { path = "packages/tap/pty" }
9197
tar = "0.4"
9298
tempfile = "3"
9399
tokio = "1"

lib/rust-workspace.nix

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,13 @@ let
6060
"test"
6161
"bench"
6262
];
63-
packageTestInputs.tui = [ workspacePkgs.vim ];
64-
packageTestInputs.ix-mcp = [ workspacePkgs.python3 ];
63+
packageTestInputs = {
64+
tui = [ workspacePkgs.vim ];
65+
ix-mcp = [ workspacePkgs.python3 ];
66+
# tap's integration tests drive the `tap` binary on a PTY and run `bash`
67+
# as the session child; the daemon resolves `bash` from PATH at runtime.
68+
tap = [ workspacePkgs.bash ];
69+
};
6570
# `rodio` (packages/minecraft/sound) pulls `cpal`/`alsa-sys`, whose build
6671
# script needs ALSA's pkg-config metadata to link `libasound` on Linux.
6772
# Scoped to the whole workspace because the unit graph compiles every

packages/tap/Cargo.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[package]
2+
name = "tap"
3+
version.workspace = true
4+
edition.workspace = true
5+
publish.workspace = true
6+
description = "Minimal terminal session manager for tiling-WM users: detach, attach, share"
7+
8+
[lints]
9+
workspace = true
10+
11+
[dependencies]
12+
anyhow.workspace = true
13+
clap = { workspace = true, features = ["derive"] }
14+
dirs.workspace = true
15+
nix = { workspace = true, features = ["term", "process"] }
16+
parking_lot.workspace = true
17+
serde = { workspace = true, features = ["derive"] }
18+
serde_json.workspace = true
19+
tap-protocol.workspace = true
20+
tap-pty.workspace = true
21+
tempfile.workspace = true
22+
tokio = { workspace = true, features = ["io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
23+
toml = { workspace = true, features = ["parse", "serde"] }
24+
25+
[dev-dependencies]
26+
# Drive the built `tap` binary on a real PTY and assert on its rendered screen.
27+
tui.workspace = true

packages/tap/README.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# tap
2+
3+
A terminal session manager for tiling-WM users. Start a command, detach from it,
4+
and reattach later from any terminal, with no in-terminal tiling layer to fight
5+
your window manager. Multiple people can attach to one session at once.
6+
7+
```sh
8+
nix run .#tap # start a session in the current terminal
9+
nix run .#tap -- attach # reattach to the most recent session
10+
```
11+
12+
## Why not tmux
13+
14+
If you use a tiling window manager (i3, Sway, Aerospace, yabai) you already tile
15+
windows. tmux then runs a second tiling system inside the terminal: competing
16+
keybinds, nested navigation, panes that duplicate what your WM windows already
17+
do. tap keeps the one feature you actually came for, session persistence, and
18+
drops the rest. Your WM tiles; tap persists.
19+
20+
## Commands
21+
22+
```sh
23+
tap # start a session (interactive $SHELL)
24+
tap start <cmd...> # start a session running a command
25+
tap start -d <cmd...> # start in the background, do not attach
26+
tap attach [id] # attach to a session (most recent if omitted)
27+
tap list # list live sessions
28+
tap kill [id] # stop a session and its child
29+
tap scrollback [-s id] # print the session's screen as text
30+
tap cursor [-s id] # print the cursor position
31+
tap size [-s id] # print the negotiated size
32+
tap inject [-s id] <text> # type text into a session without attaching
33+
tap subscribe [-s id] # stream the session's raw output
34+
```
35+
36+
Detach from an attached session with `Ctrl-\`. Open the scrollback in `$EDITOR`
37+
with `Alt-e`. Both keybinds are configurable (see Configuration).
38+
39+
## Multiplayer
40+
41+
Several clients can attach to the same session and see the same screen live.
42+
Input from any client reaches the shared child, so two people can drive one
43+
terminal. The session is sized to the **smallest** attached client (the
44+
element-wise minimum of every client's rows and columns), the same rule tmux
45+
uses, so no client ever sees clipped output. A client whose own terminal is
46+
larger than the negotiated size gets a dim warning on its bottom row noting that
47+
the extra space is unused.
48+
49+
```sh
50+
# terminal A
51+
tap start --id pair bash
52+
# terminal B (anywhere on the same machine)
53+
tap attach pair
54+
```
55+
56+
## Running it
57+
58+
tap is a package in this repository's Cargo workspace, exposed as a flake app:
59+
60+
```sh
61+
nix run .#tap -- <args> # run
62+
nix build .#tap # build the binary
63+
```
64+
65+
## Architecture
66+
67+
tap is one daemon per session plus any number of thin clients, connected over a
68+
per-session Unix socket speaking newline-delimited JSON (binary payloads ride as
69+
base64). Every interactive `tap`, even for a single user, is a client of a
70+
daemon. That uniformity is what makes resize-while-attached and multi-client
71+
sharing work: the daemon owns the PTY and the screen, and clients only render it
72+
and forward keystrokes.
73+
74+
The code is three composable crates:
75+
76+
- [`tap-pty`](pty) is the reusable session engine: it spawns a child on a real
77+
PTY (via [`pty-process`](https://docs.rs/pty-process)), mirrors it in a
78+
[`vt100`](https://docs.rs/vt100) emulator, and fans the raw output out to many
79+
subscribers. A new subscriber gets an atomic resync snapshot (the escape
80+
sequences that reproduce the current screen) joined to the live stream with no
81+
gap and no duplicated byte, which is what makes attaching to a running
82+
full-screen TUI paint correctly.
83+
- [`tap-protocol`](protocol) is the wire types and runtime paths, with no I/O.
84+
- `tap` (this crate) is the CLI, the daemon, and the attach client.
85+
86+
Integration tests in [`tests/integration.rs`](tests/integration.rs) drive the
87+
real `tap` binary on a PTY using this repository's [`tui`](../tui) driver and
88+
assert on the rendered grid: round-trip input, full-screen resync on a second
89+
attach, multiplayer min-size negotiation, and resize-while-attached.
90+
91+
## Configuration
92+
93+
Optional, at `~/.config/tap/config.toml`:
94+
95+
```toml
96+
editor = "nvim" # overrides $EDITOR / $VISUAL for the Alt-e keybind
97+
98+
[keybinds]
99+
editor = "Alt-e"
100+
detach = "Ctrl-\\"
101+
102+
[timing]
103+
escape_timeout_ms = 50 # window to tell a lone ESC from an Alt- sequence
104+
```
105+
106+
Session sockets and the session index live under `$XDG_RUNTIME_DIR/tap` (falling
107+
back to `~/.tap`). Set `TAP_RUNTIME_DIR` to relocate them, for example to isolate
108+
test state.
109+
110+
## Known limitations
111+
112+
- Unix only (Linux and macOS). It relies on PTYs, `SIGWINCH`, and `setsid`.
113+
- Sessions are local to one machine and one user; there is no network transport
114+
or cross-user access. The socket lives in the user's runtime directory.
115+
- A client that falls far behind the output stream is resynced from a fresh
116+
snapshot rather than a byte-exact replay, so a burst can skip intermediate
117+
frames on a slow link.
118+
- The `Alt-e` editor keybind is detected with a short escape timeout; on a slow
119+
or split input read a literal `Alt-e` can be delayed by `escape_timeout_ms`.

packages/tap/default.nix

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{ ix, ... }:
2+
3+
ix.cargoUnit.selectBinaryWithTests ix.rustWorkspace.units {
4+
binary = "tap";
5+
meta.mainProgram = "tap";
6+
}

packages/tap/package.nix

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
id = "tap";
3+
packageSet = true;
4+
flake = true;
5+
inRustWorkspace = true;
6+
passthruTests = true;
7+
}

packages/tap/protocol/Cargo.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[package]
2+
name = "tap-protocol"
3+
version.workspace = true
4+
edition.workspace = true
5+
publish.workspace = true
6+
description = "Wire protocol and runtime paths for tap terminal sessions"
7+
8+
[lints]
9+
workspace = true
10+
11+
[dependencies]
12+
base64.workspace = true
13+
dirs.workspace = true
14+
serde = { workspace = true, features = ["derive"] }
15+
serde_json.workspace = true

packages/tap/protocol/package.nix

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
id = "tap-protocol";
3+
inRustWorkspace = true;
4+
passthruTests = true;
5+
}

0 commit comments

Comments
 (0)