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:
co-authored by
Claude Opus 5
parent
1556b87f37
commit
d76da3babb
@@ -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"));
|
||||
}
|
||||
}
|
||||
+355
-104
@@ -1,3 +1,4 @@
|
||||
mod keymap;
|
||||
mod panel_registry;
|
||||
mod theme;
|
||||
mod usage_feed;
|
||||
@@ -7,9 +8,8 @@ use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::{
|
||||
App, Application, Bounds, Context, FocusHandle, FontWeight, KeyBinding, KeyDownEvent, Pixels,
|
||||
Rgba, Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb,
|
||||
size,
|
||||
App, Application, Bounds, Context, FocusHandle, FontWeight, KeyDownEvent, Pixels, Rgba, Size,
|
||||
Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, size,
|
||||
};
|
||||
use lumbridge_core::UsageProvenance;
|
||||
use lumbridge_runtime::{
|
||||
@@ -56,6 +56,9 @@ actions!(
|
||||
FocusDown,
|
||||
OpenPalette,
|
||||
ClosePalette,
|
||||
RestartPane,
|
||||
TerminatePane,
|
||||
PasteIntoPane,
|
||||
SelectPane1,
|
||||
SelectPane2,
|
||||
SelectPane3,
|
||||
@@ -88,6 +91,10 @@ struct LumbridgeShell {
|
||||
/// inert by design: it prepares nothing and runs nothing.
|
||||
shelf_choice: BTreeMap<PanelId, usize>,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -703,6 +710,8 @@ impl LumbridgeShell {
|
||||
surfaces: BTreeMap::new(),
|
||||
shelf_choice: BTreeMap::new(),
|
||||
add_panel_chooser_open: false,
|
||||
pending_terminate: None,
|
||||
input_gap: None,
|
||||
root_focus,
|
||||
}
|
||||
}
|
||||
@@ -751,19 +760,17 @@ impl LumbridgeShell {
|
||||
Ok(Some(event)) => event,
|
||||
Ok(None) => break,
|
||||
Err(RuntimeRegistryError::Actor(RuntimeActorError::Disconnected)) => {
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.status =
|
||||
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||
terminal.status =
|
||||
LiveRuntimeStatus::Fault("runtime actor disconnected".to_owned());
|
||||
}
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
}
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
@@ -774,23 +781,21 @@ impl LumbridgeShell {
|
||||
session_id,
|
||||
process_id,
|
||||
} => {
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.status = LiveRuntimeStatus::Running {
|
||||
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||
terminal.status = LiveRuntimeStatus::Running {
|
||||
session_id: session_id.get(),
|
||||
process_id,
|
||||
};
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
RuntimeEvent::Output {
|
||||
sequence, bytes, ..
|
||||
} => {
|
||||
let responses = {
|
||||
let terminal = self
|
||||
.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists");
|
||||
let Some(terminal) = self.live_terminals.get_mut(&pane) else {
|
||||
break;
|
||||
};
|
||||
if sequence <= terminal.last_runtime_sequence {
|
||||
terminal.status = LiveRuntimeStatus::Fault(format!(
|
||||
"non-monotonic PTY output sequence {sequence}"
|
||||
@@ -805,10 +810,9 @@ impl LumbridgeShell {
|
||||
if let Err(error) =
|
||||
self.send_runtime_command(pane, RuntimeCommand::Input(response))
|
||||
{
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -817,23 +821,22 @@ impl LumbridgeShell {
|
||||
}
|
||||
RuntimeEvent::InputClosed { .. } => {}
|
||||
RuntimeEvent::Exited { status, .. } => {
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.status = LiveRuntimeStatus::Exited(format!(
|
||||
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||
terminal.status = LiveRuntimeStatus::Exited(format!(
|
||||
"PTY exited with code {}",
|
||||
status.code
|
||||
));
|
||||
}
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
RuntimeEvent::Fault {
|
||||
operation, message, ..
|
||||
} => {
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.status = LiveRuntimeStatus::Fault(format!("{operation:?}: {message}"));
|
||||
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||
terminal.status =
|
||||
LiveRuntimeStatus::Fault(format!("{operation:?}: {message}"));
|
||||
}
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
@@ -853,10 +856,9 @@ impl LumbridgeShell {
|
||||
|
||||
fn publish_terminal_snapshot(&mut self, pane: PanelId) {
|
||||
let lines = {
|
||||
let terminal = self
|
||||
.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists");
|
||||
let Some(terminal) = self.live_terminals.get_mut(&pane) else {
|
||||
return;
|
||||
};
|
||||
let snapshot = terminal.terminal.snapshot();
|
||||
let lines = snapshot.plain_rows();
|
||||
terminal.snapshot = snapshot;
|
||||
@@ -875,20 +877,18 @@ impl LumbridgeShell {
|
||||
fn resize_terminal(&mut self, pane: PanelId, rows: u16, columns: u16) -> bool {
|
||||
let dimensions = TerminalDimensions::new(rows, columns)
|
||||
.expect("resize actions always retain non-zero dimensions");
|
||||
let terminal = self
|
||||
.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists");
|
||||
let Some(terminal) = self.live_terminals.get_mut(&pane) else {
|
||||
return false;
|
||||
};
|
||||
if !terminal.terminal.resize(dimensions) {
|
||||
return false;
|
||||
}
|
||||
let pty_size = TerminalSize::new(rows, columns)
|
||||
.expect("terminal engine dimensions are valid PTY dimensions");
|
||||
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Resize(pty_size)) {
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Resize(pty_size))
|
||||
&& let Some(terminal) = self.live_terminals.get_mut(&pane)
|
||||
{
|
||||
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
}
|
||||
self.publish_terminal_snapshot(pane);
|
||||
true
|
||||
@@ -908,13 +908,10 @@ impl LumbridgeShell {
|
||||
}
|
||||
|
||||
fn scroll_terminal(&mut self, pane: PanelId, scroll: TerminalScroll, cx: &mut Context<Self>) {
|
||||
if !self
|
||||
.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.terminal
|
||||
.scroll_display(scroll)
|
||||
{
|
||||
let Some(terminal) = self.live_terminals.get_mut(&pane) else {
|
||||
return;
|
||||
};
|
||||
if !terminal.terminal.scroll_display(scroll) {
|
||||
return;
|
||||
}
|
||||
self.publish_terminal_snapshot(pane);
|
||||
@@ -1025,6 +1022,100 @@ impl LumbridgeShell {
|
||||
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>) {
|
||||
self.dispatch(ShellAction::CloseCommandPalette);
|
||||
self.add_panel_chooser_open = !self.add_panel_chooser_open;
|
||||
@@ -1156,13 +1247,28 @@ impl LumbridgeShell {
|
||||
}
|
||||
if !self.model.command_palette().is_open() {
|
||||
let pane = self.panels.selected();
|
||||
if self
|
||||
.live_terminals
|
||||
.get(&pane)
|
||||
.is_none_or(|terminal| terminal.status.is_terminal())
|
||||
{
|
||||
// Typing into a pane with nothing to type into used to vanish
|
||||
// without a word: no beep, no message, no indication that the
|
||||
// keystroke had gone anywhere. Say where it went.
|
||||
match self.live_terminals.get(&pane) {
|
||||
None => {
|
||||
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);
|
||||
if let Some(scroll) =
|
||||
terminal_scroll_from_parts(event.keystroke.key.as_str(), modifiers)
|
||||
@@ -1177,29 +1283,25 @@ impl LumbridgeShell {
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let bytes = self
|
||||
let Some(bytes) = self
|
||||
.live_terminals
|
||||
.get(&pane)
|
||||
.expect("selected live terminal exists")
|
||||
.terminal
|
||||
.encode_key(&event);
|
||||
.map(|terminal| terminal.terminal.encode_key(&event))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Input(bytes)) {
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("selected live terminal exists")
|
||||
.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
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());
|
||||
}
|
||||
let moved_to_bottom = {
|
||||
let terminal = self
|
||||
.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("selected live terminal exists");
|
||||
let moved_to_bottom = self.live_terminals.get_mut(&pane).is_some_and(|terminal| {
|
||||
terminal.terminal.display_offset() > 0
|
||||
&& terminal.terminal.scroll_display(TerminalScroll::Bottom)
|
||||
};
|
||||
});
|
||||
if moved_to_bottom {
|
||||
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 terminal = self
|
||||
.live_terminals
|
||||
.get(&pane)
|
||||
.expect("external terminal pane has live state");
|
||||
// A pane can outlive its runtime state — a failed spawn, a terminate, a
|
||||
// restored snapshot whose process is gone. Saying so beats panicking in
|
||||
// the middle of a paint.
|
||||
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)
|
||||
.into_iter()
|
||||
.map(|runs| {
|
||||
@@ -1363,6 +1476,33 @@ impl LumbridgeShell {
|
||||
.bg(theme.chrome)
|
||||
.font_family("monospace")
|
||||
.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)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1381,11 +1521,11 @@ impl LumbridgeShell {
|
||||
let pane_id = pane.id;
|
||||
let can_detach = self.panels.attached_count() > 1;
|
||||
let external = pane.output_source == OutputSource::External;
|
||||
let live_terminal = external.then(|| {
|
||||
self.live_terminals
|
||||
.get(&pane_id)
|
||||
.expect("external terminal pane has live state")
|
||||
});
|
||||
// A pane can be marked external and still have no runtime: a failed
|
||||
// spawn, a terminated session, a snapshot restored past its process.
|
||||
let live_terminal = external
|
||||
.then(|| self.live_terminals.get(&pane_id))
|
||||
.flatten();
|
||||
let status = live_terminal.map_or(pane.badge.as_str(), |terminal| terminal.status.badge());
|
||||
let detail =
|
||||
live_terminal.map_or_else(|| pane.target.clone(), |terminal| terminal.status.detail());
|
||||
@@ -1629,7 +1769,71 @@ impl LumbridgeShell {
|
||||
.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 shown = self.surface_for(pane.id, pane.kind);
|
||||
if shown != SurfaceTab::native_for(pane.kind) {
|
||||
@@ -1637,7 +1841,7 @@ impl LumbridgeShell {
|
||||
}
|
||||
let external = pane.output_source == OutputSource::External;
|
||||
let content = if external {
|
||||
self.terminal_view(pane.id)
|
||||
self.terminal_view(pane.id, cx)
|
||||
} else {
|
||||
let start = pane.lines.len().saturating_sub(18);
|
||||
div()
|
||||
@@ -1653,6 +1857,10 @@ impl LumbridgeShell {
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
let banner = self
|
||||
.live_terminals
|
||||
.get(&pane.id)
|
||||
.and_then(|terminal| Self::exited_banner(&terminal.status, theme));
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
@@ -1660,6 +1868,23 @@ impl LumbridgeShell {
|
||||
.min_w_0()
|
||||
.min_h_0()
|
||||
.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)
|
||||
.border_y_1()
|
||||
.border_color(theme.border)
|
||||
@@ -1826,7 +2051,7 @@ impl LumbridgeShell {
|
||||
.flex_none()
|
||||
.min_h_0()
|
||||
.overflow_hidden()
|
||||
.child(self.pane_work_surface(pane)),
|
||||
.child(self.pane_work_surface(pane, cx)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -2296,6 +2521,9 @@ impl LumbridgeShell {
|
||||
.on_action(cx.listener(Self::close_palette))
|
||||
.on_action(cx.listener(Self::add_panel_action))
|
||||
.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_shorter))
|
||||
.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()?;
|
||||
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,
|
||||
};
|
||||
Some(TerminalKeyEvent { key, modifiers })
|
||||
@@ -2960,25 +3201,7 @@ fn spawn_live_runtime(
|
||||
|
||||
fn main() {
|
||||
Application::new().run(|cx: &mut App| {
|
||||
cx.bind_keys([
|
||||
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")),
|
||||
]);
|
||||
cx.bind_keys(keymap::bindings());
|
||||
|
||||
let bounds = Bounds::centered(None, size(px(1500.0), px(960.0)), cx);
|
||||
cx.open_window(
|
||||
@@ -3021,6 +3244,34 @@ mod tests {
|
||||
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]
|
||||
fn reserves_platform_text_shortcuts_for_the_workspace() {
|
||||
assert!(terminal_key_from_parts("c", Some("c"), KeyModifiers::PLATFORM).is_none());
|
||||
|
||||
@@ -38,8 +38,9 @@ pub(crate) struct ThemeColors {
|
||||
pub(crate) success: Rgba,
|
||||
/// No surface reports failure in colour yet; a dead pane is described in
|
||||
/// words. Defined here so the role exists when one does.
|
||||
#[expect(dead_code, reason = "no failure surface paints yet")]
|
||||
pub(crate) danger: Rgba,
|
||||
/// A danger fill quiet enough to sit behind text.
|
||||
pub(crate) danger_container: Rgba,
|
||||
pub(crate) attention: Rgba,
|
||||
/// The needs-input row tint, which arrives with the sidebar rework.
|
||||
#[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),
|
||||
success: rgba(palette.success),
|
||||
danger: rgba(palette.danger),
|
||||
danger_container: rgba(palette.danger_container),
|
||||
attention: rgba(palette.attention),
|
||||
attention_wash: rgba(palette.attention_wash),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user