feat: add interactive VT and workspace command plane
CI / rust (push) Successful in 3m48s

This commit is contained in:
2026-08-31 17:05:49 -07:00
parent 32d190c6c6
commit b838d000db
54 changed files with 3004 additions and 113 deletions
+267 -86
View File
@@ -6,12 +6,17 @@ use gpui::{
WindowOptions, actions, div, prelude::*, px, rgb, size,
};
use lumbridge_runtime::{
CommandConfig, PtyOptions, RuntimeActor, RuntimeActorError, RuntimeActorOptions, RuntimeEvent,
CommandConfig, PtyOptions, RuntimeActor, RuntimeActorError, RuntimeActorOptions,
RuntimeCommand, RuntimeEvent, TerminalSize,
};
use lumbridge_spike_model::{
ActionOutcome, FOOTER_RIGHT, FocusDirection, OutputSource, PaneId, PaneState, ShellAction,
ShellModel, SurfaceKind, WORKSPACES,
};
use lumbridge_terminal::{
KeyModifiers, TerminalDimensions, TerminalEngine, TerminalEngineOptions, TerminalKey,
TerminalKeyEvent,
};
const BG: u32 = 0x090c12;
const PANEL: u32 = 0x101620;
@@ -28,7 +33,9 @@ const TIMING_SAMPLE_LIMIT: usize = 256;
const RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(16);
const RUNTIME_DRAIN_LIMIT: usize = 64;
const LIVE_PANE: PaneId = PaneId::CodexRuntime;
const LIVE_PTY_SCRIPT: &str = "i=0; printf 'Lumbridge runtime actor owns this PTY\\n'; while :; do printf '[pty %06d] /bin/sh · actor output\\n' \"$i\"; i=$((i+1)); sleep 1; done";
const LIVE_PTY_SCRIPT: &str = "printf 'Lumbridge interactive PTY · type here\\n'; exec /bin/sh -i";
const TERMINAL_ROW_STEP: u16 = 2;
const TERMINAL_COLUMN_STEP: u16 = 10;
actions!(
lumbridge,
@@ -45,6 +52,10 @@ actions!(
SelectPane4,
SelectPane5,
SelectPane6,
TerminalTaller,
TerminalShorter,
TerminalWider,
TerminalNarrower,
]
);
@@ -53,7 +64,7 @@ struct LumbridgeShell {
timing: RenderTiming,
runtime: Option<RuntimeActor>,
runtime_status: LiveRuntimeStatus,
runtime_lines: PtyLineFramer,
terminal: TerminalEngine,
last_runtime_sequence: u64,
root_focus: FocusHandle,
pane_focus: [FocusHandle; 6],
@@ -106,38 +117,6 @@ impl LiveRuntimeStatus {
}
}
#[derive(Default)]
struct PtyLineFramer {
pending: Vec<u8>,
}
impl PtyLineFramer {
fn push(&mut self, bytes: &[u8]) -> Vec<String> {
self.pending.extend_from_slice(bytes);
let mut lines = Vec::new();
while let Some(newline) = self.pending.iter().position(|byte| *byte == b'\n') {
let mut line = self.pending.drain(..=newline).collect::<Vec<_>>();
line.pop();
if line.last() == Some(&b'\r') {
line.pop();
}
lines.push(String::from_utf8_lossy(&line).into_owned());
}
lines
}
fn finish(&mut self) -> Vec<String> {
if self.pending.is_empty() {
return Vec::new();
}
let mut line = std::mem::take(&mut self.pending);
if line.last() == Some(&b'\r') {
line.pop();
}
vec![String::from_utf8_lossy(&line).into_owned()]
}
}
#[derive(Default)]
struct RenderTiming {
pending_dispatch: Option<Instant>,
@@ -228,7 +207,8 @@ impl LumbridgeShell {
})
.detach();
let (runtime, runtime_status) = match start_live_runtime() {
let terminal = TerminalEngine::new(TerminalEngineOptions::default());
let (runtime, runtime_status) = match start_live_runtime(terminal.dimensions()) {
Ok(runtime) => (Some(runtime), LiveRuntimeStatus::Starting),
Err(error) => (None, LiveRuntimeStatus::Fault(error.to_string())),
};
@@ -239,7 +219,7 @@ impl LumbridgeShell {
timing: RenderTiming::default(),
runtime,
runtime_status,
runtime_lines: PtyLineFramer::default(),
terminal,
last_runtime_sequence: 0,
root_focus,
pane_focus,
@@ -296,24 +276,20 @@ impl LumbridgeShell {
break;
}
self.last_runtime_sequence = sequence;
let lines = self.runtime_lines.push(&bytes);
if !lines.is_empty() {
self.dispatch(ShellAction::AppendExternalOutput {
pane: LIVE_PANE,
lines,
});
changed = true;
let update = self.terminal.process(&bytes);
for response in update.outbound {
if let Err(error) =
self.send_runtime_command(RuntimeCommand::Input(response))
{
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string());
return true;
}
}
self.publish_terminal_snapshot();
changed = true;
}
RuntimeEvent::InputClosed { .. } => {}
RuntimeEvent::Exited { status, .. } => {
let lines = self.runtime_lines.finish();
if !lines.is_empty() {
self.dispatch(ShellAction::AppendExternalOutput {
pane: LIVE_PANE,
lines,
});
}
self.runtime_status =
LiveRuntimeStatus::Exited(format!("PTY exited with code {}", status.code));
changed = true;
@@ -332,6 +308,36 @@ impl LumbridgeShell {
changed
}
fn send_runtime_command(&self, command: RuntimeCommand) -> Result<(), RuntimeActorError> {
self.runtime
.as_ref()
.ok_or(RuntimeActorError::Disconnected)?
.try_send(command)
}
fn publish_terminal_snapshot(&mut self) {
let lines = self.terminal.snapshot().plain_rows();
self.dispatch(ShellAction::ReplaceExternalOutput {
pane: LIVE_PANE,
lines,
});
}
fn resize_terminal(&mut self, rows: u16, columns: u16, cx: &mut Context<Self>) {
let dimensions = TerminalDimensions::new(rows, columns)
.expect("resize actions always retain non-zero dimensions");
if !self.terminal.resize(dimensions) {
return;
}
let pty_size = TerminalSize::new(rows, columns)
.expect("terminal engine dimensions are valid PTY dimensions");
if let Err(error) = self.send_runtime_command(RuntimeCommand::Resize(pty_size)) {
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string());
}
self.publish_terminal_snapshot();
cx.notify();
}
fn select_pane(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
self.dispatch(ShellAction::SelectPane(pane));
window.focus(&self.pane_focus[pane.index()]);
@@ -375,8 +381,66 @@ impl LumbridgeShell {
cx.notify();
}
fn terminal_taller(&mut self, _: &TerminalTaller, _: &mut Window, cx: &mut Context<Self>) {
let dimensions = self.terminal.dimensions();
self.resize_terminal(
dimensions.rows().saturating_add(TERMINAL_ROW_STEP),
dimensions.columns(),
cx,
);
}
fn terminal_shorter(&mut self, _: &TerminalShorter, _: &mut Window, cx: &mut Context<Self>) {
let dimensions = self.terminal.dimensions();
self.resize_terminal(
dimensions.rows().saturating_sub(TERMINAL_ROW_STEP).max(2),
dimensions.columns(),
cx,
);
}
fn terminal_wider(&mut self, _: &TerminalWider, _: &mut Window, cx: &mut Context<Self>) {
let dimensions = self.terminal.dimensions();
self.resize_terminal(
dimensions.rows(),
dimensions.columns().saturating_add(TERMINAL_COLUMN_STEP),
cx,
);
}
fn terminal_narrower(&mut self, _: &TerminalNarrower, _: &mut Window, cx: &mut Context<Self>) {
let dimensions = self.terminal.dimensions();
self.resize_terminal(
dimensions.rows(),
dimensions
.columns()
.saturating_sub(TERMINAL_COLUMN_STEP)
.max(20),
cx,
);
}
fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
if !self.model.command_palette().is_open() {
if self.model.selected_pane() != LIVE_PANE || self.runtime_status.is_terminal() {
return;
}
let modifiers = key_modifiers(event);
let Some(event) = terminal_key_from_parts(
event.keystroke.key.as_str(),
event.keystroke.key_char.as_deref(),
modifiers,
) else {
return;
};
let bytes = self.terminal.encode_key(&event);
if bytes.is_empty() {
return;
}
if let Err(error) = self.send_runtime_command(RuntimeCommand::Input(bytes)) {
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string());
}
cx.notify();
return;
}
@@ -390,6 +454,11 @@ impl LumbridgeShell {
cx.notify();
return;
}
"escape" => {
self.dispatch(ShellAction::CloseCommandPalette);
cx.notify();
return;
}
_ => {
if let Some(character) = event.keystroke.key_char.as_deref()
&& !event.keystroke.modifiers.control
@@ -409,6 +478,7 @@ impl LumbridgeShell {
pane: &PaneState,
selected: bool,
runtime_status: LiveRuntimeStatus,
terminal_dimensions: TerminalDimensions,
focus: FocusHandle,
cx: &mut Context<Self>,
) -> gpui::AnyElement {
@@ -478,7 +548,7 @@ impl LumbridgeShell {
.child(format!("{}", id.index() + 1)),
)
.child(if external {
"Shell · runtime actor"
"Shell · VT session"
} else {
pane.fixture().title
}),
@@ -495,16 +565,45 @@ impl LumbridgeShell {
.text_xs()
.text_color(rgb(MUTED))
.child(if external {
runtime_status.detail()
format!(
"{} · {}×{} · Alt+Shift+arrows resize",
runtime_status.detail(),
terminal_dimensions.columns(),
terminal_dimensions.rows()
)
} else {
pane.fixture().target.to_owned()
})
.when(selected, |view| view.child("FOCUSED")),
)
.when(external && selected, |view| {
view.child(
div()
.flex()
.items_center()
.gap_3()
.h(px(26.0))
.flex_none()
.px_3()
.border_y_1()
.border_color(rgb(BORDER_QUIET))
.bg(rgb(PANEL_ALT))
.text_xs()
.child(div().text_color(rgb(ACCENT)).child("TERMINAL"))
.child(div().text_color(rgb(MUTED)).child("BROWSER"))
.child(div().text_color(rgb(MUTED)).child("TOOLS"))
.child(div().text_color(rgb(MUTED)).child("CONTEXT"))
.child(div().text_color(rgb(MUTED)).child("GOAL"))
.child(div().text_color(rgb(MUTED)).child("REVIEW")),
)
})
.child(
div()
.flex()
.flex_col()
.flex_1()
.min_h_0()
.overflow_hidden()
.gap_1()
.px_3()
.pb_3()
@@ -512,6 +611,36 @@ impl LumbridgeShell {
.text_color(rgb(TEXT))
.children(pane.lines()[line_start..].iter().cloned()),
)
.when(external && selected, |view| {
view.child(
div()
.flex()
.items_center()
.justify_between()
.gap_2()
.h(px(38.0))
.flex_none()
.px_3()
.border_t_1()
.border_color(rgb(BORDER_QUIET))
.bg(rgb(PANEL_ALT))
.text_xs()
.child(
div()
.text_color(rgb(MUTED))
.child("LOCAL ANALYST · OFF · suggestions only"),
)
.child(
div()
.flex()
.gap_2()
.text_color(rgb(TEXT))
.child("Continue")
.child("Review plan")
.child("Ask…"),
),
)
})
.into_any_element()
}
@@ -767,7 +896,7 @@ impl Render for LumbridgeShell {
.px_3()
.text_xs()
.text_color(rgb(MUTED))
.child("1 live PTY · 5 deterministic · 1 waiting"),
.child("1 interactive VT · 5 deterministic · 1 waiting"),
);
let pane_cards = self
@@ -779,6 +908,7 @@ impl Render for LumbridgeShell {
pane,
self.model.selected_pane() == pane.id(),
self.runtime_status.clone(),
self.terminal.dimensions(),
self.pane_focus[pane.id().index()].clone(),
cx,
)
@@ -795,11 +925,11 @@ impl Render for LumbridgeShell {
let counters = self.model.counters();
let footer_left = format!(
"rev {} · {} focus · {} external batches · {} PTY lines · {} total lines",
"rev {} · VT rev {} · {} focus · {} VT snapshots · {} synthetic lines",
self.model.revision(),
self.terminal.revision(),
counters.focus_moves,
counters.external_output_batches,
counters.external_lines_appended,
counters.external_snapshot_updates,
counters.terminal_lines_appended
);
let timing = self.timing.summary();
@@ -815,6 +945,10 @@ impl Render for LumbridgeShell {
.on_action(cx.listener(Self::focus_down))
.on_action(cx.listener(Self::open_palette))
.on_action(cx.listener(Self::close_palette))
.on_action(cx.listener(Self::terminal_taller))
.on_action(cx.listener(Self::terminal_shorter))
.on_action(cx.listener(Self::terminal_wider))
.on_action(cx.listener(Self::terminal_narrower))
.on_action(cx.listener(|shell, _: &SelectPane1, window, cx| {
shell.select_pane(PaneId::CodexRuntime, window, cx);
}))
@@ -908,13 +1042,62 @@ impl Render for LumbridgeShell {
}
}
fn start_live_runtime() -> Result<RuntimeActor, RuntimeActorError> {
fn key_modifiers(event: &KeyDownEvent) -> KeyModifiers {
let mut modifiers = KeyModifiers::default();
if event.keystroke.modifiers.shift {
modifiers = modifiers.union(KeyModifiers::SHIFT);
}
if event.keystroke.modifiers.alt {
modifiers = modifiers.union(KeyModifiers::ALT);
}
if event.keystroke.modifiers.control {
modifiers = modifiers.union(KeyModifiers::CONTROL);
}
if event.keystroke.modifiers.platform {
modifiers = modifiers.union(KeyModifiers::PLATFORM);
}
modifiers
}
fn terminal_key_from_parts(
key: &str,
key_char: Option<&str>,
modifiers: KeyModifiers,
) -> Option<TerminalKeyEvent> {
let key = match key {
"enter" => TerminalKey::Enter,
"backspace" => TerminalKey::Backspace,
"tab" => TerminalKey::Tab,
"escape" => TerminalKey::Escape,
"up" => TerminalKey::Up,
"down" => TerminalKey::Down,
"left" => TerminalKey::Left,
"right" => TerminalKey::Right,
"home" => TerminalKey::Home,
"end" => TerminalKey::End,
"insert" => TerminalKey::Insert,
"delete" => TerminalKey::Delete,
"pageup" => TerminalKey::PageUp,
"pagedown" => TerminalKey::PageDown,
function if function.starts_with('f') => {
let number = function.strip_prefix('f')?.parse::<u8>().ok()?;
TerminalKey::Function(number)
}
_ if !modifiers.contains(KeyModifiers::PLATFORM) => TerminalKey::Text(key_char?.to_owned()),
_ => return None,
};
Some(TerminalKeyEvent { key, modifiers })
}
fn start_live_runtime(dimensions: TerminalDimensions) -> Result<RuntimeActor, RuntimeActorError> {
let command = CommandConfig::new("/bin/sh")
.map_err(RuntimeActorError::Start)?
.args(["-c", LIVE_PTY_SCRIPT]);
let pty_size = TerminalSize::new(dimensions.rows(), dimensions.columns())
.map_err(RuntimeActorError::Start)?;
RuntimeActor::spawn(
command,
PtyOptions::default(),
PtyOptions::new(pty_size),
RuntimeActorOptions::default(),
)
}
@@ -922,22 +1105,21 @@ fn start_live_runtime() -> Result<RuntimeActor, RuntimeActorError> {
fn main() {
Application::new().run(|cx: &mut App| {
cx.bind_keys([
KeyBinding::new("left", FocusLeft, Some("LumbridgeShell")),
KeyBinding::new("right", FocusRight, Some("LumbridgeShell")),
KeyBinding::new("up", FocusUp, Some("LumbridgeShell")),
KeyBinding::new("down", FocusDown, Some("LumbridgeShell")),
KeyBinding::new("h", FocusLeft, Some("LumbridgeShell")),
KeyBinding::new("l", FocusRight, Some("LumbridgeShell")),
KeyBinding::new("k", FocusUp, Some("LumbridgeShell")),
KeyBinding::new("j", FocusDown, Some("LumbridgeShell")),
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("escape", ClosePalette, Some("LumbridgeShell")),
KeyBinding::new("1", SelectPane1, Some("LumbridgeShell")),
KeyBinding::new("2", SelectPane2, Some("LumbridgeShell")),
KeyBinding::new("3", SelectPane3, Some("LumbridgeShell")),
KeyBinding::new("4", SelectPane4, Some("LumbridgeShell")),
KeyBinding::new("5", SelectPane5, Some("LumbridgeShell")),
KeyBinding::new("6", SelectPane6, 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-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);
@@ -959,22 +1141,21 @@ fn main() {
#[cfg(test)]
mod tests {
use super::PtyLineFramer;
use super::{KeyModifiers, TerminalKey, terminal_key_from_parts};
#[test]
fn frames_split_crlf_and_utf8_chunks_without_losing_bytes() {
let mut framer = PtyLineFramer::default();
assert!(framer.push(b"first\r").is_empty());
assert_eq!(framer.push(b"\nsecond\npart"), ["first", "second"]);
assert!(framer.push(&[0xf0, 0x9f]).is_empty());
assert!(framer.push(&[0x91, 0xa9]).is_empty());
assert_eq!(framer.finish(), ["part👩"]);
fn maps_named_and_composed_gpui_keys_to_terminal_input() {
let enter = terminal_key_from_parts("enter", None, KeyModifiers::default()).unwrap();
assert_eq!(enter.key, TerminalKey::Enter);
let composed = terminal_key_from_parts("é", Some("é"), KeyModifiers::default()).unwrap();
assert_eq!(composed.key, TerminalKey::Text("é".into()));
}
#[test]
fn replaces_invalid_utf8_only_after_a_complete_line() {
let mut framer = PtyLineFramer::default();
assert!(framer.push(&[0xff]).is_empty());
assert_eq!(framer.push(b"\n"), [""]);
fn reserves_platform_text_shortcuts_for_the_workspace() {
assert!(terminal_key_from_parts("c", Some("c"), KeyModifiers::PLATFORM).is_none());
let control = terminal_key_from_parts("c", Some("c"), KeyModifiers::CONTROL).unwrap();
assert_eq!(control.key, TerminalKey::Text("c".into()));
}
}