Lab 19: Copy Mode
Background
Copy mode is where the multiplexer stops being transparent. In normal mode every keystroke goes to the pane's program. In copy mode the multiplexer keeps them, so you can move a cursor through scrollback, search, select text, and copy — none of which the program knows about.
It is the feature that makes a multiplexer usable over SSH, where your terminal's own scrollback and selection are unavailable because everything is one PTY stream.
Why This Lab Matters
- It is the clearest demonstration of input interception: the mux owning keys the program would otherwise receive.
- It forces the scrollback and selection models built in Lab 14 to work without a mouse and without a GUI, which is a harder constraint.
- The two-clipboards problem (mux paste buffer vs. system clipboard) is where the client/server split gets sharp.
Prerequisites
- Lab 18 complete.
- Input routing and copy mode read.
- Lab 14's selection model — you will
reuse
selected_textandAbsolutePointunchanged.
Predict First
- You are in copy mode, scrolled up 50 lines, and the pane produces output. Where should the view go?
- Copy mode is per-client or per-session? What breaks with the other choice?
- You copy text. Which of the two clipboards can the server write to?
- You are in copy mode and the pane's program is
vim. Doesvimknow?
Step 1: The State
Copy mode lives in the client. It is a pure view/interaction mode over data the client already has, and putting it in the server would add a round trip to every cursor movement.
#![allow(unused)] fn main() { pub struct CopyModeState { /// The selection cursor, in ABSOLUTE (scrollback-inclusive) coordinates. /// Screen coordinates would make it slide when new output arrives, which is /// exactly what the user does not want while reading. cursor: AbsolutePoint, /// Where the selection started. None = moving without selecting. anchor: Option<AbsolutePoint>, mode: SelectionMode, /// Lines scrolled up from the live bottom. view_offset: usize, search: Option<SearchState>, /// Output arrived while we were reading. Shown as an indicator, NOT acted on. new_output_lines: usize, } pub struct SearchState { pattern: String, direction: SearchDirection, /// Every match, so n/N navigation is instant and highlighting is free. matches: Vec<AbsoluteRange>, current: usize, /// Incremental: we re-search as the user types. incremental: bool, } }
Note: Copy mode is per-client, not per-session. Two people attached to one session must be able to read different parts of the scrollback simultaneously. Focus is session state; view state is not. Getting this backwards produces the worst possible multi-user experience: one person scrolling drags the other's screen.
Step 2: Entering and Leaving
#![allow(unused)] fn main() { fn enter_copy_mode(&mut self) { let pane = self.focused_pane(); // Start the cursor where the program's cursor is — that is where the user // was looking. Starting at the top of scrollback is disorienting. let start = self.pane_cursor_absolute(pane); self.state = InputState::Copy(CopyModeState { cursor: start, anchor: None, mode: SelectionMode::Normal, view_offset: 0, search: None, new_output_lines: 0, }); // The client renders its OWN cursor now, and must hide the program's — // otherwise two cursors are visible and neither is obviously "yours". self.show_copy_mode_indicator = true; self.request_redraw(); } fn exit_copy_mode(&mut self) { self.state = InputState::Normal; self.show_copy_mode_indicator = false; // Snap back to the live view. A user who leaves copy mode wants to type, // and typing into a view you cannot see is hostile. self.scroll_to_bottom(); self.request_redraw(); } }
Step 3: Movement
#![allow(unused)] fn main() { fn copy_mode_key(&mut self, key: Key, mods: Modifiers) { let InputState::Copy(st) = &mut self.state else { return }; // Search input captures everything until Enter or Escape. if let Some(search) = &mut st.search { if search.incremental { return self.search_input_key(key, mods); } } match (key, mods) { // ── Movement (vi bindings; offer emacs as a config option) ────── (Char('h'), NONE) | (Named(ArrowLeft), NONE) => self.move_cursor(-1, 0), (Char('l'), NONE) | (Named(ArrowRight), NONE) => self.move_cursor(1, 0), (Char('k'), NONE) | (Named(ArrowUp), NONE) => self.move_cursor(0, -1), (Char('j'), NONE) | (Named(ArrowDown), NONE) => self.move_cursor(0, 1), (Char('w'), NONE) => self.move_word_forward(), (Char('b'), NONE) => self.move_word_back(), (Char('e'), NONE) => self.move_word_end(), (Char('0'), NONE) => self.move_line_start(), (Char('^'), NONE) => self.move_first_nonblank(), (Char('$'), NONE) => self.move_line_end(), (Char('g'), NONE) => self.move_to_top_of_scrollback(), (Char('G'), NONE) => self.move_to_bottom(), (Char('u'), CTRL) => self.page(-(self.rows() as i64 / 2)), (Char('d'), CTRL) => self.page(self.rows() as i64 / 2), (Char('b'), CTRL) => self.page(-(self.rows() as i64)), (Char('f'), CTRL) => self.page(self.rows() as i64), (Char('H'), NONE) => self.move_to_view_top(), (Char('M'), NONE) => self.move_to_view_middle(), (Char('L'), NONE) => self.move_to_view_bottom(), // ── Selection ─────────────────────────────────────────────────── (Named(Space), NONE) | (Char('v'), NONE) => self.begin_selection(SelectionMode::Normal), (Char('V'), NONE) => self.begin_selection(SelectionMode::Line), (Char('v'), CTRL) => self.begin_selection(SelectionMode::Block), // ── Search ────────────────────────────────────────────────────── (Char('/'), NONE) => self.begin_search(SearchDirection::Backward), (Char('?'), NONE) => self.begin_search(SearchDirection::Forward), (Char('n'), NONE) => self.search_next(), (Char('N'), NONE) => self.search_prev(), // ── Copy and exit ─────────────────────────────────────────────── (Named(Enter), NONE) | (Char('y'), NONE) => self.copy_and_exit(), (Named(Escape), NONE) | (Char('q'), NONE) => self.exit_copy_mode(), _ => {} } self.request_redraw(); } /// Move by (dx, dy), keeping the cursor on the visible view by scrolling. /// Absolute coordinates make "scroll to follow the cursor" the only rule needed. fn move_cursor(&mut self, dx: i64, dy: i64) { let InputState::Copy(st) = &mut self.state else { return }; let total = self.total_lines(); // scrollback + screen st.cursor.line = (st.cursor.line as i64 + dy).clamp(0, total as i64 - 1) as u64; let width = self.line_len(st.cursor.line); st.cursor.col = (st.cursor.col as i64 + dx).clamp(0, width as i64) as usize; self.scroll_view_to_include(st.cursor); } }
Step 4: Search
#![allow(unused)] fn main() { fn begin_search(&mut self, direction: SearchDirection) { let InputState::Copy(st) = &mut self.state else { return }; st.search = Some(SearchState { pattern: String::new(), direction, matches: Vec::new(), current: 0, incremental: true, }); self.show_search_prompt = true; } fn search_input_key(&mut self, key: Key, mods: Modifiers) { let InputState::Copy(st) = &mut self.state else { return }; let Some(search) = &mut st.search else { return }; match (key, mods) { (Named(Enter), NONE) => { search.incremental = false; self.show_search_prompt = false; } (Named(Escape), NONE) => { st.search = None; self.show_search_prompt = false; } (Named(Backspace), NONE) => { search.pattern.pop(); self.rerun_search(); } (Char(c), _) => { search.pattern.push(c); self.rerun_search(); } _ => {} } } /// Search the whole buffer, once, and keep every match. n/N then costs nothing, /// and highlighting is a lookup rather than a re-scan per frame. fn rerun_search(&mut self) { let InputState::Copy(st) = &mut self.state else { return }; let Some(search) = &mut st.search else { return }; if search.pattern.is_empty() { search.matches.clear(); return; } search.matches.clear(); // Bound the scan: 100k lines × a naive substring search per keystroke is // visibly slow. Cap the range, or debounce, and SAY which you chose. let total = self.total_lines(); let scan_from = total.saturating_sub(MAX_SEARCH_LINES); for line_no in scan_from..total { // Search the LOGICAL line, so a match spanning a wrap is found. This is // the same `wrapped` flag from Lab 7, earning its keep again. let text = self.logical_line_text(line_no); for (offset, _) in text.match_indices(&search.pattern) { search.matches.push(self.offset_to_range(line_no, offset, search.pattern.len())); } } // Jump to the nearest match in the search direction. self.search_next(); } }
Step 5: Copy, and the Two Clipboards
#![allow(unused)] fn main() { fn copy_and_exit(&mut self) { let InputState::Copy(st) = &self.state else { return }; let Some(anchor) = st.anchor else { return self.exit_copy_mode(); }; let sel = Selection { mode: st.mode, start: anchor, end: st.cursor }; // Reuse Lab 14's extraction UNCHANGED: wrapped lines join, trailing padding // is trimmed, wide characters are emitted once. If it needs changing here, // Lab 14's version was doing something GUI-specific it should not have. let text = self.extract_selection(&sel); if text.is_empty() { return self.exit_copy_mode(); } // TWO destinations, and they are genuinely different things: // 1. The MUX PASTE BUFFER — server-side. Survives detach, shared across // clients, pasted with `prefix ]`. self.send(Request::SetPasteBuffer { text: text.clone() }); // 2. The SYSTEM CLIPBOARD — client-side ONLY. The server is a daemon with // no display connection; it physically cannot do this. self.set_system_clipboard(&text); self.exit_copy_mode(); } fn set_system_clipboard(&mut self, text: &str) { match self.clipboard_backend { // A GUI client can talk to the OS clipboard directly. ClipboardBackend::Native(ref mut cb) => cb.set_text(text), // A TERMINAL client cannot — it has no display connection either. It // asks its OUTER terminal via OSC 52, which is how tmux does it over // SSH. Inherits every OSC 52 consideration: size caps, and the fact // that the outer terminal may refuse. ClipboardBackend::Osc52 => { let b64 = base64_encode(text.as_bytes()); if b64.len() > MAX_OSC52_LEN { return; } // silently dropping is better than truncating self.write_terminal(format!("\x1b]52;c;{b64}\x1b\\").as_bytes()); } ClipboardBackend::None => {} } } }
Warning: The OSC 52 path is the only way a terminal-based client can set the system clipboard, and it depends entirely on the outer terminal honoring it. Many terminals allow OSC 52 writes by default and deny reads, which is exactly the policy you implemented in Lab 11. Your client is now on the other side of that decision. If the copy silently does nothing, the outer terminal refused — say so in the status line rather than failing quietly.
Step 6: Output While in Copy Mode
The rule that makes copy mode usable:
The pane keeps producing output. THREE options:
1. Keep parsing; the VIEW does not move. ← CORRECT
State stays current, the reading position is stable, and an indicator
tells the user output arrived.
2. Stop parsing. ✗
The PTY buffer fills, the child blocks in write(), and the pane hangs.
3. Parse and jump the view to the bottom. ✗
The user cannot read anything. This is the behavior people complain
about in terminals that get it wrong.
#![allow(unused)] fn main() { fn on_pane_output(&mut self, pane: PaneId, rows: Vec<(usize, RowSnapshot)>) { // The server has ALREADY parsed this into pane state — that never stops. self.apply_rows(pane, rows); if let InputState::Copy(st) = &mut self.state { if pane == self.focused_pane_id() { // Do NOT move the view. Count, and show an indicator. st.new_output_lines += 1; self.status_line = format!("[copy mode] {} new lines", st.new_output_lines); } } else { self.scroll_to_bottom(); } self.request_redraw(); } }
Expected Output
┌─ 0: bash ───────────────────────────────────────────────────────┐
│ $ cargo build │
│ Compiling terminal-core v0.1.0 │
│ Compiling terminal-gui v0.1.0 │
│ error[E0308]: mismatched types │
│ --> crates/terminal-gui/src/render.rs:142:9 │
│ ^^^^^^^^^ expected `usize`, found `u16` ← selection │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
│ $ │
└──────────────────────────────────────────────────────────────────┘
[copy mode] line 1847/2103 VISUAL /error (3 matches, 2/3) [12 new lines]
prefix [ enter hjkl move v select / search
y/Enter copy+exit w/b word V line n next
q/Escape exit g/G top/bottom ^V block ^u/^d page
Debugging Steps
Keys reach the program in copy mode
The InputState::Copy branch is not capturing. Every key must be handled or discarded — none may
fall through to Request::Input.
The view jumps when output arrives
You are calling scroll_to_bottom() unconditionally. Guard it on not being in copy mode.
The cursor slides when output arrives
Screen coordinates instead of absolute. Same bug as Lab 14, same fix.
Search misses matches that span a wrap
You are searching physical lines. Search the logical line, joined via the wrapped flag.
Copy produces text with newlines inside a wrapped path
extract_selection is not joining wrapped lines. If Lab 14's version worked and this one does not,
you reimplemented it — do not.
Two clients scroll together
Copy mode is per-session instead of per-client.
The copy silently does nothing on a terminal client
The outer terminal refused OSC 52, or the payload exceeded its cap. Report it in the status line.
Search is visibly slow while typing
You are scanning 100k lines per keystroke. Bound the range or debounce, and document which.
Experiment
CLAIM. Copy mode is input interception, and the program genuinely cannot tell it is happening.
METHOD.
mini-mux server & mini-mux attach
# In the pane, run your Lab 1 byte inspector:
cargo run -p raw-inspector
# 1. Type some keys — they appear in the inspector.
# 2. <prefix> [ to enter copy mode.
# 3. Press hjkl, v, y — the inspector shows NOTHING.
# 4. Press q to leave.
# 5. Type again — the inspector resumes.
Then check the other side:
# In another window, watch the pane's PTY master traffic:
mini-mux server --log-protocol /tmp/proto.jsonl
grep -c '"Input"' /tmp/proto.jsonl # before and after a copy-mode session
PREDICTION. Before step 3: does the inspector show anything while you move around in copy mode? Does the pane's program receive a single byte? Does the server even hear about the keystrokes?
RESULT. The answer to all three is no — the client handles them entirely, and the server is not involved. That is why copy mode is client-side: every movement would otherwise be a round trip.
Test
#![allow(unused)] fn main() { #[test] fn copy_mode_captures_every_key() { let mut c = TestClient::new(); c.enter_copy_mode(); for key in [Char('j'), Char('k'), Char('h'), Char('l'), Char('w'), Char('b'), Char('v'), Char('G')] { c.press(key, NONE); } assert!(c.sent_input().is_empty(), "no key may reach the pane in copy mode"); } #[test] fn the_view_does_not_move_when_output_arrives() { let mut c = TestClient::new(); c.enter_copy_mode(); c.scroll_up(20); let view = c.view_offset(); let cursor = c.copy_cursor(); c.receive_pane_output(b"new output line\n"); assert_eq!(c.view_offset(), view, "the reading position must be stable"); assert_eq!(c.copy_cursor(), cursor, "absolute coordinates must not slide"); assert!(c.status_line().contains("new"), "but the user must be told"); } #[test] fn output_is_still_parsed_while_in_copy_mode() { // The child must NOT block. Parsing never stops; only the view is frozen. let mut mux = TestMux::new(); let pane = mux.new_pane("bash"); let client = mux.attach_client(); mux.client_enter_copy_mode(client); mux.send_to_pane(pane, b"echo MARKER\n"); mux.pump(Duration::from_millis(500)); assert!(mux.pane_snapshot(pane).contains("MARKER")); } #[test] fn copy_mode_is_per_client() { // Two readers must be able to look at different history. let mut mux = TestMux::new(); let s = mux.new_session(); let a = mux.attach_client(s, Size::new(24, 80)); let b = mux.attach_client(s, Size::new(24, 80)); mux.client_enter_copy_mode(a); mux.client_scroll_up(a, 50); assert_eq!(mux.client_view_offset(b), 0, "client B must be unaffected"); assert!(!mux.client_in_copy_mode(b)); } #[test] fn selection_extraction_matches_lab_14_exactly() { // If copy mode needs its OWN extraction, Lab 14's was GUI-specific and // should not have been. let term = terminal_with(b"a-very-long-path/that/wraps/across/lines.txt\n"); let sel = Selection::all(&term); assert_eq!(copy_mode_extract(&term, &sel), selected_text(&term, &sel)); } #[test] fn search_finds_matches_spanning_a_wrap() { let mut t = Terminal::new(5, 10, cfg()); t.advance(b"abcdeNEEDLEfghij"); // "NEEDLE" straddles the wrap let matches = search_logical(&t, "NEEDLE"); assert_eq!(matches.len(), 1, "search must operate on logical lines"); } #[test] fn search_is_bounded() { let mut t = Terminal::new(24, 80, TerminalConfig { scrollback_limit: 500_000, ..cfg() }); for i in 0..500_000 { t.advance(format!("line {i}\n").as_bytes()); } let start = Instant::now(); let _ = search_logical(&t, "line 4999"); assert!(start.elapsed() < Duration::from_millis(100), "search took {:?} — bound the range or debounce", start.elapsed()); } #[test] fn exiting_copy_mode_returns_to_the_live_view() { let mut c = TestClient::new(); c.enter_copy_mode(); c.scroll_up(100); c.press(Named(Escape), NONE); assert_eq!(c.view_offset(), 0, "typing into an invisible view is hostile"); assert!(matches!(c.state(), InputState::Normal)); } }
Challenge Extensions
- Emacs bindings as a config option, with a test that both tables cover the same command set.
- Regex search with case-insensitivity and whole-word options; measure the cost against plain substring matching on 100k lines.
- Multiple named paste buffers with a chooser, server-side so they survive detach.
- Mouse support in copy mode: drag to select, wheel to scroll, double-click for a word — with Shift still bypassing to the outer terminal's own selection.
- Semantic selection using OSC 133 marks:
prefix [then a key that selects the entire output of the previous command. - A "copy to a file" command, useful for capturing long build output without a clipboard round trip.
- Highlight all search matches in the rendered view, not just the current one — and measure whether it costs a frame.
Deliverables
- Copy mode as a client-side state that captures every key.
-
vi-style movement:
hjkl, words, line ends,g/G, half- and full-page. - Character, line, and block selection.
-
Incremental search with
n/N, operating on logical lines and bounded in cost. - Copy to both the mux paste buffer and the system clipboard, with the OSC 52 path for terminal clients.
- The view frozen while output continues to be parsed, with a new-output indicator.
- Copy mode per-client, verified with two attached clients.
-
extract_selectionreused from Lab 14 unchanged. - All eight tests passing.
- The input-interception experiment, with predictions.
Validation / Self-check
- Why does copy mode live in the client rather than the server? What would the alternative cost?
- Why is it per-client while focus is per-session?
- What must happen to pane output while a client is in copy mode, and what breaks with each of the two wrong answers?
- Why absolute rather than screen coordinates for the copy cursor?
- Name the two clipboards, who can write to each, and why the server cannot touch one of them.
- How does a terminal client set the system clipboard, and what can go wrong?
- Why must search operate on logical lines rather than physical ones?
- Why must leaving copy mode return to the live view?
- If your copy-mode extraction differs from Lab 14's, what does that tell you about Lab 14's?
- A user reports that copy mode is laggy while a build runs. Name two possible causes.