Skip to content
Open
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
1,821 changes: 1,747 additions & 74 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ members = [
"packages/pty_manager",
"packages/tty_wrapper",
"packages/virtual_terminal",
"packages/mindmeld",
]
# workshop_ui/crate requires WORKSHOP_UI_PATH env var pointing to SvelteKit build output.
# workshop_desktop requires Tauri system dependencies (WebKit, etc.).
Expand All @@ -18,6 +19,7 @@ default-members = [
"packages/pty_manager",
"packages/tty_wrapper",
"packages/virtual_terminal",
"packages/mindmeld",
]
resolver = "2"

Expand Down Expand Up @@ -46,3 +48,6 @@ tower = "0.5"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.13.2", features = ["v4"] }
workshop = { path = "packages/workshop" }
pty_manager = { path = "packages/pty_manager" }
virtual_terminal = { path = "packages/virtual_terminal" }
13 changes: 13 additions & 0 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,19 @@ crate_index.spec(
package = "ratatui",
version = "0.30",
)
crate_index.spec(
package = "iroh",
version = "0.97",
)
crate_index.spec(
package = "iroh-tickets",
version = "0.4",
)
crate_index.spec(
default_features = False,
package = "arboard",
version = "3",
)

# File / directory utilities
crate_index.spec(
Expand Down
30 changes: 30 additions & 0 deletions packages/mindmeld/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test")

package(default_visibility = ["//visibility:public"])

rust_binary(
name = "meld",
srcs = glob(["src/**/*.rs"]),
edition = "2024",
deps = [
"//packages/pty_manager",
"//packages/virtual_terminal",
"@crate_index//:anyhow",
"@crate_index//:arboard",
"@crate_index//:clap",
"@crate_index//:iroh",
"@crate_index//:iroh-tickets",
"@crate_index//:ratatui",
"@crate_index//:serde",
"@crate_index//:tokio",
"@crate_index//:toml",
"@crate_index//:tracing",
"@crate_index//:tracing-subscriber",
"@crate_index//:uuid",
],
)

rust_test(
name = "meld_test",
crate = ":meld",
)
30 changes: 30 additions & 0 deletions packages/mindmeld/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[package]
name = "mindmeld"
version = "0.1.0"
edition = "2024"
description = "Peer-to-peer terminal sharing over iroh. Host a shell, share a ticket, pair-program with turn-based edit control."
license = "Apache-2.0"
repository = "https://github.com/empathic/workshop"
readme = "README.md"
keywords = ["terminal", "iroh", "p2p", "pair-programming", "tmux"]
categories = ["command-line-utilities", "network-programming", "development-tools"]

[[bin]]
name = "meld"
path = "src/main.rs"

[dependencies]
pty_manager = { workspace = true }
virtual_terminal = { workspace = true }
iroh = "0.97"
iroh-tickets = "0.4"
ratatui = "0.30"
arboard = { version = "3", default-features = false }
serde = { workspace = true }
toml = "1"
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
74 changes: 74 additions & 0 deletions packages/mindmeld/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# mindmeld

Peer-to-peer terminal sharing over [iroh](https://iroh.computer). One person hosts a shell; others connect read-only and can request edit access. No relay servers to run, no accounts, no ports to open — connections are established directly between peers via iroh's QUIC transport.

## Install

```sh
cargo install mindmeld
```

This installs the `meld` binary.

## Usage

**Host a session** (shares your `$SHELL` by default; runs the given command otherwise):

```sh
meld host # shares $SHELL
meld host claude # shares a specific command
```

On startup, the join command is copied to your clipboard:

```
meld join <ticket>
```

Send that to whoever should join.

## Keybindings

### Host

| Key | Action |
| -------------- | ----------------------------------------------- |
| F9 | Accept a pending edit request / revoke the turn |
| F10 | Deny a pending edit request |
| Shift+PageUp | Scroll back |
| Shift+PageDown | Scroll forward |

While no viewer holds the turn, the host types into the shared shell normally.

### Viewer

| Key | Action |
| -------- | ------------------------------------------------ |
| q | Quit |
| F9 | Request edit access / cancel request / release |
| PageUp | Scroll back |
| PageDown | Scroll forward |

Once the host grants the turn, the viewer's keystrokes flow into the shared shell. Host F9 revokes.

## Config

`~/.meld/config.toml` stores your display name, prompted for on first run:

```toml
name = "alice"
```

## Subprocess integration

Meld exports two hooks so subprocesses inside the shared shell can attribute actions to the user currently driving the session.

**`$MELD_SESSION_ID`** is set in the child environment to a per-session UUID. Presence of this variable tells a subprocess it's running inside a meld-shared shell.

**`~/.meld/sessions/$MELD_SESSION_ID/active_user`** is a plain-text file holding the display name of whoever currently holds the edit turn (the host, or the viewer who was granted by F9). It's rewritten atomically on every turn change and deleted when the session ends.

## How it works

The host spawns a PTY and binds an iroh endpoint with the `meld/term/0` ALPN. Viewers dial the host's ticket, open a bidirectional QUIC stream, and receive a keyframe replay of the current screen followed by a live stream of PTY output. A single viewer at a time may hold the edit turn; their keystrokes are forwarded to the host's PTY. The PTY is resized to the smallest connected viewport so everyone sees the same layout.

Wire protocol: `[tag: u8][len: u32 BE][payload]`. See `src/protocol.rs`.
86 changes: 86 additions & 0 deletions packages/mindmeld/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use std::path::PathBuf;

use anyhow::Result;
use ratatui::crossterm::event::{self, Event, KeyCode};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub name: String,
}

fn config_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".meld").join("config.toml")
}

impl Config {
pub fn load() -> Option<Self> {
let content = std::fs::read_to_string(config_path()).ok()?;
let config: Config = toml::from_str(&content).ok()?;
if config.name.is_empty() {
None
} else {
Some(config)
}
}

pub fn save(&self) -> Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, toml::to_string_pretty(self)?)?;
Ok(())
}
}

pub fn ensure_name(terminal: &mut ratatui::DefaultTerminal) -> Result<String> {
let default = Config::load()
.map(|c| c.name)
.unwrap_or_else(|| std::env::var("USER").unwrap_or_default());
let mut input = default;
let mut cursor = input.len();

loop {
let prompt = format!("enter your name: {}", &input);
terminal.draw(|frame| {
let area = frame.area();
let status_area =
ratatui::prelude::Rect::new(area.x, area.bottom().saturating_sub(1), area.width, 1);
frame.render_widget(
ratatui::widgets::Paragraph::new(crate::status_line(&prompt)),
status_area,
);
let cursor_x = status_area.x
+ "(meld) ".len() as u16
+ "enter your name: ".len() as u16
+ cursor as u16;
frame.set_cursor_position((cursor_x, status_area.y));
})?;

if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Enter if !input.is_empty() => {
let config = Config {
name: input.clone(),
};
config.save()?;
return Ok(input);
}
KeyCode::Char(c) => {
input.insert(cursor, c);
cursor += 1;
}
KeyCode::Backspace if cursor > 0 => {
cursor -= 1;
input.remove(cursor);
}
KeyCode::Left if cursor > 0 => cursor -= 1,
KeyCode::Right if cursor < input.len() => cursor += 1,
_ => {}
}
}
}
}
Loading
Loading