DEV Community

Cover image for Building a Multi-Contact TUI in Rust with Ratatui: Tabs, Consoles, and UX
Matías Denda
Matías Denda

Posted on

Building a Multi-Contact TUI in Rust with Ratatui: Tabs, Consoles, and UX

This is the final post in a 6-part series on Anyhide, a Rust steganography tool. This post is about the part users actually see.


Cryptographic tools that don't feel good to use don't get used. You can ship the world's most elegant Double Ratchet — if the interface is hostile, nobody will touch it, and you've built a demo, not a tool.

When I got to the chat feature, I knew I wanted a TUI. Terminal UIs are faster to iterate on than web UIs, work over SSH, respect the aesthetic of the rest of the tool, and — honestly — just feel right for something that's about privacy and control. The library I used is ratatui, a mature fork of the older tui-rs crate.

This post is about what I built on top of ratatui, what went well, and what I'd do differently.

What it looks like

╭──────────────────────────────────────────────────────────────────╮
│  ⠿ anyhide                                            v0.13.0   │
│ ──────────────────────────────────────────────────────────────── │
│ Contacts    │  alice   bob   ~guest                              │
│─────────────├────────────────────────────────────────────────────│
│ ● alice   ▲ │ [14:32] alice: hola                         ▲     │
│ ○ bob     █ │ [14:33] you: todo bien?                     █     │
│ ◌ ~guest  █ │ [14:34] alice: si, vos?                     │     │
│           ▼ │                                             ▼     │
│─────────────├────────────────────────────────────────────────────│
│  + Add      │ > Mensaje...                              128/256 │
│─────────────┴────────────────────────────────────────────────────│
│ 🔒 Tor ● | Chat ● | abc123...onion | ↑↓: nav | Ctrl+Q: quit     │
╰──────────────────────────────────────────────────────────────────╯
Enter fullscreen mode Exit fullscreen mode

Left sidebar: contacts with status icons. Top: tabs for open conversations. Middle: messages for the active tab. Bottom: input and status bar. This is a familiar layout — it's basically every chat client you've ever used, compressed into a terminal.

Contact statuses are an enum

One of the small design moments I enjoyed was the ContactStatus enum:

// src/chat/tui/multi_app.rs

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContactStatus {
    /// Contact is online (connected session).
    Online,
    /// Contact is offline (no active session).
    Offline,
    /// Ephemeral contact (not saved).
    Ephemeral,
    /// Connecting to contact.
    Connecting,
    /// Incoming chat request (waiting for us to accept).
    IncomingRequest,
    /// We sent a request, waiting for them to accept.
    PendingAccept,
}

