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"));
}
}
+363 -112
View File
@@ -1,3 +1,4 @@
mod keymap;
mod panel_registry; mod panel_registry;
mod theme; mod theme;
mod usage_feed; mod usage_feed;
@@ -7,9 +8,8 @@ use std::path::PathBuf;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use gpui::{ use gpui::{
App, Application, Bounds, Context, FocusHandle, FontWeight, KeyBinding, KeyDownEvent, Pixels, App, Application, Bounds, Context, FocusHandle, FontWeight, KeyDownEvent, Pixels, Rgba, Size,
Rgba, Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, size,
size,
}; };
use lumbridge_core::UsageProvenance; use lumbridge_core::UsageProvenance;
use lumbridge_runtime::{ use lumbridge_runtime::{
@@ -56,6 +56,9 @@ actions!(
FocusDown, FocusDown,
OpenPalette, OpenPalette,
ClosePalette, ClosePalette,
RestartPane,
TerminatePane,
PasteIntoPane,
SelectPane1, SelectPane1,
SelectPane2, SelectPane2,
SelectPane3, SelectPane3,
@@ -88,6 +91,10 @@ struct LumbridgeShell {
/// inert by design: it prepares nothing and runs nothing. /// inert by design: it prepares nothing and runs nothing.
shelf_choice: BTreeMap<PanelId, usize>, shelf_choice: BTreeMap<PanelId, usize>,
add_panel_chooser_open: bool, add_panel_chooser_open: bool,
/// The pane a terminate has been asked for but not yet confirmed.
pending_terminate: Option<PanelId>,
/// Why the last keystroke went nowhere, if it did.
input_gap: Option<String>,
root_focus: FocusHandle, root_focus: FocusHandle,
} }
@@ -703,6 +710,8 @@ impl LumbridgeShell {
surfaces: BTreeMap::new(), surfaces: BTreeMap::new(),
shelf_choice: BTreeMap::new(), shelf_choice: BTreeMap::new(),
add_panel_chooser_open: false, add_panel_chooser_open: false,
pending_terminate: None,
input_gap: None,
root_focus, root_focus,
} }
} }
@@ -751,19 +760,17 @@ impl LumbridgeShell {
Ok(Some(event)) => event, Ok(Some(event)) => event,
Ok(None) => break, Ok(None) => break,
Err(RuntimeRegistryError::Actor(RuntimeActorError::Disconnected)) => { Err(RuntimeRegistryError::Actor(RuntimeActorError::Disconnected)) => {
self.live_terminals if let Some(terminal) = self.live_terminals.get_mut(&pane) {
.get_mut(&pane) terminal.status =
.expect("live terminal state exists") LiveRuntimeStatus::Fault("runtime actor disconnected".to_owned());
.status = }
LiveRuntimeStatus::Fault("runtime actor disconnected".to_owned());
changed = true; changed = true;
break; break;
} }
Err(error) => { Err(error) => {
self.live_terminals if let Some(terminal) = self.live_terminals.get_mut(&pane) {
.get_mut(&pane) terminal.status = LiveRuntimeStatus::Fault(error.to_string());
.expect("live terminal state exists") }
.status = LiveRuntimeStatus::Fault(error.to_string());
changed = true; changed = true;
break; break;
} }
@@ -774,23 +781,21 @@ impl LumbridgeShell {
session_id, session_id,
process_id, process_id,
} => { } => {
self.live_terminals if let Some(terminal) = self.live_terminals.get_mut(&pane) {
.get_mut(&pane) terminal.status = LiveRuntimeStatus::Running {
.expect("live terminal state exists") session_id: session_id.get(),
.status = LiveRuntimeStatus::Running { process_id,
session_id: session_id.get(), };
process_id, }
};
changed = true; changed = true;
} }
RuntimeEvent::Output { RuntimeEvent::Output {
sequence, bytes, .. sequence, bytes, ..
} => { } => {
let responses = { let responses = {
let terminal = self let Some(terminal) = self.live_terminals.get_mut(&pane) else {
.live_terminals break;
.get_mut(&pane) };
.expect("live terminal state exists");
if sequence <= terminal.last_runtime_sequence { if sequence <= terminal.last_runtime_sequence {
terminal.status = LiveRuntimeStatus::Fault(format!( terminal.status = LiveRuntimeStatus::Fault(format!(
"non-monotonic PTY output sequence {sequence}" "non-monotonic PTY output sequence {sequence}"
@@ -805,10 +810,9 @@ impl LumbridgeShell {
if let Err(error) = if let Err(error) =
self.send_runtime_command(pane, RuntimeCommand::Input(response)) self.send_runtime_command(pane, RuntimeCommand::Input(response))
{ {
self.live_terminals if let Some(terminal) = self.live_terminals.get_mut(&pane) {
.get_mut(&pane) terminal.status = LiveRuntimeStatus::Fault(error.to_string());
.expect("live terminal state exists") }
.status = LiveRuntimeStatus::Fault(error.to_string());
return true; return true;
} }
} }
@@ -817,23 +821,22 @@ impl LumbridgeShell {
} }
RuntimeEvent::InputClosed { .. } => {} RuntimeEvent::InputClosed { .. } => {}
RuntimeEvent::Exited { status, .. } => { RuntimeEvent::Exited { status, .. } => {
self.live_terminals if let Some(terminal) = self.live_terminals.get_mut(&pane) {
.get_mut(&pane) terminal.status = LiveRuntimeStatus::Exited(format!(
.expect("live terminal state exists") "PTY exited with code {}",
.status = LiveRuntimeStatus::Exited(format!( status.code
"PTY exited with code {}", ));
status.code }
));
changed = true; changed = true;
break; break;
} }
RuntimeEvent::Fault { RuntimeEvent::Fault {
operation, message, .. operation, message, ..
} => { } => {
self.live_terminals if let Some(terminal) = self.live_terminals.get_mut(&pane) {
.get_mut(&pane) terminal.status =
.expect("live terminal state exists") LiveRuntimeStatus::Fault(format!("{operation:?}: {message}"));
.status = LiveRuntimeStatus::Fault(format!("{operation:?}: {message}")); }
changed = true; changed = true;
break; break;
} }
@@ -853,10 +856,9 @@ impl LumbridgeShell {
fn publish_terminal_snapshot(&mut self, pane: PanelId) { fn publish_terminal_snapshot(&mut self, pane: PanelId) {
let lines = { let lines = {
let terminal = self let Some(terminal) = self.live_terminals.get_mut(&pane) else {
.live_terminals return;
.get_mut(&pane) };
.expect("live terminal state exists");
let snapshot = terminal.terminal.snapshot(); let snapshot = terminal.terminal.snapshot();
let lines = snapshot.plain_rows(); let lines = snapshot.plain_rows();
terminal.snapshot = snapshot; terminal.snapshot = snapshot;
@@ -875,20 +877,18 @@ impl LumbridgeShell {
fn resize_terminal(&mut self, pane: PanelId, rows: u16, columns: u16) -> bool { fn resize_terminal(&mut self, pane: PanelId, rows: u16, columns: u16) -> bool {
let dimensions = TerminalDimensions::new(rows, columns) let dimensions = TerminalDimensions::new(rows, columns)
.expect("resize actions always retain non-zero dimensions"); .expect("resize actions always retain non-zero dimensions");
let terminal = self let Some(terminal) = self.live_terminals.get_mut(&pane) else {
.live_terminals return false;
.get_mut(&pane) };
.expect("live terminal state exists");
if !terminal.terminal.resize(dimensions) { if !terminal.terminal.resize(dimensions) {
return false; return false;
} }
let pty_size = TerminalSize::new(rows, columns) let pty_size = TerminalSize::new(rows, columns)
.expect("terminal engine dimensions are valid PTY dimensions"); .expect("terminal engine dimensions are valid PTY dimensions");
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Resize(pty_size)) { if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Resize(pty_size))
self.live_terminals && let Some(terminal) = self.live_terminals.get_mut(&pane)
.get_mut(&pane) {
.expect("live terminal state exists") terminal.status = LiveRuntimeStatus::Fault(error.to_string());
.status = LiveRuntimeStatus::Fault(error.to_string());
} }
self.publish_terminal_snapshot(pane); self.publish_terminal_snapshot(pane);
true true
@@ -908,13 +908,10 @@ impl LumbridgeShell {
} }
fn scroll_terminal(&mut self, pane: PanelId, scroll: TerminalScroll, cx: &mut Context<Self>) { fn scroll_terminal(&mut self, pane: PanelId, scroll: TerminalScroll, cx: &mut Context<Self>) {
if !self let Some(terminal) = self.live_terminals.get_mut(&pane) else {
.live_terminals return;
.get_mut(&pane) };
.expect("live terminal state exists") if !terminal.terminal.scroll_display(scroll) {
.terminal
.scroll_display(scroll)
{
return; return;
} }
self.publish_terminal_snapshot(pane); self.publish_terminal_snapshot(pane);
@@ -1025,6 +1022,100 @@ impl LumbridgeShell {
cx.notify(); cx.notify();
} }
/// Restarts the selected pane's process, keeping the pane.
///
/// The pane's identity is durable and its process is not, which is the whole
/// point of decision 0011: a crashed shell should not cost you the pane, its
/// position, or its place in the workspace.
fn restart_pane(&mut self, _: &RestartPane, window: &mut Window, cx: &mut Context<Self>) {
let pane = self.panels.selected();
if !self.live_terminals.contains_key(&pane) {
return;
}
self.timing.mark_dispatch();
// Stop the old one first. A failure here is reported rather than
// swallowed: a restart that silently left the old process running would
// leak a shell every time it was pressed.
if let Err(error) = self.runtimes.shutdown(&pane) {
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
}
cx.notify();
return;
}
let dimensions =
terminal_dimensions_for_window(window.bounds().size, self.panels.attached_count());
let mut terminal = LiveTerminalState::new(dimensions);
if let Err(error) = spawn_live_runtime(&mut self.runtimes, pane, None, dimensions) {
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
}
self.live_terminals.insert(pane, terminal);
self.pending_terminate = None;
window.focus(&self.root_focus);
cx.notify();
}
/// Asks before terminating, then terminates on a second press.
///
/// Decision 0010 requires terminate to be a distinct, named, confirmed
/// operation rather than a close icon, and the confirmation names the
/// process being killed. Detaching a pane leaves its process alive; this is
/// the only path in the application that ends one.
fn terminate_pane(&mut self, _: &TerminatePane, window: &mut Window, cx: &mut Context<Self>) {
let pane = self.panels.selected();
if !self.live_terminals.contains_key(&pane) {
return;
}
self.timing.mark_dispatch();
if self.pending_terminate != Some(pane) {
self.pending_terminate = Some(pane);
cx.notify();
return;
}
self.pending_terminate = None;
let outcome = self.runtimes.shutdown(&pane);
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
terminal.status = match outcome {
Ok(()) => LiveRuntimeStatus::Exited("terminated by request".to_owned()),
Err(error) => LiveRuntimeStatus::Fault(error.to_string()),
};
}
window.focus(&self.root_focus);
cx.notify();
}
/// Pastes the clipboard into the selected terminal.
///
/// Through the engine's bracketed-paste path rather than as synthetic
/// keystrokes, so a shell or editor that asked for bracketed paste is told
/// this is a paste and does not run every newline as a command.
///
/// There is no matching copy: the terminal engine has no selection yet, so
/// there is nothing to copy from. Adding a key that copied the whole screen
/// would not be the same feature under the same name.
fn paste_into_pane(&mut self, _: &PasteIntoPane, _: &mut Window, cx: &mut Context<Self>) {
let pane = self.panels.selected();
let Some(terminal) = self.live_terminals.get(&pane) else {
return;
};
if terminal.status.is_terminal() {
return;
}
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
return;
};
if text.is_empty() {
return;
}
let bytes = terminal.terminal.encode_paste(&text);
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Input(bytes))
&& let Some(terminal) = self.live_terminals.get_mut(&pane)
{
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
}
cx.notify();
}
fn add_panel_action(&mut self, _: &AddPanel, window: &mut Window, cx: &mut Context<Self>) { fn add_panel_action(&mut self, _: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
self.dispatch(ShellAction::CloseCommandPalette); self.dispatch(ShellAction::CloseCommandPalette);
self.add_panel_chooser_open = !self.add_panel_chooser_open; self.add_panel_chooser_open = !self.add_panel_chooser_open;
@@ -1156,13 +1247,28 @@ impl LumbridgeShell {
} }
if !self.model.command_palette().is_open() { if !self.model.command_palette().is_open() {
let pane = self.panels.selected(); let pane = self.panels.selected();
if self // Typing into a pane with nothing to type into used to vanish
.live_terminals // without a word: no beep, no message, no indication that the
.get(&pane) // keystroke had gone anywhere. Say where it went.
.is_none_or(|terminal| terminal.status.is_terminal()) match self.live_terminals.get(&pane) {
{ None => {
return; self.input_gap = Some(
"This pane has no terminal. Select a terminal pane to type into it."
.to_owned(),
);
cx.notify();
return;
}
Some(terminal) if terminal.status.is_terminal() => {
self.input_gap =
Some("This pane's process has ended. ⌘⌥R restarts it.".to_owned());
cx.notify();
return;
}
Some(_) => self.input_gap = None,
} }
// Any key that is not the confirmation cancels a pending terminate.
self.pending_terminate = None;
let modifiers = key_modifiers(event); let modifiers = key_modifiers(event);
if let Some(scroll) = if let Some(scroll) =
terminal_scroll_from_parts(event.keystroke.key.as_str(), modifiers) terminal_scroll_from_parts(event.keystroke.key.as_str(), modifiers)
@@ -1177,29 +1283,25 @@ impl LumbridgeShell {
) else { ) else {
return; return;
}; };
let bytes = self let Some(bytes) = self
.live_terminals .live_terminals
.get(&pane) .get(&pane)
.expect("selected live terminal exists") .map(|terminal| terminal.terminal.encode_key(&event))
.terminal else {
.encode_key(&event); return;
};
if bytes.is_empty() { if bytes.is_empty() {
return; return;
} }
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Input(bytes)) { if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Input(bytes))
self.live_terminals && let Some(terminal) = self.live_terminals.get_mut(&pane)
.get_mut(&pane) {
.expect("selected live terminal exists") terminal.status = LiveRuntimeStatus::Fault(error.to_string());
.status = LiveRuntimeStatus::Fault(error.to_string());
} }
let moved_to_bottom = { let moved_to_bottom = self.live_terminals.get_mut(&pane).is_some_and(|terminal| {
let terminal = self
.live_terminals
.get_mut(&pane)
.expect("selected live terminal exists");
terminal.terminal.display_offset() > 0 terminal.terminal.display_offset() > 0
&& terminal.terminal.scroll_display(TerminalScroll::Bottom) && terminal.terminal.scroll_display(TerminalScroll::Bottom)
}; });
if moved_to_bottom { if moved_to_bottom {
self.publish_terminal_snapshot(pane); self.publish_terminal_snapshot(pane);
} }
@@ -1339,12 +1441,23 @@ impl LumbridgeShell {
} }
} }
fn terminal_view(&self, pane: PanelId) -> gpui::AnyElement { fn terminal_view(&self, pane: PanelId, cx: &mut Context<Self>) -> gpui::AnyElement {
let theme = self.theme.colors; let theme = self.theme.colors;
let terminal = self // A pane can outlive its runtime state — a failed spawn, a terminate, a
.live_terminals // restored snapshot whose process is gone. Saying so beats panicking in
.get(&pane) // the middle of a paint.
.expect("external terminal pane has live state"); let Some(terminal) = self.live_terminals.get(&pane) else {
return div()
.flex()
.size_full()
.items_center()
.justify_center()
.text_sm()
.text_color(theme.muted)
.child("No runtime is attached to this pane.")
.into_any_element();
};
let scroll_pane = pane;
let rows = terminal_paint_rows(&terminal.snapshot, theme) let rows = terminal_paint_rows(&terminal.snapshot, theme)
.into_iter() .into_iter()
.map(|runs| { .map(|runs| {
@@ -1363,6 +1476,33 @@ impl LumbridgeShell {
.bg(theme.chrome) .bg(theme.chrome)
.font_family("monospace") .font_family("monospace")
.text_size(px(13.0)) .text_size(px(13.0))
// Shift+PageUp was the only way into scrollback, which is not
// something anyone guesses. A wheel is how people scroll.
.on_scroll_wheel(
cx.listener(move |shell, event: &gpui::ScrollWheelEvent, _, cx| {
let delta = event.delta.pixel_delta(px(TERMINAL_CELL_HEIGHT));
let lines = (f32::from(delta.y) / TERMINAL_CELL_HEIGHT).clamp(-64.0, 64.0);
#[expect(
clippy::cast_possible_truncation,
reason = "clamped into -64..=64 on the line above"
)]
let rows = lines.trunc() as i32;
// A wheel notch shorter than one row still scrolls one row,
// otherwise a fine-grained trackpad does nothing at all.
let rows = if rows == 0 {
if lines > 0.0 {
1
} else if lines < 0.0 {
-1
} else {
return;
}
} else {
rows
};
shell.scroll_terminal(scroll_pane, TerminalScroll::Delta(rows), cx);
}),
)
.children(rows) .children(rows)
.into_any_element() .into_any_element()
} }
@@ -1381,11 +1521,11 @@ impl LumbridgeShell {
let pane_id = pane.id; let pane_id = pane.id;
let can_detach = self.panels.attached_count() > 1; let can_detach = self.panels.attached_count() > 1;
let external = pane.output_source == OutputSource::External; let external = pane.output_source == OutputSource::External;
let live_terminal = external.then(|| { // A pane can be marked external and still have no runtime: a failed
self.live_terminals // spawn, a terminated session, a snapshot restored past its process.
.get(&pane_id) let live_terminal = external
.expect("external terminal pane has live state") .then(|| self.live_terminals.get(&pane_id))
}); .flatten();
let status = live_terminal.map_or(pane.badge.as_str(), |terminal| terminal.status.badge()); let status = live_terminal.map_or(pane.badge.as_str(), |terminal| terminal.status.badge());
let detail = let detail =
live_terminal.map_or_else(|| pane.target.clone(), |terminal| terminal.status.detail()); live_terminal.map_or_else(|| pane.target.clone(), |terminal| terminal.status.detail());
@@ -1629,7 +1769,71 @@ impl LumbridgeShell {
.into_any_element() .into_any_element()
} }
fn pane_work_surface(&self, pane: &PanelView) -> gpui::AnyElement { /// What a pane shows when its process has ended.
///
/// The stale screen stays, dimmed, rather than being cleared: what the
/// process last printed is usually why it stopped. Decision 0010 wants a
/// terminated session to be visibly terminated, not silently blank.
fn exited_banner(status: &LiveRuntimeStatus, theme: ThemeColors) -> Option<gpui::AnyElement> {
let (label, tone) = match status {
LiveRuntimeStatus::Exited(detail) => (format!("Process ended · {detail}"), theme.muted),
LiveRuntimeStatus::Fault(detail) => (format!("Runtime fault · {detail}"), theme.danger),
LiveRuntimeStatus::Starting | LiveRuntimeStatus::Running { .. } => return None,
};
Some(
div()
.flex_none()
.px_3()
.py_1()
.text_xs()
.bg(theme.danger_container)
.text_color(tone)
.child(format!("{label} · ⌘⌥R restarts it"))
.into_any_element(),
)
}
/// The confirmation decision 0010 requires before a process is ended.
///
/// It names the process and says what will happen, and it is a separate
/// keystroke rather than a close icon, because detaching a pane and killing
/// its process are different operations with different consequences.
fn terminate_confirmation(
&self,
pane: PanelId,
theme: ThemeColors,
) -> Option<gpui::AnyElement> {
if self.pending_terminate != Some(pane) {
return None;
}
let process = self.live_terminals.get(&pane).map_or_else(
|| "this pane".to_owned(),
|terminal| match terminal.status {
LiveRuntimeStatus::Running {
process_id: Some(id),
..
} => format!("pid {id} on this machine"),
_ => "this pane's process".to_owned(),
},
);
Some(
div()
.flex_none()
.px_3()
.py_1()
.text_xs()
.bg(theme.danger_container)
.border_1()
.border_color(theme.danger)
.text_color(theme.text)
.child(format!(
"Terminate session? {process} will be ended. ⌘⌥X again to confirm, any other key to cancel."
))
.into_any_element(),
)
}
fn pane_work_surface(&self, pane: &PanelView, cx: &mut Context<Self>) -> gpui::AnyElement {
let theme = self.theme.colors; let theme = self.theme.colors;
let shown = self.surface_for(pane.id, pane.kind); let shown = self.surface_for(pane.id, pane.kind);
if shown != SurfaceTab::native_for(pane.kind) { if shown != SurfaceTab::native_for(pane.kind) {
@@ -1637,7 +1841,7 @@ impl LumbridgeShell {
} }
let external = pane.output_source == OutputSource::External; let external = pane.output_source == OutputSource::External;
let content = if external { let content = if external {
self.terminal_view(pane.id) self.terminal_view(pane.id, cx)
} else { } else {
let start = pane.lines.len().saturating_sub(18); let start = pane.lines.len().saturating_sub(18);
div() div()
@@ -1653,6 +1857,10 @@ impl LumbridgeShell {
.into_any_element() .into_any_element()
}; };
let banner = self
.live_terminals
.get(&pane.id)
.and_then(|terminal| Self::exited_banner(&terminal.status, theme));
div() div()
.flex() .flex()
.flex_col() .flex_col()
@@ -1660,6 +1868,23 @@ impl LumbridgeShell {
.min_w_0() .min_w_0()
.min_h_0() .min_h_0()
.overflow_hidden() .overflow_hidden()
.children(self.terminate_confirmation(pane.id, theme))
.children(banner)
.children(
self.input_gap
.as_ref()
.filter(|_| pane.id == self.panels.selected())
.map(|reason| {
div()
.flex_none()
.px_3()
.py_1()
.text_xs()
.bg(theme.surface_raised)
.text_color(theme.attention)
.child(reason.clone())
}),
)
.bg(theme.chrome) .bg(theme.chrome)
.border_y_1() .border_y_1()
.border_color(theme.border) .border_color(theme.border)
@@ -1826,7 +2051,7 @@ impl LumbridgeShell {
.flex_none() .flex_none()
.min_h_0() .min_h_0()
.overflow_hidden() .overflow_hidden()
.child(self.pane_work_surface(pane)), .child(self.pane_work_surface(pane, cx)),
) )
.child( .child(
div() div()
@@ -2296,6 +2521,9 @@ impl LumbridgeShell {
.on_action(cx.listener(Self::close_palette)) .on_action(cx.listener(Self::close_palette))
.on_action(cx.listener(Self::add_panel_action)) .on_action(cx.listener(Self::add_panel_action))
.on_action(cx.listener(Self::detach_selected_panel)) .on_action(cx.listener(Self::detach_selected_panel))
.on_action(cx.listener(Self::restart_pane))
.on_action(cx.listener(Self::terminate_pane))
.on_action(cx.listener(Self::paste_into_pane))
.on_action(cx.listener(Self::terminal_taller)) .on_action(cx.listener(Self::terminal_taller))
.on_action(cx.listener(Self::terminal_shorter)) .on_action(cx.listener(Self::terminal_shorter))
.on_action(cx.listener(Self::terminal_wider)) .on_action(cx.listener(Self::terminal_wider))
@@ -2901,7 +3129,20 @@ fn terminal_key_from_parts(
let number = function.strip_prefix('f')?.parse::<u8>().ok()?; let number = function.strip_prefix('f')?.parse::<u8>().ok()?;
TerminalKey::Function(number) TerminalKey::Function(number)
} }
_ if !modifiers.contains(KeyModifiers::PLATFORM) => TerminalKey::Text(key_char?.to_owned()), _ if !modifiers.contains(KeyModifiers::PLATFORM) => {
// GPUI reports no `key_char` for a control chord, because ctrl-k
// produces no printable character. Taking `key_char` alone meant
// every control byte was dropped before it reached the PTY — not
// just ctrl-k, but ctrl-c, ctrl-d, ctrl-a and ctrl-r as well, which
// is most of what makes a terminal usable. Fall back to the key
// name when it names a single character; the engine applies the
// control transformation.
let text = key_char
.filter(|text| !text.is_empty())
.map(str::to_owned)
.or_else(|| (key.chars().count() == 1).then(|| key.to_owned()))?;
TerminalKey::Text(text)
}
_ => return None, _ => return None,
}; };
Some(TerminalKeyEvent { key, modifiers }) Some(TerminalKeyEvent { key, modifiers })
@@ -2960,25 +3201,7 @@ fn spawn_live_runtime(
fn main() { fn main() {
Application::new().run(|cx: &mut App| { Application::new().run(|cx: &mut App| {
cx.bind_keys([ cx.bind_keys(keymap::bindings());
KeyBinding::new("alt-left", FocusLeft, Some("LumbridgeShell")),
KeyBinding::new("alt-right", FocusRight, Some("LumbridgeShell")),
KeyBinding::new("alt-up", FocusUp, Some("LumbridgeShell")),
KeyBinding::new("alt-down", FocusDown, Some("LumbridgeShell")),
KeyBinding::new("secondary-k", OpenPalette, Some("LumbridgeShell")),
KeyBinding::new("alt-1", SelectPane1, Some("LumbridgeShell")),
KeyBinding::new("alt-2", SelectPane2, Some("LumbridgeShell")),
KeyBinding::new("alt-3", SelectPane3, Some("LumbridgeShell")),
KeyBinding::new("alt-4", SelectPane4, Some("LumbridgeShell")),
KeyBinding::new("alt-5", SelectPane5, Some("LumbridgeShell")),
KeyBinding::new("alt-6", SelectPane6, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-n", AddPanel, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-w", DetachSelectedPanel, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-up", TerminalTaller, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-down", TerminalShorter, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-right", TerminalWider, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-left", TerminalNarrower, Some("LumbridgeShell")),
]);
let bounds = Bounds::centered(None, size(px(1500.0), px(960.0)), cx); let bounds = Bounds::centered(None, size(px(1500.0), px(960.0)), cx);
cx.open_window( cx.open_window(
@@ -3021,6 +3244,34 @@ mod tests {
assert_eq!(composed.key, TerminalKey::Text("é".into())); assert_eq!(composed.key, TerminalKey::Text("é".into()));
} }
/// The regression this test exists for: a control chord carries no
/// printable character, so requiring one dropped every control byte.
#[test]
fn a_control_chord_still_reaches_the_terminal_without_a_key_char() {
for letter in ["k", "c", "d", "a", "r"] {
let event = terminal_key_from_parts(letter, None, KeyModifiers::CONTROL)
.unwrap_or_else(|| panic!("ctrl-{letter} must reach the terminal"));
assert_eq!(event.key, TerminalKey::Text(letter.to_owned()));
assert!(event.modifiers.contains(KeyModifiers::CONTROL));
}
}
#[test]
fn a_printable_character_still_wins_over_the_key_name() {
// Shift-a arrives as key "a" with key_char "A"; the character is what
// the terminal should receive.
let event = terminal_key_from_parts("a", Some("A"), KeyModifiers::SHIFT).expect("shift-a");
assert_eq!(event.key, TerminalKey::Text("A".to_owned()));
}
#[test]
fn a_named_key_with_no_character_is_still_refused() {
// Multi-character key names that are not in the table above are not
// text and must not be sent as though they were.
assert!(terminal_key_from_parts("capslock", None, KeyModifiers::default()).is_none());
assert!(terminal_key_from_parts("shift", None, KeyModifiers::default()).is_none());
}
#[test] #[test]
fn reserves_platform_text_shortcuts_for_the_workspace() { fn reserves_platform_text_shortcuts_for_the_workspace() {
assert!(terminal_key_from_parts("c", Some("c"), KeyModifiers::PLATFORM).is_none()); assert!(terminal_key_from_parts("c", Some("c"), KeyModifiers::PLATFORM).is_none());
+3 -1
View File
@@ -38,8 +38,9 @@ pub(crate) struct ThemeColors {
pub(crate) success: Rgba, pub(crate) success: Rgba,
/// No surface reports failure in colour yet; a dead pane is described in /// No surface reports failure in colour yet; a dead pane is described in
/// words. Defined here so the role exists when one does. /// words. Defined here so the role exists when one does.
#[expect(dead_code, reason = "no failure surface paints yet")]
pub(crate) danger: Rgba, pub(crate) danger: Rgba,
/// A danger fill quiet enough to sit behind text.
pub(crate) danger_container: Rgba,
pub(crate) attention: Rgba, pub(crate) attention: Rgba,
/// The needs-input row tint, which arrives with the sidebar rework. /// The needs-input row tint, which arrives with the sidebar rework.
#[expect(dead_code, reason = "the attention row is rebuilt with the sidebar")] #[expect(dead_code, reason = "the attention row is rebuilt with the sidebar")]
@@ -75,6 +76,7 @@ impl From<&Palette> for ThemeColors {
accent: rgba(palette.accent), accent: rgba(palette.accent),
success: rgba(palette.success), success: rgba(palette.success),
danger: rgba(palette.danger), danger: rgba(palette.danger),
danger_container: rgba(palette.danger_container),
attention: rgba(palette.attention), attention: rgba(palette.attention),
attention_wash: rgba(palette.attention_wash), attention_wash: rgba(palette.attention_wash),
} }