Make the terminal usable: control keys, paste, scroll, restart, terminate

The largest defect was not the one the plan named. Every control character was
being dropped before it reached the PTY — ctrl-c, ctrl-d, ctrl-a, ctrl-r, not
just ctrl-k — because terminal_key_from_parts required a key_char and GPUI
reports none for a control chord, since ctrl-k produces no printable character.
Verified with `cat -v`, which now prints ^K^A^R; ctrl-c interrupts a sleep and
ctrl-d ends a heredoc. The engine had always encoded these correctly; nothing
ever handed them to it.

The binding shadowing was real too. OpenPalette was on secondary-k, which is
ctrl-k on Linux, and GPUI stops dispatching once a binding claims an event, so
readline's kill-line was unreachable in every pane. Pane selection sat on
alt-1..6, which readline reads as a digit argument, and focus movement on
alt-arrows, which is word motion in most terminals.

Bindings now live in keymap.rs with the rule written down and tested: no binding
may be a bare control character or a bare Meta sequence, because those are what
a terminal application actually receives. A leader chord was considered and
rejected — GPUI parks a chord prefix for a second and drops it if focus moves.

Also in this pass:

- Paste on secondary-shift-v, through the engine's bracketed-paste path so a
  shell that asked for bracketed paste is told this is a paste. secondary-v
  would have been ctrl-v, which readline reads as quoted-insert. There is no
  matching copy: the engine has no selection yet, and a key that copied the
  whole screen would not be the same feature under the same name.
- A scroll wheel on the terminal surface. Shift+PageUp was the only route to
  scrollback, which is not something anyone guesses.
- Restart and Terminate. RuntimeRegistry::shutdown existed and was called only
  from its own crate's tests, so nothing in the application could ever stop a
  PTY. Terminate is the literal words with a confirmation naming the pid, per
  decision 0010, never a close icon; restart keeps the pane and replaces the
  process, per decision 0011.
- A dead or faulted pane now says so over its stale screen instead of looking
  idle, and typing into a pane with no terminal explains where the keystroke
  went instead of silently discarding it.
- The twelve reachable .expect panics on live-terminal state are gone. A pane
  can outlive its runtime — failed spawn, terminate, restored snapshot — and
  every one of those paths used to be a panic in the middle of a paint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Metal Agent
