From d76da3babb97ddc17c7547061efc691b8881aa14 Mon Sep 17 00:00:00 2001 From: Metal Agent Date: Mon, 31 Aug 2026 23:35:32 -0700 Subject: [PATCH] Make the terminal usable: control keys, paste, scroll, restart, terminate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/lumbridge/src/keymap.rs | 186 ++++++++++++++ apps/lumbridge/src/main.rs | 475 ++++++++++++++++++++++++++--------- apps/lumbridge/src/theme.rs | 4 +- 3 files changed, 552 insertions(+), 113 deletions(-) create mode 100644 apps/lumbridge/src/keymap.rs diff --git a/apps/lumbridge/src/keymap.rs b/apps/lumbridge/src/keymap.rs new file mode 100644 index 0000000..cb2a849 --- /dev/null +++ b/apps/lumbridge/src/keymap.rs @@ -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-` 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-` is an ESC-prefixed Meta sequence. `alt-f`/`alt-b` are word +//! motion, `alt-1` is readline's digit argument, and `alt-` 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 { + 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-`: 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")); + } +} diff --git a/apps/lumbridge/src/main.rs b/apps/lumbridge/src/main.rs index 3f716f1..69663d5 100644 --- a/apps/lumbridge/src/main.rs +++ b/apps/lumbridge/src/main.rs @@ -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, add_panel_chooser_open: bool, + /// The pane a terminate has been asked for but not yet confirmed. + pending_terminate: Option, + /// Why the last keystroke went nowhere, if it did. + input_gap: Option, 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 = - LiveRuntimeStatus::Fault("runtime actor disconnected".to_owned()); + 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 { - session_id: session_id.get(), - process_id, - }; + 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!( - "PTY exited with code {}", - status.code - )); + 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) { - 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) { + 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) { + 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) { + 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.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()) - { - return; + // 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) -> 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 { + 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 { + 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) -> 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::().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()); diff --git a/apps/lumbridge/src/theme.rs b/apps/lumbridge/src/theme.rs index 0ac9fc9..2038a3c 100644 --- a/apps/lumbridge/src/theme.rs +++ b/apps/lumbridge/src/theme.rs @@ -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), }