Input Routing, the Prefix Key, and Copy Mode

A multiplexer sits between your keyboard and the program. That means it must parse your input before forwarding it — to notice the prefix key, to intercept commands, and to implement copy mode.

This is the layer that makes a multiplexer feel different from a terminal, and it is where the "how does a multiplexer parse its own command prefix?" question gets its answer.


Why the Multiplexer Must Parse Input First

   WITHOUT interception:
     every byte goes straight to the pane's PTY.
     There is no way to say "split this window" — any key you press is data
     for the program. A mux with no command channel is not a mux.

   THE SOLUTION: a PREFIX key.
     One key (Ctrl+B by default) is stolen from the program. Everything after
     it, until one command key, is the mux's.

   THE COST:
     The program can never receive that key... unless you provide an escape.
     `prefix prefix` sends one literal prefix byte. That escape hatch is
     mandatory, and forgetting it is a real bug: emacs users need Ctrl+B.

The Prefix State Machine

                    ┌──────────────────────────────────┐
                    │           NORMAL                  │
                    │  every byte → forward to the pane │
                    └───────────┬──────────────────────┘
                       prefix key│
                    ┌────────────▼─────────────────────┐
                    │           PREFIX                  │
                    │  waiting for a command key        │
                    │  (with a TIMEOUT — see below)     │
                    └──┬────────────┬──────────────┬────┘
        prefix again   │   a known  │    anything  │
        → send ONE     │   command  │    else      │
          literal      │   → run it │    → discard │
          prefix byte  │            │      + beep  │
                    ┌──▼────────────▼──────────────▼────┐
                    │           NORMAL                  │
                    └───────────────────────────────────┘

                    Special: some commands enter COPY MODE instead of
                    returning to NORMAL.
#![allow(unused)]
fn main() {
pub enum InputState {
    Normal,
    /// Waiting for a command key. `since` drives the timeout.
    Prefix { since: Instant },
    /// Copy mode owns ALL input until it exits.
    Copy(CopyModeState),
}

impl Client {
    fn on_key(&mut self, key: Key, mods: Modifiers) {
        match &mut self.state {
            InputState::Normal => {
                if self.is_prefix(key, mods) {
                    self.state = InputState::Prefix { since: Instant::now() };
                    return;                      // send NOTHING yet
                }
                // Ordinary input: encode and forward.
                if let Some(bytes) = encode_key(key, mods, &self.pane_modes) {
                    self.send(Request::Input { bytes: bytes.into() });
                }
            }

            InputState::Prefix { since } => {
                // A timeout keeps a stray prefix from swallowing the next
                // keystroke forever. tmux's `escape-time` is the same idea.
                if since.elapsed() > self.prefix_timeout {
                    self.state = InputState::Normal;
                    return self.on_key(key, mods);      // reprocess as normal
                }
                let st = std::mem::replace(&mut self.state, InputState::Normal);
                let _ = st;

                // THE ESCAPE HATCH. Without it, the program can never receive
                // the prefix key at all — and emacs users will file a bug.
                if self.is_prefix(key, mods) {
                    if let Some(bytes) = encode_key(key, mods, &self.pane_modes) {
                        self.send(Request::Input { bytes: bytes.into() });
                    }
                    return;
                }

                match self.lookup_command(key, mods) {
                    Some(cmd) => self.run_command(cmd),
                    // Unknown command: discard and beep. Forwarding it would be
                    // worse — the user meant a command, not data.
                    None => self.bell(),
                }
            }

            InputState::Copy(_) => self.copy_mode_key(key, mods),
        }
    }
}
}

The Command Table