2026-08-31 23:35:32 -07:00
co-authored by Claude Opus 5
parent 1556b87f37
commit d76da3babb
3 changed files with 552 additions and 113 deletions
+186
View File
@@ -0,0 +1,186 @@
//! Every key Lumbridge claims, and the rule for which keys it may claim.
//!
//! A terminal multiplexer is a program whose main job is to *not* intercept
//! keys. Everything it takes is taken from the shell, the editor, and the
//! reader running inside it. So the bindings live in one table with a test that
//! enforces the rule, rather than being scattered through the shell where the
//! next one gets added without anyone checking what it costs.
//!
//! **The rule: no binding may be a bare control character or a bare Meta
//! sequence.** Those are what a terminal application actually receives.
//!
//! - `ctrl-<letter>` is a C0 control byte. `ctrl-k` is kill-line, `ctrl-a`
//! start-of-line, `ctrl-r` reverse search, `ctrl-c` interrupt. Binding one at
//! the window level makes it unreachable in every pane, because GPUI stops
//! dispatching once a binding claims the event — `finish_dispatch_key_event`
//! never runs, so there is no fallthrough to the terminal.
//! - `alt-<key>` is an ESC-prefixed Meta sequence. `alt-f`/`alt-b` are word
//! motion, `alt-1` is readline's digit argument, and `alt-<arrow>` is word
//! motion in most terminals. tmux and zellij leave these alone too.
//!
//! What is left is `secondary-alt-…` (ctrl+alt on Linux, cmd+alt on macOS) and
//! `secondary-shift-…`, neither of which a terminal program can receive as a
//! distinct sequence anyway.
//!
//! A leader chord — tmux's `ctrl-b` — was considered and rejected: GPUI parks a
//! pending chord prefix for a full second and discards it if focus moves, so the
//! second key of a chord is unreliable in a multi-pane window.
use gpui::KeyBinding;
use crate::{
AddPanel, DetachSelectedPanel, FocusDown, FocusLeft, FocusRight, FocusUp, OpenPalette,
PasteIntoPane, RestartPane, SelectPane1, SelectPane2, SelectPane3, SelectPane4, SelectPane5,
SelectPane6, TerminalNarrower, TerminalShorter, TerminalTaller, TerminalWider, TerminatePane,
};
/// The context every binding is scoped to.
const CONTEXT: &str = "LumbridgeShell";
/// Every keystroke Lumbridge claims, as written for `KeyBinding::new`.
///
/// Kept as strings so the test below can inspect them. A binding that is not in
/// this table does not exist.
pub(crate) const BINDINGS: &[&str] = &[
"secondary-shift-p",
"secondary-alt-left",
"secondary-alt-right",
"secondary-alt-up",
"secondary-alt-down",
"secondary-alt-1",
"secondary-alt-2",
"secondary-alt-3",
"secondary-alt-4",
"secondary-alt-5",
"secondary-alt-6",
"secondary-alt-n",
"secondary-alt-w",
"secondary-alt-shift-up",
"secondary-alt-shift-down",
"secondary-alt-shift-right",
"secondary-alt-shift-left",
"secondary-alt-r",
"secondary-alt-x",
// The terminal convention, and the only safe spelling: `secondary-v` is
// ctrl-v on Linux, which readline reads as quoted-insert.
"secondary-shift-v",
];
/// Builds the bindings in the same order as [`BINDINGS`].
pub(crate) fn bindings() -> Vec<KeyBinding> {
vec![
// Was `secondary-k`, which on Linux is ctrl-k — readline's kill-line,
// unreachable in every pane for as long as that binding existed.
KeyBinding::new(BINDINGS[0], OpenPalette, Some(CONTEXT)),
// Was `alt-<arrow>`: word motion in most terminals.
KeyBinding::new(BINDINGS[1], FocusLeft, Some(CONTEXT)),
KeyBinding::new(BINDINGS[2], FocusRight, Some(CONTEXT)),
KeyBinding::new(BINDINGS[3], FocusUp, Some(CONTEXT)),
KeyBinding::new(BINDINGS[4], FocusDown, Some(CONTEXT)),
// Was `alt-1..6`: readline reads those as a digit argument.
KeyBinding::new(BINDINGS[5], SelectPane1, Some(CONTEXT)),
KeyBinding::new(BINDINGS[6], SelectPane2, Some(CONTEXT)),
KeyBinding::new(BINDINGS[7], SelectPane3, Some(CONTEXT)),
KeyBinding::new(BINDINGS[8], SelectPane4, Some(CONTEXT)),
KeyBinding::new(BINDINGS[9], SelectPane5, Some(CONTEXT)),
KeyBinding::new(BINDINGS[10], SelectPane6, Some(CONTEXT)),
KeyBinding::new(BINDINGS[11], AddPanel, Some(CONTEXT)),
KeyBinding::new(BINDINGS[12], DetachSelectedPanel, Some(CONTEXT)),
KeyBinding::new(BINDINGS[13], TerminalTaller, Some(CONTEXT)),
KeyBinding::new(BINDINGS[14], TerminalShorter, Some(CONTEXT)),
KeyBinding::new(BINDINGS[15], TerminalWider, Some(CONTEXT)),
KeyBinding::new(BINDINGS[16], TerminalNarrower, Some(CONTEXT)),
KeyBinding::new(BINDINGS[17], RestartPane, Some(CONTEXT)),
KeyBinding::new(BINDINGS[18], TerminatePane, Some(CONTEXT)),
KeyBinding::new(BINDINGS[19], PasteIntoPane, Some(CONTEXT)),
]
}
/// Splits a binding into its modifiers and its key.
#[cfg(test)]
fn parts(binding: &str) -> (Vec<&str>, &str) {
let mut segments: Vec<&str> = binding.split('-').collect();
// A trailing empty segment means the key itself is `-`.
let key = if segments.last() == Some(&"") {
segments.pop();
segments.pop();
"-"
} else {
segments.pop().unwrap_or("")
};
(segments, key)
}
#[cfg(test)]
mod tests {
use super::{BINDINGS, bindings, parts};
#[test]
fn no_binding_shadows_a_control_character() {
// `secondary` is ctrl on Linux. secondary+letter with no other modifier
// is a C0 control byte the terminal needs: ctrl-k kill-line, ctrl-a
// start-of-line, ctrl-r reverse-search, ctrl-w kill-word, ctrl-c.
for binding in BINDINGS {
let (modifiers, key) = parts(binding);
let bare_secondary = modifiers == ["secondary"];
assert!(
!(bare_secondary && key.len() == 1 && key.chars().all(|c| c.is_ascii_alphabetic())),
"{binding} shadows a control character the terminal needs"
);
}
}
#[test]
fn no_binding_shadows_a_meta_sequence() {
// A bare alt+key is ESC-prefixed Meta: readline word motion, digit
// arguments, and vim's own Meta bindings all arrive this way.
for binding in BINDINGS {
let (modifiers, _) = parts(binding);
assert_ne!(
modifiers,
["alt"],
"{binding} shadows an ESC-prefixed Meta sequence"
);
}
}
#[test]
fn no_binding_claims_a_key_a_terminal_cannot_live_without() {
// Even with modifiers, these must never be claimed at window level.
for binding in BINDINGS {
let (_, key) = parts(binding);
assert!(
!matches!(key, "escape" | "tab" | "enter" | "backspace"),
"{binding} claims {key}, which every terminal application needs"
);
}
}
#[test]
fn every_binding_is_unique() {
let mut seen = Vec::new();
for binding in BINDINGS {
assert!(!seen.contains(binding), "{binding} is bound twice");
seen.push(binding);
}
}
#[test]
fn the_table_and_the_builder_stay_in_step() {
assert_eq!(
bindings().len(),
BINDINGS.len(),
"every entry in BINDINGS must build exactly one binding"
);
}
#[test]
fn parts_splits_modifiers_from_the_key() {
assert_eq!(
parts("secondary-shift-p"),
(vec!["secondary", "shift"], "p")
);
assert_eq!(parts("alt-left"), (vec!["alt"], "left"));
assert_eq!(parts("escape"), (Vec::new(), "escape"));
}
}