Skip to content

Commit a58db36

Browse files
committed
feat: add session detach/attach for tmux-like persistence
- Add detach keybind (Ctrl-\) to leave session running in background - Add `tap attach [session]` command to reattach to running sessions - Add `tap start -d` for starting detached sessions - Update README with new value proposition for tiling WM users - Sessions now survive terminal close, can be reattached later The key insight: tiling WM users already have window management. tmux's tiling is redundant overhead. tap gives you the one thing that matters (session persistence) without the rest.
1 parent c1e8166 commit a58db36

10 files changed

Lines changed: 702 additions & 90 deletions

File tree

.github/assets/header.svg

Lines changed: 1 addition & 1 deletion
Loading

Cargo.lock

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

README.md

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,56 +6,75 @@
66
<code>nix run github:andrewgazelka/tap</code>
77
</p>
88

9-
Let Claude Code see and control your other terminal windows.
9+
Terminal session manager for tiling WM users. Detach and reattach to sessions without the overhead of tmux.
1010

1111
## The Problem
1212

13-
You're running a dev server in one terminal tab. You ask Claude Code to check if it's working. But Claude Code can't see that tab - it only sees its own terminal.
13+
If you use a tiling window manager (i3, Sway, Aerospace, yabai), you already have window tiling. Running tmux means two competing systems: tmux tiles inside the terminal, your WM tiles windows. This creates:
14+
15+
- Conflicting keybindings (is `Ctrl+b` for tmux or your WM?)
16+
- Nested navigation (WM to window, then tmux to pane)
17+
- Redundant concepts (tmux panes vs terminal windows)
18+
19+
But tmux has one killer feature: **session persistence**. You can detach from a running process and reattach later. That's worth the overhead for some people.
1420

1521
## The Solution
1622

23+
`tap` gives you session persistence without the tiling overhead.
24+
1725
```sh
18-
tap
26+
tap # start a session
27+
# ... run your dev server ...
28+
# close the terminal (or press Ctrl+\ to detach)
29+
30+
tap attach # reattach from any terminal
1931
```
2032

21-
Your shell works exactly the same - you won't notice any difference. But now Claude Code can see and type into this terminal in the background.
33+
Your window manager handles tiling. `tap` handles persistence. No conflicts.
2234

23-
## Example
35+
## Features
2436

25-
```sh
26-
# Terminal 1
27-
tap # starts your normal shell, nothing changes
28-
npm run dev # use it like normal
29-
30-
# Meanwhile, Claude Code can:
31-
# - See "Server running on :3000"
32-
# - Run "curl localhost:3000" in this terminal
33-
# - Watch for errors
34-
```
37+
- **Detach/Attach** - Close your terminal, reattach later
38+
- **Background sessions** - Start processes that outlive your terminal
39+
- **Scrollback capture** - Access terminal output programmatically
40+
- **AI integration** - Claude Code can see and control tap sessions
41+
- **Zero config** - Works out of the box, no `.tmux.conf` required
3542

3643
## Commands
3744

3845
```sh
39-
tap # start your normal shell
40-
tap start htop # or any command
41-
tap list # see active sessions
42-
tap scrollback # read terminal output
43-
tap cursor # get cursor position
44-
tap size # get terminal size
45-
tap inject "ls" # type into the terminal
46-
tap subscribe # stream live output
46+
tap # start interactive session
47+
tap start htop # run a command in a new session
48+
tap list # list active sessions
49+
tap attach [session] # reattach to a session
50+
tap detach # detach from current session (or Ctrl+\)
51+
tap scrollback [session] # get terminal output
52+
tap inject "ls" [session] # type into a session
4753
```
4854

4955
## Shell Integration
5056

5157
### Ghostty
5258

53-
Add to your Ghostty config (`~/.config/ghostty/config`):
59+
Add to `~/.config/ghostty/config`:
5460

5561
```
5662
command = tap
5763
```
5864

65+
Now every new terminal window is automatically a tap session.
66+
67+
## Comparison
68+
69+
| Feature | tmux | screen | abduco | tap |
70+
|---------|------|--------|--------|-----|
71+
| Session persistence | yes | yes | yes | yes |
72+
| Window/pane tiling | yes | yes | no | no |
73+
| Config complexity | high | medium | low | none |
74+
| Keybind conflicts with WM | yes | yes | minimal | minimal |
75+
| Scrollback API | no | no | no | yes |
76+
| AI tool integration | no | no | no | yes |
77+
5978
## Architecture
6079