#![allow(unused)]
fn main() {
fn default_bindings() -> HashMap<(Key, Modifiers), Command> {
    use Command::*;
    HashMap::from([
        // Panes
        ((Key::Char('%'), NONE), SplitPane(SplitDirection::Vertical)),
        ((Key::Char('"'), NONE), SplitPane(SplitDirection::Horizontal)),
        ((Key::Char('o'), NONE), FocusNextPane),
        ((Key::Char('x'), NONE), ClosePaneWithConfirm),
        ((Key::Char('z'), NONE), ToggleZoom),
        ((Key::Named(ArrowLeft), NONE), FocusDirection(Direction::Left)),
        ((Key::Named(ArrowRight), NONE), FocusDirection(Direction::Right)),
        ((Key::Named(ArrowUp), NONE), FocusDirection(Direction::Up)),
        ((Key::Named(ArrowDown), NONE), FocusDirection(Direction::Down)),
        ((Key::Named(ArrowLeft), CTRL), ResizePane(Direction::Left, 5)),
        // Windows
        ((Key::Char('c'), NONE), NewWindow),
        ((Key::Char('n'), NONE), NextWindow),
        ((Key::Char('p'), NONE), PrevWindow),
        ((Key::Char('0'), NONE), SelectWindow(0)),
        ((Key::Char(','), NONE), RenameWindow),
        ((Key::Char('w'), NONE), ChooseWindow),
        // Sessions
        ((Key::Char('d'), NONE), Detach),
        ((Key::Char('s'), NONE), ChooseSession),
        ((Key::Char('$'), NONE), RenameSession),
        // Copy mode
        ((Key::Char('['), NONE), EnterCopyMode),
        ((Key::Char(']'), NONE), PasteBuffer),
        // Misc
        ((Key::Char('?'), NONE), ShowKeybindings),
        ((Key::Char(':'), NONE), CommandPrompt),
    ])
}
}

Where commands execute:

CommandRuns inWhy
SplitPane, ClosePane, NewWindowServerIt owns the tree and the PTYs
FocusPane, SelectWindowServerFocus is session state, shared by clients
DetachClient, then serverThe client closes its socket; the server cleans up
EnterCopyModeClientIt is a pure view/interaction mode, and it must be instant
PasteBufferClient sends the text; server applies bracketed pasteThe server knows the pane's modes
ShowKeybindings, CommandPromptClientUI

Tip: Commands that run in the client feel instant; commands that round-trip to the server have one RTT of latency. Over a Unix socket that is microseconds, so it does not matter — but if you ever tunnel the protocol over a network, the split suddenly matters a great deal. Design it now.


The Prefix Timeout and the Escape Problem

   The user presses Ctrl+B and then goes to lunch.

   WITHOUT a timeout: the next key they press — hours later — is eaten as a
   command. Baffling.

   WITH a timeout (say 1 second): the prefix state lapses and normal input
   resumes.

A second, subtler timeout: ESC versus an escape sequence. When the user presses Escape, your client receives 0x1b. When they press Up, it receives 0x1b 0x5b 0x41. If your client is a terminal-based client reading raw bytes (rather than a GUI with real key events), it faces the same ambiguity every terminal program does, and needs the same fix:

#![allow(unused)]
fn main() {
/// A terminal-based client reads BYTES, not key events, so it must reconstruct
/// keys — including the Escape ambiguity. tmux's `escape-time` option exists
/// for exactly this, and its default (500ms) is a notorious source of "vim feels
/// laggy in tmux" complaints. 25-50ms is the modern recommendation.
const ESCAPE_TIMEOUT: Duration = Duration::from_millis(25);
}

Note: A GUI client (your Section 3 frontend) does not have this problem — it gets real key events from the windowing system. That is a genuine architectural advantage of a graphical mux client, and worth noting in your write-up.


Copy Mode

Copy mode is where the multiplexer stops being transparent. Normal input goes to the program; copy mode input goes to the multiplexer so you can move a cursor through scrollback, select, and copy.

   NORMAL MODE                          COPY MODE
   ───────────                          ─────────
   Input → the pane's PTY               Input → the mux's copy-mode handler
   The view follows the live screen     The view is a scrollable window over
                                          the pane's scrollback + screen
   The cursor is the PROGRAM's cursor   The cursor is the MUX's selection cursor
   Output updates the screen            Output is buffered; the view does NOT jump
