1596 lines
56 KiB
Rust
1596 lines
56 KiB
Rust
use std::collections::VecDeque;
|
||
use std::time::{Duration, Instant};
|
||
|
||
use gpui::{
|
||
App, Application, Bounds, Context, FocusHandle, FontWeight, KeyBinding, KeyDownEvent, Pixels,
|
||
Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, size,
|
||
};
|
||
use lumbridge_runtime::{
|
||
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, TerminalCellStyle, TerminalColor, TerminalCursorShape, TerminalDimensions,
|
||
TerminalEngine, TerminalEngineOptions, TerminalKey, TerminalKeyEvent, TerminalNamedColor,
|
||
TerminalSnapshot,
|
||
};
|
||
|
||
const BG: u32 = 0x090c12;
|
||
const PANEL: u32 = 0x101620;
|
||
const PANEL_ALT: u32 = 0x151d29;
|
||
const PANEL_ACTIVE: u32 = 0x182334;
|
||
const BORDER: u32 = 0x263246;
|
||
const BORDER_QUIET: u32 = 0x1c2636;
|
||
const TEXT: u32 = 0xdbe5f4;
|
||
const MUTED: u32 = 0x8290a8;
|
||
const ACCENT: u32 = 0x68b5f8;
|
||
const ATTENTION: u32 = 0xf1b96a;
|
||
const SUCCESS: u32 = 0x70d6a8;
|
||
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 = "printf 'Lumbridge interactive PTY · type here\\n'; exec /bin/sh -i";
|
||
const SIDEBAR_WIDTH: f32 = 248.0;
|
||
const APP_HEADER_HEIGHT: f32 = 48.0;
|
||
const TAB_BAR_HEIGHT: f32 = 38.0;
|
||
const APP_FOOTER_HEIGHT: f32 = 32.0;
|
||
const TERMINAL_CHROME_HEIGHT: f32 = 72.0;
|
||
const TERMINAL_HORIZONTAL_INSET: f32 = 32.0;
|
||
const TERMINAL_CELL_WIDTH: f32 = 8.4;
|
||
const TERMINAL_CELL_HEIGHT: f32 = 18.0;
|
||
const TERMINAL_ROW_STEP: u16 = 2;
|
||
const TERMINAL_COLUMN_STEP: u16 = 10;
|
||
|
||
actions!(
|
||
lumbridge,
|
||
[
|
||
FocusLeft,
|
||
FocusRight,
|
||
FocusUp,
|
||
FocusDown,
|
||
OpenPalette,
|
||
ClosePalette,
|
||
SelectPane1,
|
||
SelectPane2,
|
||
SelectPane3,
|
||
SelectPane4,
|
||
SelectPane5,
|
||
SelectPane6,
|
||
TerminalTaller,
|
||
TerminalShorter,
|
||
TerminalWider,
|
||
TerminalNarrower,
|
||
]
|
||
);
|
||
|
||
struct LumbridgeShell {
|
||
model: ShellModel,
|
||
timing: RenderTiming,
|
||
runtime: Option<RuntimeActor>,
|
||
runtime_status: LiveRuntimeStatus,
|
||
terminal: TerminalEngine,
|
||
terminal_snapshot: TerminalSnapshot,
|
||
last_runtime_sequence: u64,
|
||
root_focus: FocusHandle,
|
||
pane_focus: [FocusHandle; 6],
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
enum LiveRuntimeStatus {
|
||
Starting,
|
||
Running {
|
||
session_id: u64,
|
||
process_id: Option<u32>,
|
||
},
|
||
Exited(String),
|
||
Fault(String),
|
||
}
|
||
|
||
impl LiveRuntimeStatus {
|
||
fn badge(&self) -> &'static str {
|
||
match self {
|
||
Self::Starting => "PTY STARTING",
|
||
Self::Running { .. } => "LIVE PTY",
|
||
Self::Exited(_) => "PTY EXITED",
|
||
Self::Fault(_) => "PTY FAULT",
|
||
}
|
||
}
|
||
|
||
fn detail(&self) -> String {
|
||
match self {
|
||
Self::Starting => "local · runtime actor starting".to_owned(),
|
||
Self::Running {
|
||
session_id,
|
||
process_id,
|
||
} => match process_id {
|
||
Some(process_id) => {
|
||
format!("local · runtime session {session_id} · pid {process_id}")
|
||
}
|
||
None => format!("local · runtime session {session_id}"),
|
||
},
|
||
Self::Exited(status) => format!("local · {status}"),
|
||
Self::Fault(message) => format!("local · {message}"),
|
||
}
|
||
}
|
||
|
||
const fn is_fault(&self) -> bool {
|
||
matches!(self, Self::Fault(_))
|
||
}
|
||
|
||
const fn is_terminal(&self) -> bool {
|
||
matches!(self, Self::Exited(_) | Self::Fault(_))
|
||
}
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct RenderTiming {
|
||
pending_dispatch: Option<Instant>,
|
||
dispatch_to_element_micros: VecDeque<u128>,
|
||
}
|
||
|
||
#[derive(Clone, Eq, PartialEq)]
|
||
struct TerminalPaintRun {
|
||
text: String,
|
||
columns: u16,
|
||
foreground: u32,
|
||
background: u32,
|
||
style: TerminalCellStyle,
|
||
cursor: Option<TerminalCursorShape>,
|
||
hyperlink: bool,
|
||
}
|
||
|
||
impl TerminalPaintRun {
|
||
fn can_merge(&self, other: &Self) -> bool {
|
||
self.foreground == other.foreground
|
||
&& self.background == other.background
|
||
&& self.style == other.style
|
||
&& self.cursor == other.cursor
|
||
&& self.hyperlink == other.hyperlink
|
||
}
|
||
}
|
||
|
||
fn terminal_paint_rows(snapshot: &TerminalSnapshot) -> Vec<Vec<TerminalPaintRun>> {
|
||
(0..snapshot.dimensions.rows())
|
||
.map(|row| {
|
||
let mut runs: Vec<TerminalPaintRun> = Vec::new();
|
||
for column in 0..snapshot.dimensions.columns() {
|
||
let Some(cell) = snapshot.cell(row, column) else {
|
||
continue;
|
||
};
|
||
if cell.wide_spacer {
|
||
continue;
|
||
}
|
||
|
||
let mut foreground = terminal_color(cell.foreground);
|
||
let mut background = terminal_color(cell.background);
|
||
if cell.style.contains(TerminalCellStyle::INVERSE) {
|
||
std::mem::swap(&mut foreground, &mut background);
|
||
}
|
||
if cell.style.contains(TerminalCellStyle::DIM) {
|
||
foreground = dim_color(foreground);
|
||
}
|
||
if cell.style.contains(TerminalCellStyle::HIDDEN) {
|
||
foreground = background;
|
||
}
|
||
|
||
let cursor = (snapshot.cursor.row == row
|
||
&& snapshot.cursor.column == column
|
||
&& snapshot.cursor.shape != TerminalCursorShape::Hidden)
|
||
.then_some(snapshot.cursor.shape);
|
||
let text = if cell.text.is_empty() {
|
||
" ".to_owned()
|
||
} else {
|
||
cell.text.clone()
|
||
};
|
||
let next = TerminalPaintRun {
|
||
text,
|
||
columns: if cell.wide { 2 } else { 1 },
|
||
foreground,
|
||
background,
|
||
style: cell.style,
|
||
cursor,
|
||
hyperlink: cell.hyperlink.is_some(),
|
||
};
|
||
|
||
if let Some(current) = runs.last_mut()
|
||
&& current.can_merge(&next)
|
||
{
|
||
current.text.push_str(&next.text);
|
||
current.columns = current.columns.saturating_add(next.columns);
|
||
continue;
|
||
}
|
||
runs.push(next);
|
||
}
|
||
runs
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn terminal_color(color: TerminalColor) -> u32 {
|
||
match color {
|
||
TerminalColor::Rgb { red, green, blue } => {
|
||
(u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue)
|
||
}
|
||
TerminalColor::Indexed(index) => indexed_terminal_color(index),
|
||
TerminalColor::Named(named) => match named {
|
||
TerminalNamedColor::Black => 0x1d2430,
|
||
TerminalNamedColor::Red => 0xff6b6b,
|
||
TerminalNamedColor::Green => 0x70d6a8,
|
||
TerminalNamedColor::Yellow => 0xf1c76a,
|
||
TerminalNamedColor::Blue => 0x68b5f8,
|
||
TerminalNamedColor::Magenta => 0xc79bf2,
|
||
TerminalNamedColor::Cyan => 0x63d5da,
|
||
TerminalNamedColor::White => 0xdbe5f4,
|
||
TerminalNamedColor::BrightBlack => 0x6d7a91,
|
||
TerminalNamedColor::BrightRed => 0xff8b8b,
|
||
TerminalNamedColor::BrightGreen => 0x93e6be,
|
||
TerminalNamedColor::BrightYellow => 0xf8d98c,
|
||
TerminalNamedColor::BrightBlue => 0x8bc8ff,
|
||
TerminalNamedColor::BrightMagenta => 0xd9b4fb,
|
||
TerminalNamedColor::BrightCyan => 0x86e7eb,
|
||
TerminalNamedColor::BrightWhite | TerminalNamedColor::BrightForeground => 0xf4f8ff,
|
||
TerminalNamedColor::Foreground => TEXT,
|
||
TerminalNamedColor::Background => BG,
|
||
TerminalNamedColor::Cursor => 0xf4f8ff,
|
||
TerminalNamedColor::DimBlack => 0x121820,
|
||
TerminalNamedColor::DimRed => 0x9f4a4a,
|
||
TerminalNamedColor::DimGreen => 0x4e9878,
|
||
TerminalNamedColor::DimYellow => 0xa88a49,
|
||
TerminalNamedColor::DimBlue => 0x477fac,
|
||
TerminalNamedColor::DimMagenta => 0x896ca4,
|
||
TerminalNamedColor::DimCyan => 0x459397,
|
||
TerminalNamedColor::DimWhite | TerminalNamedColor::DimForeground => MUTED,
|
||
},
|
||
}
|
||
}
|
||
|
||
fn indexed_terminal_color(index: u8) -> u32 {
|
||
const ANSI: [u32; 16] = [
|
||
0x1d2430, 0xff6b6b, 0x70d6a8, 0xf1c76a, 0x68b5f8, 0xc79bf2, 0x63d5da, 0xdbe5f4, 0x6d7a91,
|
||
0xff8b8b, 0x93e6be, 0xf8d98c, 0x8bc8ff, 0xd9b4fb, 0x86e7eb, 0xf4f8ff,
|
||
];
|
||
match index {
|
||
0..=15 => ANSI[usize::from(index)],
|
||
16..=231 => {
|
||
const LEVELS: [u32; 6] = [0, 95, 135, 175, 215, 255];
|
||
let offset = u32::from(index - 16);
|
||
let red = LEVELS[(offset / 36) as usize];
|
||
let green = LEVELS[((offset % 36) / 6) as usize];
|
||
let blue = LEVELS[(offset % 6) as usize];
|
||
(red << 16) | (green << 8) | blue
|
||
}
|
||
232..=255 => {
|
||
let level = 8 + 10 * u32::from(index - 232);
|
||
(level << 16) | (level << 8) | level
|
||
}
|
||
}
|
||
}
|
||
|
||
fn dim_color(color: u32) -> u32 {
|
||
let dim = |channel: u32| channel * 13 / 20;
|
||
(dim((color >> 16) & 0xff) << 16) | (dim((color >> 8) & 0xff) << 8) | dim(color & 0xff)
|
||
}
|
||
|
||
fn terminal_dimensions_for_window(window_size: Size<Pixels>) -> TerminalDimensions {
|
||
let width = f32::from(window_size.width);
|
||
let height = f32::from(window_size.height);
|
||
let workspace_height =
|
||
(height - APP_HEADER_HEIGHT - TAB_BAR_HEIGHT - APP_FOOTER_HEIGHT).max(0.0);
|
||
let terminal_height = (workspace_height * 0.60 - TERMINAL_CHROME_HEIGHT).max(0.0);
|
||
let terminal_width = (width - SIDEBAR_WIDTH - TERMINAL_HORIZONTAL_INSET).max(0.0);
|
||
let rows = (terminal_height / TERMINAL_CELL_HEIGHT)
|
||
.floor()
|
||
.clamp(2.0, f32::from(u16::MAX));
|
||
let columns = (terminal_width / TERMINAL_CELL_WIDTH)
|
||
.floor()
|
||
.clamp(20.0, f32::from(u16::MAX));
|
||
TerminalDimensions::with_cell_size(
|
||
rows as u16,
|
||
columns as u16,
|
||
TERMINAL_CELL_WIDTH.round() as u16,
|
||
TERMINAL_CELL_HEIGHT.round() as u16,
|
||
)
|
||
.expect("geometry clamps terminal dimensions above zero")
|
||
}
|
||
|
||
impl RenderTiming {
|
||
fn mark_dispatch(&mut self) {
|
||
self.pending_dispatch = Some(Instant::now());
|
||
}
|
||
|
||
fn observe_element_build(&mut self) {
|
||
let Some(started) = self.pending_dispatch.take() else {
|
||
return;
|
||
};
|
||
if self.dispatch_to_element_micros.len() == TIMING_SAMPLE_LIMIT {
|
||
self.dispatch_to_element_micros.pop_front();
|
||
}
|
||
self.dispatch_to_element_micros
|
||
.push_back(started.elapsed().as_micros());
|
||
}
|
||
|
||
fn summary(&self) -> String {
|
||
if self.dispatch_to_element_micros.is_empty() {
|
||
return "dispatch→element collecting…".to_owned();
|
||
}
|
||
let mut samples = self
|
||
.dispatch_to_element_micros
|
||
.iter()
|
||
.copied()
|
||
.collect::<Vec<_>>();
|
||
samples.sort_unstable();
|
||
let percentile = |percent: usize| {
|
||
let index = ((samples.len() - 1) * percent) / 100;
|
||
samples[index]
|
||
};
|
||
format!(
|
||
"dispatch→element p50 {}µs · p95 {}µs · n{}",
|
||
percentile(50),
|
||
percentile(95),
|
||
samples.len()
|
||
)
|
||
}
|
||
}
|
||
|
||
impl LumbridgeShell {
|
||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||
let pane_focus = std::array::from_fn(|index| {
|
||
cx.focus_handle()
|
||
.tab_index(index as isize + 1)
|
||
.tab_stop(true)
|
||
});
|
||
let root_focus = cx.focus_handle();
|
||
window.focus(&pane_focus[0]);
|
||
|
||
cx.spawn(async move |this, cx| {
|
||
loop {
|
||
cx.background_executor()
|
||
.timer(Duration::from_millis(650))
|
||
.await;
|
||
if this
|
||
.update(cx, |shell, cx| {
|
||
shell.dispatch(ShellAction::SyntheticStreamTick);
|
||
cx.notify();
|
||
})
|
||
.is_err()
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
})
|
||
.detach();
|
||
|
||
cx.spawn(async move |this, cx| {
|
||
loop {
|
||
cx.background_executor().timer(RUNTIME_POLL_INTERVAL).await;
|
||
if this
|
||
.update(cx, |shell, cx| {
|
||
if shell.drain_runtime_events() {
|
||
cx.notify();
|
||
}
|
||
})
|
||
.is_err()
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
})
|
||
.detach();
|
||
|
||
let terminal_dimensions = terminal_dimensions_for_window(window.bounds().size);
|
||
let terminal = TerminalEngine::new(TerminalEngineOptions {
|
||
dimensions: terminal_dimensions,
|
||
..TerminalEngineOptions::default()
|
||
});
|
||
let terminal_snapshot = terminal.snapshot();
|
||
let (runtime, runtime_status) = match start_live_runtime(terminal.dimensions()) {
|
||
Ok(runtime) => (Some(runtime), LiveRuntimeStatus::Starting),
|
||
Err(error) => (None, LiveRuntimeStatus::Fault(error.to_string())),
|
||
};
|
||
|
||
cx.observe_window_bounds(window, |shell, window, cx| {
|
||
let dimensions = terminal_dimensions_for_window(window.bounds().size);
|
||
shell.resize_terminal(dimensions.rows(), dimensions.columns(), cx);
|
||
})
|
||
.detach();
|
||
|
||
Self {
|
||
model: ShellModel::with_external_output(LIVE_PANE)
|
||
.expect("the live comparison pane is a terminal"),
|
||
timing: RenderTiming::default(),
|
||
runtime,
|
||
runtime_status,
|
||
terminal,
|
||
terminal_snapshot,
|
||
last_runtime_sequence: 0,
|
||
root_focus,
|
||
pane_focus,
|
||
}
|
||
}
|
||
|
||
fn dispatch(&mut self, action: ShellAction) -> ActionOutcome {
|
||
self.timing.mark_dispatch();
|
||
self.model.dispatch(action)
|
||
}
|
||
|
||
fn drain_runtime_events(&mut self) -> bool {
|
||
if self.runtime_status.is_terminal() {
|
||
return false;
|
||
}
|
||
|
||
let mut changed = false;
|
||
for _ in 0..RUNTIME_DRAIN_LIMIT {
|
||
let event = match self.runtime.as_ref().map(RuntimeActor::try_recv) {
|
||
Some(Ok(Some(event))) => event,
|
||
Some(Ok(None)) | None => break,
|
||
Some(Err(RuntimeActorError::Disconnected)) => {
|
||
self.runtime_status =
|
||
LiveRuntimeStatus::Fault("runtime actor disconnected".to_owned());
|
||
changed = true;
|
||
break;
|
||
}
|
||
Some(Err(error)) => {
|
||
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string());
|
||
changed = true;
|
||
break;
|
||
}
|
||
};
|
||
|
||
match event {
|
||
RuntimeEvent::Started {
|
||
session_id,
|
||
process_id,
|
||
} => {
|
||
self.runtime_status = LiveRuntimeStatus::Running {
|
||
session_id: session_id.get(),
|
||
process_id,
|
||
};
|
||
changed = true;
|
||
}
|
||
RuntimeEvent::Output {
|
||
sequence, bytes, ..
|
||
} => {
|
||
if sequence <= self.last_runtime_sequence {
|
||
self.runtime_status = LiveRuntimeStatus::Fault(format!(
|
||
"non-monotonic PTY output sequence {sequence}"
|
||
));
|
||
changed = true;
|
||
break;
|
||
}
|
||
self.last_runtime_sequence = sequence;
|
||
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, .. } => {
|
||
self.runtime_status =
|
||
LiveRuntimeStatus::Exited(format!("PTY exited with code {}", status.code));
|
||
changed = true;
|
||
break;
|
||
}
|
||
RuntimeEvent::Fault {
|
||
operation, message, ..
|
||
} => {
|
||
self.runtime_status =
|
||
LiveRuntimeStatus::Fault(format!("{operation:?}: {message}"));
|
||
changed = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
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 snapshot = self.terminal.snapshot();
|
||
let lines = snapshot.plain_rows();
|
||
self.terminal_snapshot = snapshot;
|
||
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()]);
|
||
cx.notify();
|
||
}
|
||
|
||
fn move_focus(
|
||
&mut self,
|
||
direction: FocusDirection,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
let outcome = self.dispatch(ShellAction::MoveFocus(direction));
|
||
window.focus(&self.pane_focus[outcome.selected_pane.index()]);
|
||
cx.notify();
|
||
}
|
||
|
||
fn focus_left(&mut self, _: &FocusLeft, window: &mut Window, cx: &mut Context<Self>) {
|
||
self.move_focus(FocusDirection::Left, window, cx);
|
||
}
|
||
|
||
fn focus_right(&mut self, _: &FocusRight, window: &mut Window, cx: &mut Context<Self>) {
|
||
self.move_focus(FocusDirection::Right, window, cx);
|
||
}
|
||
|
||
fn focus_up(&mut self, _: &FocusUp, window: &mut Window, cx: &mut Context<Self>) {
|
||
self.move_focus(FocusDirection::Up, window, cx);
|
||
}
|
||
|
||
fn focus_down(&mut self, _: &FocusDown, window: &mut Window, cx: &mut Context<Self>) {
|
||
self.move_focus(FocusDirection::Down, window, cx);
|
||
}
|
||
|
||
fn open_palette(&mut self, _: &OpenPalette, _: &mut Window, cx: &mut Context<Self>) {
|
||
self.dispatch(ShellAction::OpenCommandPalette);
|
||
cx.notify();
|
||
}
|
||
|
||
fn close_palette(&mut self, _: &ClosePalette, _: &mut Window, cx: &mut Context<Self>) {
|
||
self.dispatch(ShellAction::CloseCommandPalette);
|
||
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;
|
||
}
|
||
|
||
let mut query = self.model.command_palette().query().to_owned();
|
||
match event.keystroke.key.as_str() {
|
||
"backspace" => {
|
||
query.pop();
|
||
}
|
||
"enter" => {
|
||
self.dispatch(ShellAction::CloseCommandPalette);
|
||
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
|
||
&& !event.keystroke.modifiers.platform
|
||
{
|
||
query.push_str(character);
|
||
} else {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
self.dispatch(ShellAction::SetCommandPaletteQuery(query));
|
||
cx.notify();
|
||
}
|
||
|
||
fn pane_selector(
|
||
pane: &PaneState,
|
||
selected: bool,
|
||
runtime_status: LiveRuntimeStatus,
|
||
focus: FocusHandle,
|
||
cx: &mut Context<Self>,
|
||
) -> gpui::AnyElement {
|
||
let id = pane.id();
|
||
let needs_input = pane.needs_input();
|
||
let external = pane.output_source() == OutputSource::External;
|
||
let label = if external {
|
||
runtime_status.badge()
|
||
} else {
|
||
match pane.kind() {
|
||
SurfaceKind::Terminal => pane.status().label(),
|
||
_ => pane.fixture().badge,
|
||
}
|
||
};
|
||
let state_color = if needs_input || (external && runtime_status.is_fault()) {
|
||
ATTENTION
|
||
} else if matches!(pane.kind(), SurfaceKind::Terminal) {
|
||
SUCCESS
|
||
} else {
|
||
ACCENT
|
||
};
|
||
|
||
div()
|
||
.id(("pane", id.index()))
|
||
.track_focus(&focus)
|
||
.tab_index(id.index() as isize + 1)
|
||
.flex()
|
||
.items_center()
|
||
.justify_between()
|
||
.gap_3()
|
||
.h_full()
|
||
.min_w(px(172.0))
|
||
.px_3()
|
||
.bg(rgb(if selected { PANEL_ACTIVE } else { PANEL_ALT }))
|
||
.border_1()
|
||
.border_color(rgb(if selected || needs_input {
|
||
state_color
|
||
} else {
|
||
BORDER_QUIET
|
||
}))
|
||
.rounded(px(5.0))
|
||
.on_click(cx.listener(move |shell, _, window, cx| {
|
||
shell.select_pane(id, window, cx);
|
||
}))
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.flex_col()
|
||
.min_w_0()
|
||
.child(
|
||
div()
|
||
.truncate()
|
||
.text_sm()
|
||
.text_color(rgb(TEXT))
|
||
.child(if external {
|
||
"Terminal · local shell"
|
||
} else {
|
||
pane.fixture().title
|
||
}),
|
||
)
|
||
.child(
|
||
div()
|
||
.mt_1()
|
||
.truncate()
|
||
.text_xs()
|
||
.text_color(rgb(MUTED))
|
||
.child(pane.fixture().target.to_owned()),
|
||
),
|
||
)
|
||
.child(div().text_xs().text_color(rgb(state_color)).child(label))
|
||
.into_any_element()
|
||
}
|
||
|
||
fn terminal_run(run: TerminalPaintRun) -> gpui::AnyElement {
|
||
let cursor = run.cursor;
|
||
div()
|
||
.flex_none()
|
||
.h(px(TERMINAL_CELL_HEIGHT))
|
||
.w(px(f32::from(run.columns) * TERMINAL_CELL_WIDTH))
|
||
.overflow_hidden()
|
||
.whitespace_nowrap()
|
||
.text_color(rgb(run.foreground))
|
||
.bg(rgb(run.background))
|
||
.when(run.style.contains(TerminalCellStyle::BOLD), |view| {
|
||
view.font_weight(FontWeight::BOLD)
|
||
})
|
||
.when(run.style.contains(TerminalCellStyle::ITALIC), |view| {
|
||
view.italic()
|
||
})
|
||
.when(
|
||
run.style.contains(TerminalCellStyle::UNDERLINE)
|
||
|| run.style.contains(TerminalCellStyle::DOUBLE_UNDERLINE)
|
||
|| run.style.contains(TerminalCellStyle::UNDERCURL)
|
||
|| run.style.contains(TerminalCellStyle::DOTTED_UNDERLINE)
|
||
|| run.style.contains(TerminalCellStyle::DASHED_UNDERLINE)
|
||
|| run.hyperlink,
|
||
|view| view.underline(),
|
||
)
|
||
.when(run.style.contains(TerminalCellStyle::STRIKEOUT), |view| {
|
||
view.line_through()
|
||
})
|
||
.when(cursor == Some(TerminalCursorShape::Block), |view| {
|
||
view.bg(rgb(TEXT)).text_color(rgb(BG))
|
||
})
|
||
.when(cursor == Some(TerminalCursorShape::HollowBlock), |view| {
|
||
view.border_1().border_color(rgb(TEXT))
|
||
})
|
||
.when(cursor == Some(TerminalCursorShape::Underline), |view| {
|
||
view.border_b_2().border_color(rgb(TEXT))
|
||
})
|
||
.when(cursor == Some(TerminalCursorShape::Beam), |view| {
|
||
view.border_l_2().border_color(rgb(TEXT))
|
||
})
|
||
.child(run.text)
|
||
.into_any_element()
|
||
}
|
||
|
||
fn terminal_view(&self) -> gpui::AnyElement {
|
||
let rows = terminal_paint_rows(&self.terminal_snapshot)
|
||
.into_iter()
|
||
.map(|runs| {
|
||
div()
|
||
.flex()
|
||
.h(px(TERMINAL_CELL_HEIGHT))
|
||
.flex_none()
|
||
.children(runs.into_iter().map(Self::terminal_run))
|
||
})
|
||
.collect::<Vec<_>>();
|
||
div()
|
||
.flex()
|
||
.flex_col()
|
||
.size_full()
|
||
.overflow_hidden()
|
||
.bg(rgb(BG))
|
||
.font_family("monospace")
|
||
.text_size(px(13.0))
|
||
.children(rows)
|
||
.into_any_element()
|
||
}
|
||
|
||
fn work_surface(&self) -> gpui::AnyElement {
|
||
let pane = self.model.pane(self.model.selected_pane());
|
||
let external = pane.output_source() == OutputSource::External;
|
||
let status = if external {
|
||
self.runtime_status.badge()
|
||
} else {
|
||
pane.fixture().badge
|
||
};
|
||
let detail = if external {
|
||
self.runtime_status.detail()
|
||
} else {
|
||
pane.fixture().target.to_owned()
|
||
};
|
||
let surface_status = if external {
|
||
format!(
|
||
"{} · {}×{} · auto-fit",
|
||
status,
|
||
self.terminal.dimensions().columns(),
|
||
self.terminal.dimensions().rows()
|
||
)
|
||
} else {
|
||
status.to_owned()
|
||
};
|
||
let surface = match pane.kind() {
|
||
SurfaceKind::Terminal => "TERMINAL",
|
||
SurfaceKind::Markdown => "CONTEXT",
|
||
SurfaceKind::Browser => "BROWSER",
|
||
SurfaceKind::Review => "REVIEW",
|
||
};
|
||
let tabs = ["TERMINAL", "BROWSER", "TOOLS", "CONTEXT", "GOAL", "REVIEW"]
|
||
.into_iter()
|
||
.map(|label| {
|
||
div()
|
||
.h_full()
|
||
.flex()
|
||
.items_center()
|
||
.px_3()
|
||
.text_xs()
|
||
.text_color(rgb(if label == surface { ACCENT } else { MUTED }))
|
||
.when(label == surface, |view| {
|
||
view.border_b_2().border_color(rgb(ACCENT))
|
||
})
|
||
.child(label)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let content = if external {
|
||
self.terminal_view()
|
||
} else {
|
||
let start = pane.lines().len().saturating_sub(18);
|
||
div()
|
||
.flex()
|
||
.flex_col()
|
||
.gap_1()
|
||
.size_full()
|
||
.overflow_hidden()
|
||
.font_family("monospace")
|
||
.text_sm()
|
||
.text_color(rgb(TEXT))
|
||
.children(pane.lines()[start..].iter().cloned())
|
||
.into_any_element()
|
||
};
|
||
|
||
div()
|
||
.flex()
|
||
.flex_col()
|
||
.size_full()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.bg(rgb(PANEL))
|
||
.border_y_1()
|
||
.border_color(rgb(BORDER))
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.items_center()
|
||
.justify_between()
|
||
.h(px(36.0))
|
||
.flex_none()
|
||
.px_3()
|
||
.bg(rgb(PANEL_ALT))
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.items_center()
|
||
.gap_3()
|
||
.text_sm()
|
||
.child(pane.fixture().title)
|
||
.child(div().text_xs().text_color(rgb(MUTED)).child(detail)),
|
||
)
|
||
.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(rgb(SUCCESS))
|
||
.child(surface_status),
|
||
),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.h(px(28.0))
|
||
.flex_none()
|
||
.px_1()
|
||
.bg(rgb(PANEL_ALT))
|
||
.border_t_1()
|
||
.border_color(rgb(BORDER_QUIET))
|
||
.children(tabs),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.p_3()
|
||
.child(content),
|
||
)
|
||
.into_any_element()
|
||
}
|
||
|
||
fn decision_region(&self) -> gpui::AnyElement {
|
||
let choice = |label: &'static str, detail: &'static str| {
|
||
div()
|
||
.flex()
|
||
.flex_col()
|
||
.min_w(px(200.0))
|
||
.px_3()
|
||
.py_2()
|
||
.rounded(px(5.0))
|
||
.border_1()
|
||
.border_color(rgb(BORDER))
|
||
.bg(rgb(PANEL_ALT))
|
||
.text_sm()
|
||
.text_color(rgb(TEXT))
|
||
.child(label)
|
||
.child(div().mt_1().text_xs().text_color(rgb(MUTED)).child(detail))
|
||
};
|
||
div()
|
||
.flex()
|
||
.flex_col()
|
||
.size_full()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.px_3()
|
||
.py_2()
|
||
.bg(rgb(PANEL))
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.items_center()
|
||
.justify_between()
|
||
.text_xs()
|
||
.child(
|
||
div()
|
||
.text_color(rgb(MUTED))
|
||
.child("DECISION SHELF · answer, choices, approvals"),
|
||
)
|
||
.child(
|
||
div()
|
||
.text_color(rgb(ATTENTION))
|
||
.child("LOCAL ANALYST OFF · suggestions inert"),
|
||
),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.items_center()
|
||
.gap_2()
|
||
.mt_2()
|
||
.child(choice("Continue", "Keep the selected agent moving"))
|
||
.child(choice("Review plan", "Inspect capability-scoped commands"))
|
||
.child(choice("Ask…", "Refine before any action")),
|
||
)
|
||
.into_any_element()
|
||
}
|
||
|
||
fn command_palette(&self) -> impl IntoElement {
|
||
let query = self.model.command_palette().query();
|
||
let prompt = if query.is_empty() {
|
||
"Type a command…".to_owned()
|
||
} else {
|
||
query.to_owned()
|
||
};
|
||
|
||
div()
|
||
.absolute()
|
||
.inset_0()
|
||
.flex()
|
||
.justify_center()
|
||
.items_start()
|
||
.pt(px(92.0))
|
||
.bg(gpui::black().opacity(0.72))
|
||
.child(
|
||
div()
|
||
.w(px(620.0))
|
||
.overflow_hidden()
|
||
.bg(rgb(PANEL_ALT))
|
||
.border_1()
|
||
.border_color(rgb(ACCENT))
|
||
.rounded(px(8.0))
|
||
.child(
|
||
div()
|
||
.px_4()
|
||
.py_3()
|
||
.border_b_1()
|
||
.border_color(rgb(BORDER))
|
||
.text_color(rgb(if query.is_empty() { MUTED } else { TEXT }))
|
||
.child(prompt),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.flex_col()
|
||
.p_2()
|
||
.text_sm()
|
||
.child(
|
||
div()
|
||
.px_3()
|
||
.py_2()
|
||
.rounded(px(5.0))
|
||
.bg(rgb(PANEL_ACTIVE))
|
||
.child("Focus next pane")
|
||
.child(
|
||
div()
|
||
.mt_1()
|
||
.text_xs()
|
||
.text_color(rgb(MUTED))
|
||
.child("Workspace · navigation"),
|
||
),
|
||
)
|
||
.child(
|
||
div()
|
||
.px_3()
|
||
.py_2()
|
||
.text_color(rgb(MUTED))
|
||
.child("Open attention request"),
|
||
)
|
||
.child(
|
||
div()
|
||
.px_3()
|
||
.py_2()
|
||
.text_color(rgb(MUTED))
|
||
.child("Share selected pane to Buzz…"),
|
||
),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.justify_between()
|
||
.px_4()
|
||
.py_2()
|
||
.border_t_1()
|
||
.border_color(rgb(BORDER))
|
||
.text_xs()
|
||
.text_color(rgb(MUTED))
|
||
.child("Enter to run")
|
||
.child("Esc to close"),
|
||
),
|
||
)
|
||
}
|
||
}
|
||
|
||
impl Render for LumbridgeShell {
|
||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||
let attention = self.model.pane(PaneId::ClaudeUi);
|
||
let sidebar = div()
|
||
.flex()
|
||
.flex_col()
|
||
.w(px(248.0))
|
||
.flex_none()
|
||
.bg(rgb(PANEL))
|
||
.border_r_1()
|
||
.border_color(rgb(BORDER_QUIET))
|
||
.child(
|
||
div()
|
||
.px_4()
|
||
.pt_4()
|
||
.pb_2()
|
||
.text_xs()
|
||
.text_color(rgb(ATTENTION))
|
||
.child("ATTENTION · 1"),
|
||
)
|
||
.child(
|
||
div()
|
||
.mx_2()
|
||
.mb_3()
|
||
.px_3()
|
||
.py_2()
|
||
.rounded(px(5.0))
|
||
.bg(rgb(PANEL_ACTIVE))
|
||
.border_1()
|
||
.border_color(rgb(ATTENTION))
|
||
.child(
|
||
div()
|
||
.text_sm()
|
||
.text_color(rgb(TEXT))
|
||
.child(attention.fixture().title),
|
||
)
|
||
.child(
|
||
div()
|
||
.mt_1()
|
||
.text_xs()
|
||
.text_color(rgb(MUTED))
|
||
.child("Waiting for a split decision"),
|
||
),
|
||
)
|
||
.child(
|
||
div()
|
||
.px_4()
|
||
.py_2()
|
||
.text_xs()
|
||
.text_color(rgb(MUTED))
|
||
.child("WORKTREES"),
|
||
)
|
||
.children(WORKSPACES.into_iter().enumerate().map(|(index, name)| {
|
||
div()
|
||
.mx_2()
|
||
.mb_1()
|
||
.px_3()
|
||
.py_2()
|
||
.rounded(px(5.0))
|
||
.when(index == 0, |view| {
|
||
view.bg(rgb(PANEL_ALT)).text_color(rgb(TEXT))
|
||
})
|
||
.when(index != 0, |view| view.text_color(rgb(MUTED)))
|
||
.child(name)
|
||
.when(index == 0, |view| {
|
||
view.child(
|
||
div()
|
||
.mt_1()
|
||
.text_xs()
|
||
.text_color(rgb(MUTED))
|
||
.child("main · metal"),
|
||
)
|
||
})
|
||
}))
|
||
.child(
|
||
div()
|
||
.mt_3()
|
||
.px_4()
|
||
.py_2()
|
||
.text_xs()
|
||
.text_color(rgb(MUTED))
|
||
.child("RUNTIMES"),
|
||
)
|
||
.child(
|
||
div()
|
||
.px_4()
|
||
.py_1()
|
||
.text_sm()
|
||
.text_color(rgb(SUCCESS))
|
||
.child(format!("metal · {}", self.runtime_status.badge())),
|
||
)
|
||
.child(
|
||
div()
|
||
.px_4()
|
||
.py_1()
|
||
.text_sm()
|
||
.text_color(rgb(MUTED))
|
||
.child("amd-server · connected"),
|
||
)
|
||
.child(
|
||
div()
|
||
.px_4()
|
||
.py_1()
|
||
.text_sm()
|
||
.text_color(rgb(MUTED))
|
||
.child("spark-1 · sleeping"),
|
||
)
|
||
.child(div().flex_1())
|
||
.child(
|
||
div()
|
||
.m_3()
|
||
.p_3()
|
||
.rounded(px(5.0))
|
||
.bg(rgb(PANEL_ALT))
|
||
.text_xs()
|
||
.text_color(rgb(MUTED))
|
||
.child("Buzz · lumbridgecode")
|
||
.child(
|
||
div()
|
||
.mt_1()
|
||
.text_color(rgb(SUCCESS))
|
||
.child("connected · signed identity"),
|
||
),
|
||
);
|
||
|
||
let tabs = div()
|
||
.flex()
|
||
.items_center()
|
||
.h(px(38.0))
|
||
.flex_none()
|
||
.px_2()
|
||
.gap_1()
|
||
.bg(rgb(PANEL))
|
||
.border_b_1()
|
||
.border_color(rgb(BORDER_QUIET))
|
||
.child(
|
||
div()
|
||
.h_full()
|
||
.flex()
|
||
.items_center()
|
||
.px_3()
|
||
.border_b_2()
|
||
.border_color(rgb(ACCENT))
|
||
.text_sm()
|
||
.child("Agent workspace"),
|
||
)
|
||
.child(
|
||
div()
|
||
.px_3()
|
||
.text_sm()
|
||
.text_color(rgb(MUTED))
|
||
.child("Architecture.md"),
|
||
)
|
||
.child(
|
||
div()
|
||
.px_3()
|
||
.text_sm()
|
||
.text_color(rgb(MUTED))
|
||
.child("Review"),
|
||
)
|
||
.child(div().flex_1())
|
||
.child(
|
||
div()
|
||
.px_3()
|
||
.text_xs()
|
||
.text_color(rgb(MUTED))
|
||
.child("1 interactive VT · 5 deterministic · 1 waiting"),
|
||
);
|
||
|
||
let pane_selectors = self
|
||
.model
|
||
.panes()
|
||
.iter()
|
||
.map(|pane| {
|
||
Self::pane_selector(
|
||
pane,
|
||
self.model.selected_pane() == pane.id(),
|
||
self.runtime_status.clone(),
|
||
self.pane_focus[pane.id().index()].clone(),
|
||
cx,
|
||
)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let selected = self.model.pane(self.model.selected_pane());
|
||
let workspace_stack = div()
|
||
.flex()
|
||
.flex_col()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.child(
|
||
div()
|
||
.h(relative(0.20))
|
||
.flex_none()
|
||
.flex()
|
||
.flex_col()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.px_3()
|
||
.py_2()
|
||
.bg(rgb(PANEL))
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.items_center()
|
||
.justify_between()
|
||
.text_xs()
|
||
.child(
|
||
div()
|
||
.text_color(rgb(MUTED))
|
||
.child("CONTEXT · panes, agents, tools, goal"),
|
||
)
|
||
.child(
|
||
div()
|
||
.text_color(rgb(if selected.needs_input() {
|
||
ATTENTION
|
||
} else {
|
||
SUCCESS
|
||
}))
|
||
.child(if selected.needs_input() {
|
||
"NEEDS INPUT · decision below"
|
||
} else {
|
||
"WORKSPACE HEALTHY"
|
||
}),
|
||
),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.items_center()
|
||
.gap_2()
|
||
.mt_2()
|
||
.flex_1()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.children(pane_selectors),
|
||
),
|
||
)
|
||
.child(
|
||
div()
|
||
.h(relative(0.60))
|
||
.flex_none()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.child(self.work_surface()),
|
||
)
|
||
.child(
|
||
div()
|
||
.h(relative(0.20))
|
||
.flex_none()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.child(self.decision_region()),
|
||
);
|
||
|
||
let counters = self.model.counters();
|
||
let footer_left = format!(
|
||
"rev {} · VT rev {} · {} focus · {} VT snapshots · {} synthetic lines",
|
||
self.model.revision(),
|
||
self.terminal.revision(),
|
||
counters.focus_moves,
|
||
counters.external_snapshot_updates,
|
||
counters.terminal_lines_appended
|
||
);
|
||
let timing = self.timing.summary();
|
||
|
||
let root = div()
|
||
.id("lumbridge-shell")
|
||
.relative()
|
||
.track_focus(&self.root_focus)
|
||
.key_context("LumbridgeShell")
|
||
.on_action(cx.listener(Self::focus_left))
|
||
.on_action(cx.listener(Self::focus_right))
|
||
.on_action(cx.listener(Self::focus_up))
|
||
.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);
|
||
}))
|
||
.on_action(cx.listener(|shell, _: &SelectPane2, window, cx| {
|
||
shell.select_pane(PaneId::ClaudeUi, window, cx);
|
||
}))
|
||
.on_action(cx.listener(|shell, _: &SelectPane3, window, cx| {
|
||
shell.select_pane(PaneId::PiDocs, window, cx);
|
||
}))
|
||
.on_action(cx.listener(|shell, _: &SelectPane4, window, cx| {
|
||
shell.select_pane(PaneId::Architecture, window, cx);
|
||
}))
|
||
.on_action(cx.listener(|shell, _: &SelectPane5, window, cx| {
|
||
shell.select_pane(PaneId::AcpPreview, window, cx);
|
||
}))
|
||
.on_action(cx.listener(|shell, _: &SelectPane6, window, cx| {
|
||
shell.select_pane(PaneId::RuntimeReview, window, cx);
|
||
}))
|
||
.on_key_down(cx.listener(Self::on_key_down))
|
||
.flex()
|
||
.flex_col()
|
||
.size_full()
|
||
.bg(rgb(BG))
|
||
.text_color(rgb(TEXT))
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.items_center()
|
||
.h(px(48.0))
|
||
.flex_none()
|
||
.px_4()
|
||
.bg(rgb(PANEL_ALT))
|
||
.border_b_1()
|
||
.border_color(rgb(BORDER_QUIET))
|
||
.child(div().text_lg().child("Lumbridge"))
|
||
.child(
|
||
div()
|
||
.ml_3()
|
||
.text_sm()
|
||
.text_color(rgb(MUTED))
|
||
.child("Lumbridge Code / main"),
|
||
)
|
||
.child(div().flex_1())
|
||
.child(
|
||
div()
|
||
.mr_4()
|
||
.text_xs()
|
||
.text_color(rgb(SUCCESS))
|
||
.child(format!("metal · {}", self.runtime_status.badge())),
|
||
)
|
||
.child(
|
||
div()
|
||
.text_sm()
|
||
.text_color(rgb(ACCENT))
|
||
.child("Ctrl/⌘ K · Commands"),
|
||
),
|
||
)
|
||
.child(
|
||
div().flex().flex_1().min_h_0().child(sidebar).child(
|
||
div()
|
||
.flex()
|
||
.flex_col()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.child(tabs)
|
||
.child(workspace_stack),
|
||
),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.items_center()
|
||
.justify_between()
|
||
.h(px(32.0))
|
||
.flex_none()
|
||
.px_3()
|
||
.bg(rgb(PANEL_ALT))
|
||
.border_t_1()
|
||
.border_color(rgb(BORDER_QUIET))
|
||
.text_xs()
|
||
.text_color(rgb(MUTED))
|
||
.child(footer_left)
|
||
.child(timing)
|
||
.child(FOOTER_RIGHT),
|
||
)
|
||
.when(self.model.command_palette().is_open(), |view| {
|
||
view.child(self.command_palette())
|
||
});
|
||
self.timing.observe_element_build();
|
||
root
|
||
}
|
||
}
|
||
|
||
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::new(pty_size),
|
||
RuntimeActorOptions::default(),
|
||
)
|
||
}
|
||
|
||
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-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);
|
||
cx.open_window(
|
||
WindowOptions {
|
||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||
titlebar: Some(gpui::TitlebarOptions {
|
||
title: Some("Lumbridge · GPUI workspace".into()),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
},
|
||
|window, cx| cx.new(|cx| LumbridgeShell::new(window, cx)),
|
||
)
|
||
.expect("GPUI workspace window should open");
|
||
cx.activate(true);
|
||
});
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{
|
||
KeyModifiers, TerminalEngine, TerminalEngineOptions, TerminalKey, indexed_terminal_color,
|
||
terminal_dimensions_for_window, terminal_key_from_parts, terminal_paint_rows,
|
||
};
|
||
use gpui::{px, size};
|
||
|
||
#[test]
|
||
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 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()));
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_geometry_tracks_the_middle_sixty_percent() {
|
||
let dimensions = terminal_dimensions_for_window(size(px(1500.0), px(960.0)));
|
||
assert_eq!(dimensions.rows(), 24);
|
||
assert_eq!(dimensions.columns(), 145);
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_paint_rows_merge_runs_and_preserve_cursor_boundary() {
|
||
let mut terminal = TerminalEngine::new(TerminalEngineOptions::default());
|
||
terminal.process(b"\x1b[31mAB\x1b[0m");
|
||
let rows = terminal_paint_rows(&terminal.snapshot());
|
||
assert!(
|
||
rows[0]
|
||
.iter()
|
||
.any(|run| run.text == "AB" && run.columns == 2)
|
||
);
|
||
assert!(rows[0].iter().any(|run| run.cursor.is_some()));
|
||
}
|
||
|
||
#[test]
|
||
fn xterm_color_cube_and_grayscale_are_deterministic() {
|
||
assert_eq!(indexed_terminal_color(16), 0x000000);
|
||
assert_eq!(indexed_terminal_color(231), 0xffffff);
|
||
assert_eq!(indexed_terminal_color(232), 0x080808);
|
||
assert_eq!(indexed_terminal_color(255), 0xeeeeee);
|
||
}
|
||
}
|