impl ContactStatus {
    pub fn icon(&self) -> &'static str {
        match self {
            ContactStatus::Online           => "●",
            ContactStatus::Offline          => "○",
            ContactStatus::Ephemeral        => "◌",
            ContactStatus::Connecting       => "◐",
            ContactStatus::IncomingRequest  => "◀",  // Arrow pointing at us
            ContactStatus::PendingAccept    => "▶",  // Arrow pointing at them
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Six states, one glyph each. The arrows for request statuses are the part I'm proudest of: for "they're trying to reach us" and for "we're trying to reach them". No text needed. A glance at the sidebar tells you the state of every relationship at once.

This works because ratatui renders any Unicode string ratatui can layout, and the Unicode repertoire has exactly the glyphs I needed. If you're building a TUI, spend fifteen minutes in the Unicode symbol blocks — U+25A0 through U+25FF (Geometric Shapes) and U+2800 through U+28FF (Braille Patterns) are treasure troves.

Request/accept and the "unknown caller" problem

Private chat over Tor has a social problem: anyone with your .onion address can try to connect. If your client auto-accepts everything, you've built a DoS vector. If it rejects everything, you can't meet new people.

The solution is a request/accept flow, like iMessage or Signal invites. Incoming connections land in a queue. The user sees a notification. They choose to accept or reject.

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NotificationKind {
    /// Chat request from a known contact.
    KnownContactRequest,
    /// Chat request from an unknown/ephemeral source.
    EphemeralRequest,
    /// Connection established.
    Connected,
    Disconnected,
    Error,
    Info,
}

impl NotificationKind {
    pub fn icon(&self) -> &'static str {
        match self {
            NotificationKind::KnownContactRequest => "👤",
            NotificationKind::EphemeralRequest    => "👻",
            NotificationKind::Connected           => "✓",
            NotificationKind::Disconnected        => "✗",
            NotificationKind::Error               => "⚠",
            NotificationKind::Info                => "ℹ",
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The design split that matters: known contacts and unknown contacts are separate notification streams. If Alice is already in my contacts, her incoming request gets the 👤 icon and goes into the "known" queue. A stranger — someone whose identity key I don't have — gets the 👻 icon and goes into the "unknown" queue.

I show them both in the status bar like this: 👤2 👻1. The user can press r to review known requests or z for unknown ones. Separating the queues means you can blanket-reject unknowns without missing a friend's reconnect attempt.

The command console

I wanted a way to do things that weren't frequent enough to need a keyboard shortcut but frequent enough that clicking through menus would be annoying. The answer: a Doom-style command console.

Press / or Ctrl+P anywhere in the app. A dark overlay drops down from the top. You type a command. Press Enter. The console runs it and shows output. Press Escape to close.

The commands available:

/quit         (q, exit)    Quit application
/close        (c)          Close active tab
/status       (s)          Show session status
/clear                     Clear messages or output
/requests     (r)          Show pending chat requests
/notifications (n)         Show notification count
/help         (h, ?)       Show commands; /help keys shows shortcuts
/debug        (d)          Show debug info
/myonion      (me)         Show your .onion address
/who <name>                Show contact's .onion address
Enter fullscreen mode Exit fullscreen mode

The console has command history (up/down arrows) and output scrolling. Critically, everything in the console's state is zeroized on session end. Command history might contain onion addresses, debug output might contain status info — none of it should persist past the session lifetime.

impl Drop for ConsoleSession {
    fn drop(&mut self) {
        // Zeroize input history, output buffer, command history
        for entry in self.input_history.drain(..) {
            entry.zeroize();
        }
        for entry in self.output_buffer.drain(..) {
            entry.zeroize();
        }
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

I wrote this pattern once for ChatSession (from Post 4) and it felt heavy. Writing it a second time for the console made me realize it should be a trait or a macro. Future refactor.

Keyboard-first design

The TUI is keyboard-only. Not mouse-averse — keyboard-only. Every action has a shortcut, and you can reach every state without touching the mouse. Some highlights:

  • Tab / Shift+Tab — cycle between sidebar, tabs, input.
  • Ctrl+Left/Right or Alt+Left/Right — move between tabs.
  • Alt+1 through Alt+9 — jump directly to a specific tab.
  • Ctrl+W — close the active tab.
  • Ctrl+Q — quit the application.
  • Inside the sidebar: j/k for up/down (Vim users), + to add a contact, e for an ephemeral chat, r/z for request queues, n/N for notifications.

The thing that changed my mind about keyboard design in TUIs: status bar hints that update based on focus. When the sidebar has focus, the status bar shows ↑↓: nav | Enter: open | +: add. When the input box has focus, it shows Enter: send | PageUp: scroll. The user never has to remember what's available — the UI tells them what's applicable right now.

This is more work than static hints but it's the difference between "this TUI is usable after you read the manual" and "this TUI is usable as you explore it." Prioritize the second.

Concurrent sessions

Multi-contact means I needed to run multiple chat sessions simultaneously in the same process. Each session has its own Tor circuits, its own ratcheted key state, its own message queue. The state looks roughly like this:

pub struct MultiApp {
    // One entry per open conversation
    active_sessions: HashMap<String, ActiveSession>,

    // Incoming requests waiting for accept/reject
    pending_connections: Vec<PendingConnection>,

    // Our shared Tor hidden service (one per app, not per session)
    listener: Option<RunningOnionService>,

    contacts: Vec<Contact>,
    notifications: Vec<Notification>,

    focused_panel: FocusedPanel,
    // ... plus UI state ...
}
Enter fullscreen mode Exit fullscreen mode

A single hidden service receives all incoming connections. When one arrives, the handshake code inspects the identity key and figures out which existing ActiveSession it belongs to — or spawns a new PendingConnection if it's a stranger.

The trickiest bug in this whole area was a race condition where two concurrent /connect commands to the same contact would both get through handshake, creating two sessions with stale key state. The fix: sessions are keyed by contact name, and the HashMap::insert atomicity is enforced by holding the app's &mut self for the duration of the setup. You cannot start two concurrent connections to the same contact because the type system prevents it.

That's a case where Rust's ownership model earned its keep directly. In a language with shared mutable state, you'd need a lock or a state machine. In Rust, the &mut self is the lock.

What I'd do differently

Terminal resize handling was harder than I expected. Ratatui handles the redraw, but computing layouts that degrade gracefully on 80x24 terminals (without losing the sidebar) took several iterations. If I started over I'd prototype the narrow-terminal layout first and scale up, not the other way around.

The notification queue is just a Vec. This was fine for small volumes but doesn't scale. A bounded ring buffer with age-based expiry would be better, especially for the 👻 (stranger) queue in cases where you're being probed.

Themes. The TUI is hardcoded to the cyan/dark navy palette that matches the rest of the Anyhide branding. I want a light theme and a "classic terminal" (amber-on-black) theme, but I punted because color theming in ratatui is per-widget and doing it "right" would mean a theme trait pervading the app. On the list.

Ratatui notes

A few quick observations for anyone starting a ratatui project:

  • Use the List, Tabs, and Paragraph widgets directly before reaching for custom widgets. They cover 80% of what a chat app needs. I wrote a custom scrollbar and then noticed ratatui had one — delete, replace, tests still pass.
  • State structs are yours to design. Ratatui doesn't impose an architecture. I have an App struct that holds all state and render functions that take &App. The event loop mutates App. This is classic "The Elm Architecture" shape and it works fine in imperative Rust.
  • crossterm is the sibling crate for input events. It's not as ergonomic as ratatui itself — the KeyCode matching is verbose — but it's reliable and works everywhere.

Closing out the series

This series covered:

  1. The core idea: never transmit the carrier, only a short encrypted code.
  2. Multi-carrier encoding: N files as an ordered secret, N! additional combinations.
  3. Duress passwords: two messages, one ciphertext, cryptographic deniability.
  4. Forward secrecy: Double Ratchet, HKDF chains, three storage formats.
  5. P2P chat over Tor: arti-client, hidden services, bidirectional racing.
  6. This post: the TUI that ties it together.

Six posts, one codebase, 16,000 lines of Rust and 319 tests. I learned a lot writing this tool and a lot writing about writing this tool. If you made it all the way through, thank you. If there's a topic you'd like me to dig deeper into for a follow-up, let me know — I have ideas for a Part 2 series on things I left out (message signing, QR codes, the BIP39 mnemonic backup, the self-update mechanism), but I'd rather follow interest than my own biases.

Repo: github.com/matutetandil/anyhide. Issues, PRs, and honest critique all welcome.


That's all six. If you've been following along, thanks for reading. If you just arrived at this post and want to start at the beginning, Post 1 is here.

Top comments (0)