#![allow(unused)]
fn main() {
pub struct CopyModeState {
    /// A position in the pane's ABSOLUTE (scrollback-inclusive) coordinates,
    /// so it does not slide when new output arrives.
    cursor: AbsolutePoint,
    /// Where the selection started, if selecting.
    anchor: Option<AbsolutePoint>,
    mode: SelectionMode,
    /// How far up from the live bottom the view is scrolled.
    view_offset: usize,
    search: Option<SearchState>,
}

fn copy_mode_key(&mut self, key: Key, mods: Modifiers) {
    let InputState::Copy(st) = &mut self.state else { return };
    match (key, mods) {
        // Movement (vi bindings; offer emacs bindings as an option).
        (Key::Char('h'), NONE) | (Key::Named(ArrowLeft), NONE) => st.move_left(1),
        (Key::Char('l'), NONE) | (Key::Named(ArrowRight), NONE) => st.move_right(1),
        (Key::Char('k'), NONE) | (Key::Named(ArrowUp), NONE) => st.move_up(1),
        (Key::Char('j'), NONE) | (Key::Named(ArrowDown), NONE) => st.move_down(1),
        (Key::Char('w'), NONE) => st.next_word(),
        (Key::Char('b'), NONE) => st.prev_word(),
        (Key::Char('0'), NONE) => st.line_start(),
        (Key::Char('$'), NONE) => st.line_end(),
        (Key::Char('g'), NONE) => st.top_of_scrollback(),
        (Key::Char('G'), NONE) => st.bottom(),
        (Key::Char('u'), CTRL) => st.page_up(),
        (Key::Char('d'), CTRL) => st.page_down(),

        // Selection
        (Key::Named(Space), NONE) | (Key::Char('v'), NONE) => st.begin_selection(),
        (Key::Char('V'), NONE) => st.begin_line_selection(),
        (Key::Char('v'), CTRL) => st.begin_block_selection(),

        // Search
        (Key::Char('/'), NONE) => st.begin_search(SearchDirection::Backward),
        (Key::Char('?'), NONE) => st.begin_search(SearchDirection::Forward),
        (Key::Char('n'), NONE) => st.search_next(),

        // Copy and exit
        (Key::Named(Enter), NONE) | (Key::Char('y'), NONE) => {
            let text = self.extract_selection(st);
            // Two destinations, and they are different things:
            //   • the MUX's own paste buffer (prefix ] pastes it)
            //   • the SYSTEM clipboard, which only the CLIENT can touch —
            //     the server has no display connection at all.
            self.send(Request::SetPasteBuffer { text: text.clone() });
            self.set_system_clipboard(&text);
            self.state = InputState::Normal;
        }
        (Key::Named(Escape), NONE) | (Key::Char('q'), NONE) => self.state = InputState::Normal,
        _ => {}
    }
    self.request_redraw();
}
}

The two clipboards

BufferLives inSet byPasted by
The mux paste bufferThe serverCopy modeprefix ] — works across clients and survives detach
The system clipboardThe OS, per displayThe clientCtrl+V in any application

The server cannot touch the system clipboard: it is a daemon with no display connection. Only the client can. That asymmetry is exactly why the SetClipboard event exists in the protocol — the server asks the client to do it.

Note: OSC 52 is the other route: the server emits an OSC 52 sequence, the client passes it to its outer terminal, and the outer terminal sets the clipboard. That is how tmux sets the clipboard over SSH. It works, and it inherits every OSC 52 security consideration from the OSC chapter.

Output while in copy mode

   The pane keeps producing output. Three choices:

   1. Keep parsing into the Terminal; the copy-mode VIEW does not follow.
      ← CORRECT. The state stays current; the user's reading position is stable.
   2. Stop parsing.  ✗ The child blocks in write() and the pane hangs.
   3. Parse and jump the view to the bottom.  ✗ The user cannot read anything.

   Show an indicator ("[3 new lines]") so the user knows output arrived.

Mouse in Copy Mode

   Normal mode:  mouse events → encoded and sent to the pane (if it enabled
                 mouse reporting), else used for local selection.
   Copy mode:    mouse events are the MUX's. Drag selects; the wheel scrolls;
                 double-click selects a word.

   Shift ALWAYS bypasses to local selection, in both modes, so the user can
   select text even in a program that grabbed the mouse. Without this you cannot
   copy from htop.

Experiment

CLAIM. The prefix key genuinely steals a key from the program, and the escape hatch genuinely returns it.

METHOD.

# 1. Inside tmux, run your Lab 1 byte inspector.
tmux new-session
cargo run -p raw-inspector

# 2. Press Ctrl+B.
#    → NOTHING appears. tmux ate it.

# 3. Press Ctrl+B twice.
#    → 02 appears ONCE. The escape hatch.

# 4. Press Ctrl+B then %.
#    → the window splits; nothing reaches the inspector.

