Building a File Transfer TUI Nobody Asked For: tuit
I’ve always been suspicious of file transfer tools. WeTransfer wants my email. AirDrop only works when it feels like it. scp requires me to remember which machine has the SSH key (shoutout to sshs). Google Drive is well, Google…
So naturally I spent way too long building a terminal UI for something that already has a perfectly functional CLI. I did add a few features though so at least I have that going for me…
tuit
The name is a bit of a pun - send “to it” or “in-to-it” for effortless P2P transfer. Just send tuit. The whole point was making transfers secure, private, and feel effortless.

It’s fully compatible with iroh’s sendme CLI and the AltSendme GUI. Same BlobTicket format, same protocol. You can send from one and receive on another. QR code support for sharing tickets via mobile.
What iroh Actually Does
The iroh project handles the hard networking problem: getting two devices to talk directly without a central server or relay in the middle.
The core mechanic is the MagicSocket (borrowed from Tailscale’s open-source protocols, reimplemented in Rust over QUIC instead of WireGuard). It hides all the NAT traversal complexity. You just get an endpoint, connect to a peer by their public key, and packets flow.
Under the hood:
- QUIC over UDP for the actual data (TLS 1.3 encryption, multiplexed streams, no head-of-line blocking)
- Content addressing via BLAKE3 hashes (you’re transferring a specific blob, not just “a file”)
- Relay servers for coordination and fallback
The content-addressing piece is interesting. When you share a file, you’re really sharing a hash. The receiver asks for that exact blob. If even one bit differs, the hash won’t match. Integrity checking is built into the protocol, not bolted on after.
The Relay Question
I wanted a little indicator in my transfer UI to communicate status and wondered: when does iroh use a relay versus a direct connection?
Turns out the relay is always involved at first (my first privacy concern). Here’s the sequence:
-
Discovery: Your node figures out its own network situation - interfaces, NAT type, public-facing address via QUIC Address Discovery.
-
Coordination: You send a
CallMeMaybemessage through the relay to the other peer. This tells them your known addresses and asks them to try punching through. -
Hole punching: Both sides send UDP pings to each other’s addresses. If any get through, you’ve punched a hole. The relay sees the
Pongresponses and knows a direct path exists. -
Switchover: Once hole punching succeeds, the MagicSocket silently reroutes packets from the relay to the direct path. The QUIC layer doesn’t even know - iroh lies to it about addresses and rewrites packets on the way in and out.
-
Fallback: If no direct path ever works (aggressive corporate NAT, symmetric NAT on both sides), traffic keeps flowing through the relay. Encrypted end-to-end - the relay sees nothing.
The n0 team reports around 90% of connections successfully hole-punch to direct. The other 10% still work, just with slightly higher latency through the relay.
This explains why my first few seconds of a transfer sometimes show “relayed” before flipping to “direct.” The system is doing its thing.
Privacy & Persistence Trade-offs
P2P doesn’t mean invisible. There are trade-offs.
What Gets Exposed
To relays:
- Both peers’ IP addresses
- Connection timing and data volume
- Your NodeID (public key)
To peers (after hole-punch):
- Your real IP address
To your disk:
- Transfer history (JSON)
- Blob store hashes
- Config/preferences
How I Addressed These Concerns
Ephemeral NodeIDs: Both sends and receives get a fresh endpoint per transfer:
// receiver.rs - fresh NodeID per receivelet endpoint = Endpoint::builder() .alpns(vec![iroh_blobs::protocol::ALPN.to_vec()]) .discovery(DnsDiscovery::n0_dns()) .bind() .await?;If receiving from remote_01, then remote_02, the relay sees different NodeIDs. Transfers should be cryptographically unlinkable.
No pre-connection: Considered connecting to the relay on startup to reduce first-transfer latency. Decided against it - pre-connecting exposes your NodeID the moment you launch, even if you never transfer anything. The relay only sees you when you actually use it. Worth the ~1-2 second delay.
Incognito mode: --incognito leaves no trace:
| Feature | Normal | Incognito |
|---|---|---|
| Load config | ✅ | ❌ defaults |
| Load/save history | ✅ | ❌ |
| Save preference changes | ✅ | ❌ |
| Clean blob store on exit | ❌ | ✅ |
Normal mode reads config from ~/.config/tuit/, loads your transfer history, and remembers theme/keymap changes. Incognito ignores all of it - starts fresh with defaults, records nothing, cleans up the blob store on exit. Run tuit again after an incognito session and everything is exactly as you left it.
What I Can’t Fix
The quantum asterisk: TLS 1.3 uses ECDH - not post-quantum. “Harvest now, decrypt later” is real. Anyone recording traffic today could decrypt it later. BLAKE3 hashes are quantum-safe, but transport encryption isn’t.
Relay trust: Default relays are operated by n0. Without self-hosting, you’re trusting they don’t log metadata (IPs, connection timing, NodeIDs).
Direct connections: Successful hole-punch = peers learn real IPs. No built-in Tor. VPN externally if it matters.
The Architecture That Emerged
Ratatui for rendering. Tokio channels for not blocking the UI. A 100ms polling loop that felt like overkill until I realized every responsive TUI does this.
The interesting bit was separating concerns. App state lives in one place. The transfer manager runs in background tasks. The UI just reads state and draws frames. No shared mutable state between them, just message passing.
Sounds obvious written down. Took me three rewrites to land on it.
The Dumb Problems
Clipboard over SSH doesn’t work. Obvious in retrospect - there’s no system clipboard on a remote terminal like my dev server. OSC52 escape sequences fix this by writing directly to the terminal emulator. Had Claude write a custom base64 encoder because I didn’t want another dependency for 40 lines of code.
File conflicts. What happens when you receive notes.txt but you already have one? Most tools silently overwrite or silently rename. I wanted a popup. Modal dialogs in a TUI are weird (you’re fighting the render loop) but the pattern ended up clean: pause the transfer, show the popup, store the resolution, resume.
Speed calculation. First instinct: bytes_transferred / seconds_elapsed. Problem: if a transfer stalls for 30 seconds then resumes, your “speed” is wrong for the next minute. Rolling window fixed it. Keep 5 seconds of samples, drop the old ones.
Crate naming. tuit was already taken on crates.io. Went with send-tuit to stay in the sendme family. The binary is still just tuit.
File tree navigation. Harder than expected. The tree widget handles rendering, but state is split: TreeState tracks cursor position, FileNode holds actual data. Lazy loading means children only load on expand - keeps startup fast but requires careful coordination when search results need to be navigable.
The fuzzy search was the tricky part. nucleo-matcher scores matches, but you can’t just show a flat list - users expect directory structure. So search results get merged back into a tree:
// Fuzzy match, then rebuild as navigable treelet matches = paths.filter_map(|path| { let score = pattern.score(&name, &mut matcher)?; Some((score, path))});self.search_nodes = Self::build_merged_tree(&paths);Results show the file hierarchy with ancestor nodes auto-expanded. Navigate matches like a normal tree.
What I’m Still Wondering
The perceptual bandwidth of terminal UIs. How much information can you actually show before it becomes noise? I settled on four tabs, minimal chrome, lots of whitespace. But I don’t know if that’s good design or just my preference for minimal interfaces.
A question for another time.
cargo install send-tuitOr grab a binary from GitHub Releases.