6180
```mermaid

crates/tap-client/src/lib.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,37 @@ impl Client {
137137
match response {
138138
Response::Output { data } => Ok(Some(data)),
139139
Response::Error { message } => Err(Error::Server(message)),
140+
Response::SessionEnded { .. } => Ok(None),
140141
_ => Err(Error::Server("unexpected response".to_string())),
141142
}
142143
}
144+
145+
/// Attach to the session (take over stdin/stdout).
146+
/// Returns the initial scrollback content if successful.
147+
pub async fn attach(&mut self, rows: u16, cols: u16) -> Result<String> {
148+
let response = self.send_request(&Request::Attach { rows, cols }).await?;
149+
match response {
150+
Response::Attached { scrollback } => Ok(scrollback),
151+
Response::Error { message } => Err(Error::Server(message)),
152+
_ => Err(Error::Server("unexpected response".to_string())),
153+
}
154+
}
155+
156+
/// Send input to the PTY (for attached clients).
157+
pub async fn send_input(&mut self, data: Vec<u8>) -> Result<()> {
158+
let request = Request::Input { data };
159+
let request_bytes = serde_json::to_vec(&request)?;
160+
self.stream.get_mut().write_all(&request_bytes).await?;
161+
Ok(())
162+
}
163+
164+
/// Resize the PTY (for attached clients).
165+
pub async fn resize(&mut self, rows: u16, cols: u16) -> Result<()> {
166+
let request = Request::Resize { rows, cols };
167+
let request_bytes = serde_json::to_vec(&request)?;
168+
self.stream.get_mut().write_all(&request_bytes).await?;
169+
Ok(())
170+
}
143171
}
144172

145173
#[cfg(test)]

crates/tap-config/src/lib.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use eyre::WrapErr as _;
44

55
const DEFAULT_EDITOR_KEYBIND: &str = "Alt-e";
6+
const DEFAULT_DETACH_KEYBIND: &str = "Ctrl-\\";
67
const DEFAULT_ESCAPE_TIMEOUT_MS: u64 = 50;
78
const DEFAULT_EDITOR: &str = "vi";
89

@@ -27,6 +28,9 @@ pub struct KeybindConfig {
2728
/// Keybind to open scrollback in editor.
2829
/// Format: "Alt-e", "Ctrl-e", etc.
2930
pub editor: String,
31+
/// Keybind to detach from session.
32+
/// Format: "Ctrl-\\", etc.
33+
pub detach: String,
3034
}
3135

3236
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
@@ -40,6 +44,7 @@ impl Default for KeybindConfig {
4044
fn default() -> Self {
4145
Self {
4246
editor: DEFAULT_EDITOR_KEYBIND.to_string(),
47+
detach: DEFAULT_DETACH_KEYBIND.to_string(),
4348
}
4449
}
4550
}
@@ -94,8 +99,13 @@ pub enum Keybind {
9499
}
95100

96101
impl Keybind {
97-
/// Parse a keybind string like "Alt-e" or "Ctrl-e".
102+
/// Parse a keybind string like "Alt-e" or "Ctrl-e" or "Ctrl-\\".
98103
pub fn parse(s: &str) -> eyre::Result<Self> {
104+
// Handle special case of Ctrl-\ (backslash)
105+
if s == "Ctrl-\\" || s == "ctrl-\\" {
106+
return Ok(Keybind::Ctrl('\\'));
107+
}
108+
99109
let parts: Vec<&str> = s.split('-').collect();
100110
if parts.len() != 2 {
101111
eyre::bail!("invalid keybind format '{s}' — expected 'Alt-<key>' or 'Ctrl-<key>'");
@@ -265,4 +275,20 @@ mod tests {
265275
let kitty_seq = b"\x1b[101;5u";
266276
assert_eq!(kb.matches(kitty_seq), None);
267277
}
278+
279+
#[test]
280+
fn test_keybind_parse_ctrl_backslash() {
281+
let kb = Keybind::parse("Ctrl-\\").unwrap();
282+
assert_eq!(kb, Keybind::Ctrl('\\'));
283+
// Ctrl-\ is 0x1C (ASCII FS - File Separator)
284+
assert_eq!(kb.matches(&[0x1c]), Some(1));
285+
}
286+
287+
#[test]
288+
fn test_default_detach_keybind() {
289+
let config = Config::default();
290+
assert_eq!(config.keybinds.detach, "Ctrl-\\");
291+
let kb = Keybind::parse(&config.keybinds.detach).unwrap();
292+
assert_eq!(kb, Keybind::Ctrl('\\'));
293+
}
268294
}

crates/tap-protocol/src/lib.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ pub struct Session {
77
pub pid: u32,
88
pub started: String,
99
pub command: Vec<String>,
10+
/// Whether a client is currently attached to this session.
11+
#[serde(default)]
12+
pub attached: bool,
1013
}
1114

1215
/// Client requests to the server.
@@ -23,6 +26,17 @@ pub enum Request {
2326
GetSize,
2427
/// Subscribe to live output.
2528
Subscribe,
29+
/// Attach to the session (take over stdin/stdout).
30+
Attach {
31+
/// Terminal rows.
32+
rows: u16,
33+
/// Terminal columns.
34+
cols: u16,
35+
},
36+
/// Send input from attached client to PTY.
37+
Input { data: Vec<u8> },
38+
/// Resize the PTY from attached client.
39+
Resize { rows: u16, cols: u16 },
2640
}
2741

2842
/// Server responses.
@@ -39,6 +53,13 @@ pub enum Response {
3953
Output { data: Vec<u8> },
4054
/// Subscription confirmed.
4155
Subscribed,
56+
/// Attach confirmed - client now owns stdin/stdout.
57+
Attached {
58+
/// Current scrollback content for initial display.
59+
scrollback: String,
60+
},
61+
/// Session has ended (child process exited).
62+
SessionEnded { exit_code: i32 },
4263
/// Success.
4364
Ok,
4465
/// Error.

crates/tap-server/src/input.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub struct InputProcessor {
1212
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1313
pub enum KeybindAction {
1414
OpenEditor,
15+
Detach,
1516
}
1617

1718
#[derive(Debug)]
@@ -31,6 +32,9 @@ impl InputProcessor {
3132
let editor_keybind = tap_config::Keybind::parse(&config.keybinds.editor)?;
3233
keybinds.push((editor_keybind, KeybindAction::OpenEditor));
3334

35+
let detach_keybind = tap_config::Keybind::parse(&config.keybinds.detach)?;
36+
keybinds.push((detach_keybind, KeybindAction::Detach));
37+
3438
Ok(Self {
3539
keybinds,
3640
escape_timeout: std::time::Duration::from_millis(config.timing.escape_timeout_ms),

0 commit comments

Comments
 (0)