# 5. Now measure the escape-time problem:
tmux set -g escape-time 500
#    In a pane, run vim, press Escape then immediately 'i'.
#    → laggy, or the 'i' is swallowed into a fake escape sequence.
tmux set -g escape-time 25
#    → responsive.

# 6. Copy mode:
#    prefix [   then move with hjkl, Space to select, Enter to copy
#    prefix ]   to paste

PREDICTION. Before step 3: how many bytes will the inspector show? Before step 5: what exactly goes wrong with a 500 ms escape time, and why does a GUI client not have the problem?


Test

#![allow(unused)]
fn main() {
#[test]
fn prefix_is_not_forwarded() {
    let mut c = TestClient::new();
    c.press(Key::Char('b'), CTRL);
    assert!(c.sent_input().is_empty(), "the prefix must not reach the pane");
    assert!(matches!(c.state(), InputState::Prefix { .. }));
}

#[test]
fn double_prefix_sends_one_literal() {
    // The escape hatch. Without it, emacs and readline users cannot use Ctrl+B.
    let mut c = TestClient::new();
    c.press(Key::Char('b'), CTRL);
    c.press(Key::Char('b'), CTRL);
    assert_eq!(c.sent_input(), vec![0x02]);
    assert!(matches!(c.state(), InputState::Normal));
}

#[test]
fn prefix_times_out() {
    let mut c = TestClient::with_prefix_timeout(Duration::from_millis(100));
    c.press(Key::Char('b'), CTRL);
    c.advance_clock(Duration::from_millis(200));
    c.press(Key::Char('x'), NONE);
    // After the timeout the key is ordinary input, NOT the close-pane command.
    assert_eq!(c.sent_input(), b"x");
    assert!(c.commands_run().is_empty());
}

#[test]
fn unknown_prefix_command_is_discarded_not_forwarded() {
    let mut c = TestClient::new();
    c.press(Key::Char('b'), CTRL);
    c.press(Key::Char('~'), NONE);
    assert!(c.sent_input().is_empty(), "the user meant a command, not data");
    assert!(c.bell_rang());
}

#[test]
fn copy_mode_captures_all_input() {
    let mut c = TestClient::new();
    c.enter_copy_mode();
    c.press(Key::Char('j'), NONE);
    c.press(Key::Char('k'), NONE);
    assert!(c.sent_input().is_empty(), "copy mode must not forward keys");
}

#[test]
fn copy_mode_view_does_not_jump_on_new_output() {
    let mut c = TestClient::new();
    c.enter_copy_mode();
    c.scroll_up(20);
    let view = c.view_offset();
    c.receive_pane_output(b"new line\n");
    assert_eq!(c.view_offset(), view, "the user's reading position must be stable");
    assert!(c.has_new_output_indicator());
}

#[test]
fn shift_bypasses_mouse_reporting_in_both_modes() {
    let mut c = TestClient::new();
    c.set_pane_modes(Mode::MOUSE_NORMAL | Mode::MOUSE_SGR);
    c.mouse_press(10, 5, SHIFT);
    assert!(c.sent_input().is_empty(), "Shift must select locally, not report");
    assert!(c.has_local_selection());
}
}

Challenge Extensions

  1. A command prompt (prefix :) that accepts typed commands like split-window -h, with tab completion.
  2. Fully configurable bindings from a config file, including a send-keys action that transmits literal bytes.
  3. Search in copy mode, with incremental highlighting and n/N navigation.
  4. Multiple named paste buffers, with a chooser.
  5. A vi/emacs binding mode switch, and a test that both tables cover the same command set.
  6. Measure prefix latency end to end: keystroke → command executed → screen updated. Compare a client-side command against a server round trip.

Validation / Self-check

  1. Why must the multiplexer parse input before forwarding it?
  2. Draw the prefix state machine, including the timeout and the escape hatch.
  3. Why is prefix prefix mandatory? Who breaks without it?
  4. Why is the prefix parsed in the client rather than the server?
  5. Which commands run in the client and which in the server? What decides?
  6. What is escape-time, why does it exist, and why does a GUI client not need it?
  7. What changes about input handling in copy mode?
  8. Why must the copy-mode view not jump when new output arrives, and what must still happen to that output?
  9. Name the two clipboards, who owns each, and why the server cannot touch the system one.
  10. Why does Shift bypass mouse reporting, and in which modes?

Next: Lab 15 — Multiple Sessions, and later Lab 19 — Copy Mode.