1995 lines
71 KiB
Rust
1995 lines
71 KiB
Rust
use std::collections::{BTreeMap, 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, RuntimeActorError, RuntimeActorOptions, RuntimeCommand,
|
||
RuntimeEvent, RuntimeRegistry, RuntimeRegistryError, TerminalSize,
|
||
};
|
||
use lumbridge_spike_model::{
|
||
ActionOutcome, FOOTER_RIGHT, FocusDirection, INITIAL_ATTACHED_PANEL_COUNT, OutputSource,
|
||
PaneId, PaneState, ShellAction, ShellModel, SurfaceKind, WORKSPACES,
|
||
};
|
||
use lumbridge_terminal::{
|
||
KeyModifiers, TerminalCellStyle, TerminalColor, TerminalCursorShape, TerminalDimensions,
|
||
TerminalEngine, TerminalEngineOptions, TerminalKey, TerminalKeyEvent, TerminalNamedColor,
|
||
TerminalScroll, 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_PANES: [PaneId; 3] = [PaneId::CodexRuntime, PaneId::ClaudeUi, PaneId::PiDocs];
|
||
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_CONTENT_VERTICAL_INSET: f32 = 24.0;
|
||
const TERMINAL_HORIZONTAL_INSET: f32 = 32.0;
|
||
const TERMINAL_CELL_WIDTH: f32 = 8.4;
|
||
const TERMINAL_CELL_HEIGHT: f32 = 18.0;
|
||
const WORK_PANEL_GAP: f32 = 4.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,
|
||
AddPanel,
|
||
DetachSelectedPanel,
|
||
TerminalTaller,
|
||
TerminalShorter,
|
||
TerminalWider,
|
||
TerminalNarrower,
|
||
]
|
||
);
|
||
|
||
struct LumbridgeShell {
|
||
model: ShellModel,
|
||
timing: RenderTiming,
|
||
runtimes: RuntimeRegistry<PaneId>,
|
||
live_terminals: BTreeMap<PaneId, LiveTerminalState>,
|
||
root_focus: FocusHandle,
|
||
}
|
||
|
||
struct LiveTerminalState {
|
||
status: LiveRuntimeStatus,
|
||
terminal: TerminalEngine,
|
||
snapshot: TerminalSnapshot,
|
||
last_runtime_sequence: u64,
|
||
}
|
||
|
||
impl LiveTerminalState {
|
||
fn new(dimensions: TerminalDimensions) -> Self {
|
||
let terminal = TerminalEngine::new(TerminalEngineOptions {
|
||
dimensions,
|
||
..TerminalEngineOptions::default()
|
||
});
|
||
let snapshot = terminal.snapshot();
|
||
Self {
|
||
status: LiveRuntimeStatus::Starting,
|
||
terminal,
|
||
snapshot,
|
||
last_runtime_sequence: 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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_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>,
|
||
attached_panel_count: usize,
|
||
) -> TerminalDimensions {
|
||
let width = f32::from(window_size.width);
|
||
let height = f32::from(window_size.height);
|
||
let panel_count = visible_panel_count(window_size)
|
||
.min(attached_panel_count)
|
||
.max(1);
|
||
let workspace_height =
|
||
(height - APP_HEADER_HEIGHT - TAB_BAR_HEIGHT - APP_FOOTER_HEIGHT).max(0.0);
|
||
let terminal_height = (workspace_height * 0.60 - TERMINAL_CONTENT_VERTICAL_INSET).max(0.0);
|
||
let workspace_width = (width - SIDEBAR_WIDTH).max(0.0);
|
||
let panel_gaps = WORK_PANEL_GAP * panel_count.saturating_sub(1) as f32;
|
||
let panel_width = ((workspace_width - panel_gaps).max(0.0) / panel_count as f32).max(0.0);
|
||
let terminal_width = (panel_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")
|
||
}
|
||
|
||
fn visible_panel_count(window_size: Size<Pixels>) -> usize {
|
||
let workspace_width = (f32::from(window_size.width) - SIDEBAR_WIDTH).max(0.0);
|
||
if workspace_width >= 2_800.0 {
|
||
5
|
||
} else if workspace_width >= 1_100.0 {
|
||
3
|
||
} else {
|
||
1
|
||
}
|
||
}
|
||
|
||
fn visible_panel_range(
|
||
total: usize,
|
||
selected: usize,
|
||
panel_count: usize,
|
||
) -> std::ops::Range<usize> {
|
||
let count = panel_count.clamp(1, total.max(1)).min(total);
|
||
let start = selected
|
||
.saturating_sub(count / 2)
|
||
.min(total.saturating_sub(count));
|
||
start..start + count
|
||
}
|
||
|
||
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 root_focus = cx.focus_handle();
|
||
window.focus(&root_focus);
|
||
|
||
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, INITIAL_ATTACHED_PANEL_COUNT);
|
||
let mut runtimes = RuntimeRegistry::new();
|
||
let mut live_terminals = BTreeMap::new();
|
||
for pane in LIVE_PANES {
|
||
let mut terminal = LiveTerminalState::new(terminal_dimensions);
|
||
if let Err(error) = spawn_live_runtime(&mut runtimes, pane, terminal_dimensions) {
|
||
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||
}
|
||
live_terminals.insert(pane, terminal);
|
||
}
|
||
|
||
cx.observe_window_bounds(window, |shell, window, cx| {
|
||
let dimensions = terminal_dimensions_for_window(
|
||
window.bounds().size,
|
||
shell.model.attached_panel_count(),
|
||
);
|
||
shell.resize_attached_terminals(dimensions.rows(), dimensions.columns(), cx);
|
||
})
|
||
.detach();
|
||
|
||
Self {
|
||
model: ShellModel::with_external_outputs(LIVE_PANES)
|
||
.expect("all live comparison panes are terminals"),
|
||
timing: RenderTiming::default(),
|
||
runtimes,
|
||
live_terminals,
|
||
root_focus,
|
||
}
|
||
}
|
||
|
||
fn dispatch(&mut self, action: ShellAction) -> ActionOutcome {
|
||
self.timing.mark_dispatch();
|
||
self.model.dispatch(action)
|
||
}
|
||
|
||
fn drain_runtime_events(&mut self) -> bool {
|
||
let mut changed = false;
|
||
for pane in LIVE_PANES {
|
||
if self
|
||
.live_terminals
|
||
.get(&pane)
|
||
.is_none_or(|terminal| terminal.status.is_terminal())
|
||
{
|
||
continue;
|
||
}
|
||
for _ in 0..RUNTIME_DRAIN_LIMIT {
|
||
let event = match self.runtimes.try_recv(&pane) {
|
||
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());
|
||
changed = true;
|
||
break;
|
||
}
|
||
Err(error) => {
|
||
self.live_terminals
|
||
.get_mut(&pane)
|
||
.expect("live terminal state exists")
|
||
.status = LiveRuntimeStatus::Fault(error.to_string());
|
||
changed = true;
|
||
break;
|
||
}
|
||
};
|
||
|
||
match event {
|
||
RuntimeEvent::Started {
|
||
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,
|
||
};
|
||
changed = true;
|
||
}
|
||
RuntimeEvent::Output {
|
||
sequence, bytes, ..
|
||
} => {
|
||
let responses = {
|
||
let terminal = self
|
||
.live_terminals
|
||
.get_mut(&pane)
|
||
.expect("live terminal state exists");
|
||
if sequence <= terminal.last_runtime_sequence {
|
||
terminal.status = LiveRuntimeStatus::Fault(format!(
|
||
"non-monotonic PTY output sequence {sequence}"
|
||
));
|
||
changed = true;
|
||
break;
|
||
}
|
||
terminal.last_runtime_sequence = sequence;
|
||
terminal.terminal.process(&bytes).outbound
|
||
};
|
||
for response in responses {
|
||
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());
|
||
return true;
|
||
}
|
||
}
|
||
self.publish_terminal_snapshot(pane);
|
||
changed = true;
|
||
}
|
||
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
|
||
));
|
||
changed = true;
|
||
break;
|
||
}
|
||
RuntimeEvent::Fault {
|
||
operation, message, ..
|
||
} => {
|
||
self.live_terminals
|
||
.get_mut(&pane)
|
||
.expect("live terminal state exists")
|
||
.status = LiveRuntimeStatus::Fault(format!("{operation:?}: {message}"));
|
||
changed = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
changed
|
||
}
|
||
|
||
fn send_runtime_command(
|
||
&self,
|
||
pane: PaneId,
|
||
command: RuntimeCommand,
|
||
) -> Result<(), RuntimeRegistryError> {
|
||
self.runtimes.try_send(&pane, command)
|
||
}
|
||
|
||
fn publish_terminal_snapshot(&mut self, pane: PaneId) {
|
||
let lines = {
|
||
let terminal = self
|
||
.live_terminals
|
||
.get_mut(&pane)
|
||
.expect("live terminal state exists");
|
||
let snapshot = terminal.terminal.snapshot();
|
||
let lines = snapshot.plain_rows();
|
||
terminal.snapshot = snapshot;
|
||
lines
|
||
};
|
||
self.dispatch(ShellAction::ReplaceExternalOutput { pane, lines });
|
||
}
|
||
|
||
fn resize_terminal(&mut self, pane: PaneId, 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");
|
||
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());
|
||
}
|
||
self.publish_terminal_snapshot(pane);
|
||
true
|
||
}
|
||
|
||
fn resize_attached_terminals(&mut self, rows: u16, columns: u16, cx: &mut Context<Self>) {
|
||
let mut changed = false;
|
||
for pane in LIVE_PANES {
|
||
if self.model.is_panel_attached(pane) {
|
||
changed |= self.resize_terminal(pane, rows, columns);
|
||
}
|
||
}
|
||
if changed {
|
||
cx.notify();
|
||
}
|
||
}
|
||
|
||
fn scroll_terminal(&mut self, pane: PaneId, scroll: TerminalScroll, cx: &mut Context<Self>) {
|
||
if !self
|
||
.live_terminals
|
||
.get_mut(&pane)
|
||
.expect("live terminal state exists")
|
||
.terminal
|
||
.scroll_display(scroll)
|
||
{
|
||
return;
|
||
}
|
||
self.publish_terminal_snapshot(pane);
|
||
cx.notify();
|
||
}
|
||
|
||
fn select_pane(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
|
||
self.dispatch(ShellAction::SelectPane(pane));
|
||
window.focus(&self.root_focus);
|
||
cx.notify();
|
||
}
|
||
|
||
fn resize_terminal_for_workspace(&mut self, window: &Window, cx: &mut Context<Self>) {
|
||
let dimensions =
|
||
terminal_dimensions_for_window(window.bounds().size, self.model.attached_panel_count());
|
||
self.resize_attached_terminals(dimensions.rows(), dimensions.columns(), cx);
|
||
}
|
||
|
||
fn attach_panel(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
|
||
let outcome = self.dispatch(ShellAction::AttachPanel(pane));
|
||
if outcome.changed {
|
||
self.resize_terminal_for_workspace(window, cx);
|
||
}
|
||
window.focus(&self.root_focus);
|
||
cx.notify();
|
||
}
|
||
|
||
fn add_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||
let Some(pane) = self.model.next_detached_panel() else {
|
||
return;
|
||
};
|
||
self.attach_panel(pane, window, cx);
|
||
}
|
||
|
||
fn detach_panel(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
|
||
let outcome = self.dispatch(ShellAction::DetachPanel(pane));
|
||
if outcome.changed {
|
||
self.resize_terminal_for_workspace(window, cx);
|
||
}
|
||
window.focus(&self.root_focus);
|
||
cx.notify();
|
||
}
|
||
|
||
fn add_panel_action(&mut self, _: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
|
||
self.add_panel(window, cx);
|
||
}
|
||
|
||
fn detach_selected_panel(
|
||
&mut self,
|
||
_: &DetachSelectedPanel,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
self.detach_panel(self.model.selected_pane(), window, cx);
|
||
}
|
||
|
||
fn move_focus(
|
||
&mut self,
|
||
direction: FocusDirection,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
self.dispatch(ShellAction::MoveFocus(direction));
|
||
window.focus(&self.root_focus);
|
||
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 selected_terminal_dimensions(&self) -> Option<(PaneId, TerminalDimensions)> {
|
||
let pane = self.model.selected_pane();
|
||
self.live_terminals
|
||
.get(&pane)
|
||
.map(|terminal| (pane, terminal.terminal.dimensions()))
|
||
}
|
||
|
||
fn terminal_taller(&mut self, _: &TerminalTaller, _: &mut Window, cx: &mut Context<Self>) {
|
||
let Some((pane, dimensions)) = self.selected_terminal_dimensions() else {
|
||
return;
|
||
};
|
||
if self.resize_terminal(
|
||
pane,
|
||
dimensions.rows().saturating_add(TERMINAL_ROW_STEP),
|
||
dimensions.columns(),
|
||
) {
|
||
cx.notify();
|
||
}
|
||
}
|
||
|
||
fn terminal_shorter(&mut self, _: &TerminalShorter, _: &mut Window, cx: &mut Context<Self>) {
|
||
let Some((pane, dimensions)) = self.selected_terminal_dimensions() else {
|
||
return;
|
||
};
|
||
if self.resize_terminal(
|
||
pane,
|
||
dimensions.rows().saturating_sub(TERMINAL_ROW_STEP).max(2),
|
||
dimensions.columns(),
|
||
) {
|
||
cx.notify();
|
||
}
|
||
}
|
||
|
||
fn terminal_wider(&mut self, _: &TerminalWider, _: &mut Window, cx: &mut Context<Self>) {
|
||
let Some((pane, dimensions)) = self.selected_terminal_dimensions() else {
|
||
return;
|
||
};
|
||
if self.resize_terminal(
|
||
pane,
|
||
dimensions.rows(),
|
||
dimensions.columns().saturating_add(TERMINAL_COLUMN_STEP),
|
||
) {
|
||
cx.notify();
|
||
}
|
||
}
|
||
|
||
fn terminal_narrower(&mut self, _: &TerminalNarrower, _: &mut Window, cx: &mut Context<Self>) {
|
||
let Some((pane, dimensions)) = self.selected_terminal_dimensions() else {
|
||
return;
|
||
};
|
||
if self.resize_terminal(
|
||
pane,
|
||
dimensions.rows(),
|
||
dimensions
|
||
.columns()
|
||
.saturating_sub(TERMINAL_COLUMN_STEP)
|
||
.max(20),
|
||
) {
|
||
cx.notify();
|
||
}
|
||
}
|
||
|
||
fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
|
||
if !self.model.command_palette().is_open() {
|
||
let pane = self.model.selected_pane();
|
||
if self
|
||
.live_terminals
|
||
.get(&pane)
|
||
.is_none_or(|terminal| terminal.status.is_terminal())
|
||
{
|
||
return;
|
||
}
|
||
let modifiers = key_modifiers(event);
|
||
if let Some(scroll) =
|
||
terminal_scroll_from_parts(event.keystroke.key.as_str(), modifiers)
|
||
{
|
||
self.scroll_terminal(pane, scroll, cx);
|
||
return;
|
||
}
|
||
let Some(event) = terminal_key_from_parts(
|
||
event.keystroke.key.as_str(),
|
||
event.keystroke.key_char.as_deref(),
|
||
modifiers,
|
||
) else {
|
||
return;
|
||
};
|
||
let bytes = self
|
||
.live_terminals
|
||
.get(&pane)
|
||
.expect("selected live terminal exists")
|
||
.terminal
|
||
.encode_key(&event);
|
||
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());
|
||
}
|
||
let moved_to_bottom = {
|
||
let terminal = self
|
||
.live_terminals
|
||
.get_mut(&pane)
|
||
.expect("selected live terminal exists");
|
||
terminal.terminal.display_offset() > 0
|
||
&& terminal.terminal.scroll_display(TerminalScroll::Bottom)
|
||
};
|
||
if moved_to_bottom {
|
||
self.publish_terminal_snapshot(pane);
|
||
}
|
||
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 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, pane: PaneId) -> gpui::AnyElement {
|
||
let terminal = self
|
||
.live_terminals
|
||
.get(&pane)
|
||
.expect("external terminal pane has live state");
|
||
let rows = terminal_paint_rows(&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 pane_context(
|
||
&self,
|
||
pane: &PaneState,
|
||
selected: bool,
|
||
cx: &mut Context<Self>,
|
||
) -> gpui::AnyElement {
|
||
let pane_id = pane.id();
|
||
let can_detach = self.model.attached_panel_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")
|
||
});
|
||
let status = live_terminal.map_or(pane.fixture().badge, |terminal| terminal.status.badge());
|
||
let detail = live_terminal.map_or_else(
|
||
|| pane.fixture().target.to_owned(),
|
||
|terminal| terminal.status.detail(),
|
||
);
|
||
let surface_status = live_terminal.map_or_else(
|
||
|| status.to_owned(),
|
||
|terminal| {
|
||
let dimensions = terminal.terminal.dimensions();
|
||
if terminal.snapshot.display_offset > 0 {
|
||
format!(
|
||
"{} · ↑{} · {}×{}",
|
||
status,
|
||
terminal.snapshot.display_offset,
|
||
dimensions.columns(),
|
||
dimensions.rows()
|
||
)
|
||
} else {
|
||
format!(
|
||
"{} · {}×{}",
|
||
status,
|
||
dimensions.columns(),
|
||
dimensions.rows()
|
||
)
|
||
}
|
||
},
|
||
);
|
||
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<_>>();
|
||
|
||
div()
|
||
.flex()
|
||
.flex_col()
|
||
.size_full()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.px_3()
|
||
.py_2()
|
||
.bg(rgb(if selected { PANEL_ACTIVE } else { PANEL_ALT }))
|
||
.border_b_1()
|
||
.border_color(rgb(BORDER))
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.items_center()
|
||
.justify_between()
|
||
.gap_2()
|
||
.child(
|
||
div()
|
||
.min_w_0()
|
||
.child(
|
||
div()
|
||
.truncate()
|
||
.text_sm()
|
||
.text_color(rgb(TEXT))
|
||
.child(pane.fixture().title),
|
||
)
|
||
.child(
|
||
div()
|
||
.mt_1()
|
||
.truncate()
|
||
.text_xs()
|
||
.text_color(rgb(MUTED))
|
||
.child(detail),
|
||
),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.flex_col()
|
||
.items_end()
|
||
.flex_none()
|
||
.gap_1()
|
||
.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(rgb(if pane.needs_input() {
|
||
ATTENTION
|
||
} else if selected {
|
||
SUCCESS
|
||
} else {
|
||
MUTED
|
||
}))
|
||
.child(surface_status),
|
||
)
|
||
.child(
|
||
div()
|
||
.id(("detach-panel", pane_id.index()))
|
||
.cursor_pointer()
|
||
.px_2()
|
||
.py_1()
|
||
.rounded(px(4.0))
|
||
.border_1()
|
||
.border_color(rgb(BORDER))
|
||
.text_xs()
|
||
.text_color(rgb(if can_detach { MUTED } else { BORDER }))
|
||
.child(if can_detach {
|
||
"− DETACH"
|
||
} else {
|
||
"LAST PANEL"
|
||
})
|
||
.on_click(cx.listener(move |shell, _, window, cx| {
|
||
cx.stop_propagation();
|
||
shell.detach_panel(pane_id, window, cx);
|
||
})),
|
||
),
|
||
),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.h(px(28.0))
|
||
.flex_none()
|
||
.mt_2()
|
||
.overflow_hidden()
|
||
.border_t_1()
|
||
.border_color(rgb(BORDER_QUIET))
|
||
.children(tabs),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.items_center()
|
||
.justify_between()
|
||
.mt_2()
|
||
.text_xs()
|
||
.text_color(rgb(MUTED))
|
||
.child("TOOLS · CONTEXT · GOAL")
|
||
.child(if pane.needs_input() {
|
||
"NEEDS INPUT"
|
||
} else if selected {
|
||
"KEYBOARD OWNER"
|
||
} else {
|
||
"RUNNING"
|
||
}),
|
||
)
|
||
.into_any_element()
|
||
}
|
||
|
||
fn pane_work_surface(&self, pane: &PaneState) -> gpui::AnyElement {
|
||
let external = pane.output_source() == OutputSource::External;
|
||
let content = if external {
|
||
self.terminal_view(pane.id())
|
||
} 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_w_0()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.bg(rgb(BG))
|
||
.border_y_1()
|
||
.border_color(rgb(BORDER))
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.p_3()
|
||
.child(content),
|
||
)
|
||
.into_any_element()
|
||
}
|
||
|
||
fn pane_decision_region(&self, pane: &PaneState) -> gpui::AnyElement {
|
||
let choice = |label: &'static str, detail: &'static str, attention: bool| {
|
||
div()
|
||
.flex()
|
||
.items_center()
|
||
.justify_between()
|
||
.min_w_0()
|
||
.px_2()
|
||
.py_1()
|
||
.rounded(px(4.0))
|
||
.border_1()
|
||
.border_color(rgb(if attention { ATTENTION } else { BORDER }))
|
||
.bg(rgb(PANEL_ALT))
|
||
.text_xs()
|
||
.text_color(rgb(TEXT))
|
||
.child(label)
|
||
.child(div().truncate().text_color(rgb(MUTED)).child(detail))
|
||
};
|
||
let choices = if pane.needs_input() {
|
||
[
|
||
("Review request", "inspect scope", true),
|
||
("Steer…", "edit response", false),
|
||
("Dismiss", "leave inert", false),
|
||
]
|
||
} else {
|
||
[
|
||
("Continue", "keep moving", false),
|
||
("Review plan", "inspect commands", false),
|
||
("Ask…", "refine prompt", false),
|
||
]
|
||
};
|
||
div()
|
||
.flex()
|
||
.flex_col()
|
||
.size_full()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.px_2()
|
||
.py_2()
|
||
.bg(rgb(PANEL))
|
||
.border_t_1()
|
||
.border_color(rgb(BORDER))
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.items_center()
|
||
.justify_between()
|
||
.text_xs()
|
||
.child(div().text_color(rgb(MUTED)).child("DECISION SHELF"))
|
||
.child(
|
||
div()
|
||
.text_color(rgb(if pane.needs_input() { ATTENTION } else { MUTED }))
|
||
.child(if pane.needs_input() {
|
||
"REVIEW REQUIRED"
|
||
} else {
|
||
"SUGGESTIONS INERT"
|
||
}),
|
||
),
|
||
)
|
||
.child(div().flex().flex_col().gap_1().mt_2().children(
|
||
choices.map(|(label, detail, attention)| choice(label, detail, attention)),
|
||
))
|
||
.into_any_element()
|
||
}
|
||
|
||
fn workspace_panel(
|
||
&self,
|
||
pane: &PaneState,
|
||
selected: bool,
|
||
cx: &mut Context<Self>,
|
||
) -> gpui::AnyElement {
|
||
let pane_id = pane.id();
|
||
div()
|
||
.id(("workspace-panel", pane_id.index()))
|
||
.on_click(cx.listener(move |shell, _, window, cx| {
|
||
shell.select_pane(pane_id, window, cx);
|
||
}))
|
||
.flex()
|
||
.flex_col()
|
||
.size_full()
|
||
.min_w_0()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.border_1()
|
||
.border_color(rgb(if selected { ACCENT } else { BORDER }))
|
||
.child(
|
||
div()
|
||
.h(relative(0.20))
|
||
.flex_none()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.child(self.pane_context(pane, selected, cx)),
|
||
)
|
||
.child(
|
||
div()
|
||
.h(relative(0.60))
|
||
.flex_none()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.child(self.pane_work_surface(pane)),
|
||
)
|
||
.child(
|
||
div()
|
||
.h(relative(0.20))
|
||
.flex_none()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.child(self.pane_decision_region(pane)),
|
||
)
|
||
.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("Add workspace panel")
|
||
.child(
|
||
div()
|
||
.mt_1()
|
||
.text_xs()
|
||
.child("Alt+Shift+N · reattach if detached"),
|
||
),
|
||
)
|
||
.child(
|
||
div()
|
||
.px_3()
|
||
.py_2()
|
||
.text_color(rgb(MUTED))
|
||
.child("Detach selected panel")
|
||
.child(
|
||
div()
|
||
.mt_1()
|
||
.text_xs()
|
||
.child("Alt+Shift+W · session keeps running"),
|
||
),
|
||
)
|
||
.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 panel_capacity = visible_panel_count(window.bounds().size);
|
||
let attached_panes = self.model.attached_pane_ids();
|
||
let attached_count = attached_panes.len();
|
||
let detached_entries = self
|
||
.model
|
||
.detached_pane_ids()
|
||
.into_iter()
|
||
.map(|pane| (pane, self.model.pane(pane).fixture().title))
|
||
.collect::<Vec<_>>();
|
||
let detached_count = detached_entries.len();
|
||
let visible_count = panel_capacity.min(attached_count).max(1);
|
||
let running_runtime_count = self
|
||
.live_terminals
|
||
.values()
|
||
.filter(|terminal| matches!(terminal.status, LiveRuntimeStatus::Running { .. }))
|
||
.count();
|
||
let runtime_summary = format!(
|
||
"{running_runtime_count}/{} LIVE PTYS",
|
||
self.live_terminals.len()
|
||
);
|
||
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 · {runtime_summary}")),
|
||
)
|
||
.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()
|
||
.mt_3()
|
||
.px_4()
|
||
.py_2()
|
||
.text_xs()
|
||
.text_color(rgb(MUTED))
|
||
.child(format!("DETACHED SESSIONS · {detached_count}")),
|
||
)
|
||
.children(detached_entries.into_iter().map(|(pane, title)| {
|
||
div()
|
||
.id(("detached-session", pane.index()))
|
||
.cursor_pointer()
|
||
.mx_2()
|
||
.mb_1()
|
||
.px_3()
|
||
.py_2()
|
||
.rounded(px(4.0))
|
||
.border_1()
|
||
.border_color(rgb(BORDER_QUIET))
|
||
.text_xs()
|
||
.text_color(rgb(MUTED))
|
||
.child(format!("↪ {title}"))
|
||
.on_click(cx.listener(move |shell, _, window, cx| {
|
||
shell.attach_panel(pane, window, cx);
|
||
}))
|
||
}))
|
||
.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(format!(
|
||
"{visible_count} shown · {attached_count} attached · {detached_count} detached"
|
||
)))
|
||
.child(
|
||
div()
|
||
.id("add-workspace-panel")
|
||
.cursor_pointer()
|
||
.ml_2()
|
||
.px_3()
|
||
.py_1()
|
||
.rounded(px(4.0))
|
||
.border_1()
|
||
.border_color(rgb(if detached_count > 0 { ACCENT } else { BORDER }))
|
||
.text_xs()
|
||
.text_color(rgb(if detached_count > 0 { ACCENT } else { MUTED }))
|
||
.child(if detached_count > 0 {
|
||
"+ ADD PANEL"
|
||
} else {
|
||
"ALL PANELS ATTACHED"
|
||
})
|
||
.on_click(cx.listener(|shell, _, window, cx| {
|
||
shell.add_panel(window, cx);
|
||
})),
|
||
);
|
||
|
||
let selected_position = attached_panes
|
||
.iter()
|
||
.position(|pane| *pane == self.model.selected_pane())
|
||
.expect("the selected pane must remain attached");
|
||
let panel_range = visible_panel_range(attached_count, selected_position, visible_count);
|
||
let workspace_panels = panel_range
|
||
.map(|index| {
|
||
let pane = self.model.pane(attached_panes[index]);
|
||
div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.min_h_0()
|
||
.child(self.workspace_panel(pane, pane.id() == self.model.selected_pane(), cx))
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let workspace_row = div()
|
||
.flex()
|
||
.gap_1()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.min_h_0()
|
||
.overflow_hidden()
|
||
.children(workspace_panels);
|
||
|
||
let counters = self.model.counters();
|
||
let terminal_revision = self
|
||
.live_terminals
|
||
.values()
|
||
.map(|terminal| terminal.terminal.revision())
|
||
.sum::<u64>();
|
||
let footer_left = format!(
|
||
"rev {} · {} PTYs · VT rev {} · {} focus · {} VT snapshots · {} synthetic lines",
|
||
self.model.revision(),
|
||
self.runtimes.len(),
|
||
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_click(cx.listener(|shell, _, window, _| {
|
||
window.focus(&shell.root_focus);
|
||
}))
|
||
.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::add_panel_action))
|
||
.on_action(cx.listener(Self::detach_selected_panel))
|
||
.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 · {runtime_summary}")),
|
||
)
|
||
.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_row),
|
||
),
|
||
)
|
||
.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.strip_prefix('f').is_some_and(|suffix| {
|
||
!suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())
|
||
}) =>
|
||
{
|
||
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 terminal_scroll_from_parts(key: &str, modifiers: KeyModifiers) -> Option<TerminalScroll> {
|
||
if !modifiers.contains(KeyModifiers::SHIFT) {
|
||
return None;
|
||
}
|
||
match key {
|
||
"pageup" => Some(TerminalScroll::PageUp),
|
||
"pagedown" => Some(TerminalScroll::PageDown),
|
||
"home" => Some(TerminalScroll::Top),
|
||
"end" => Some(TerminalScroll::Bottom),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn live_pty_script(pane: PaneId) -> &'static str {
|
||
match pane {
|
||
PaneId::CodexRuntime => {
|
||
"printf 'Codex runtime · independent Lumbridge PTY\\n'; exec /bin/sh -i"
|
||
}
|
||
PaneId::ClaudeUi => {
|
||
"printf 'Claude workspace · independent Lumbridge PTY\\n'; exec /bin/sh -i"
|
||
}
|
||
PaneId::PiDocs => "printf 'Pi docs · independent Lumbridge PTY\\n'; exec /bin/sh -i",
|
||
PaneId::Architecture | PaneId::AcpPreview | PaneId::RuntimeReview => {
|
||
unreachable!("only terminal fixtures own live PTYs")
|
||
}
|
||
}
|
||
}
|
||
|
||
fn spawn_live_runtime(
|
||
runtimes: &mut RuntimeRegistry<PaneId>,
|
||
pane: PaneId,
|
||
dimensions: TerminalDimensions,
|
||
) -> Result<(), RuntimeRegistryError> {
|
||
let command = CommandConfig::new("/bin/sh")
|
||
.map_err(RuntimeActorError::Start)?
|
||
.args(["-c", live_pty_script(pane)]);
|
||
let pty_size = TerminalSize::new(dimensions.rows(), dimensions.columns())
|
||
.map_err(RuntimeActorError::Start)?;
|
||
runtimes.spawn(
|
||
pane,
|
||
command,
|
||
PtyOptions::new(pty_size),
|
||
RuntimeActorOptions::default(),
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
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")),
|
||
]);
|
||
|
||
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, TerminalScroll,
|
||
indexed_terminal_color, terminal_dimensions_for_window, terminal_key_from_parts,
|
||
terminal_paint_rows, terminal_scroll_from_parts, visible_panel_count, visible_panel_range,
|
||
};
|
||
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 literal_f = terminal_key_from_parts("f", Some("f"), KeyModifiers::default()).unwrap();
|
||
assert_eq!(literal_f.key, TerminalKey::Text("f".into()));
|
||
|
||
let function = terminal_key_from_parts("f12", None, KeyModifiers::default()).unwrap();
|
||
assert_eq!(function.key, TerminalKey::Function(12));
|
||
|
||
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_middle_sixty_percent_per_panel() {
|
||
let dimensions = terminal_dimensions_for_window(size(px(1500.0), px(960.0)), 5);
|
||
assert_eq!(dimensions.rows(), 26);
|
||
assert_eq!(dimensions.columns(), 45);
|
||
|
||
let ultrawide = size(px(3440.0), px(1440.0));
|
||
assert_eq!(visible_panel_count(ultrawide), 5);
|
||
let dimensions = terminal_dimensions_for_window(ultrawide, 5);
|
||
assert_eq!(dimensions.rows(), 42);
|
||
assert_eq!(dimensions.columns(), 71);
|
||
|
||
let two_panels = terminal_dimensions_for_window(ultrawide, 2);
|
||
assert_eq!(two_panels.rows(), 42);
|
||
assert_eq!(two_panels.columns(), 185);
|
||
}
|
||
|
||
#[test]
|
||
fn panel_window_keeps_the_selected_pane_visible() {
|
||
assert_eq!(visible_panel_range(6, 0, 5), 0..5);
|
||
assert_eq!(visible_panel_range(6, 5, 5), 1..6);
|
||
assert_eq!(visible_panel_range(6, 3, 3), 2..5);
|
||
assert_eq!(visible_panel_range(6, 4, 1), 4..5);
|
||
}
|
||
|
||
#[test]
|
||
fn shift_navigation_controls_scrollback_without_reaching_the_pty() {
|
||
assert_eq!(
|
||
terminal_scroll_from_parts("pageup", KeyModifiers::SHIFT),
|
||
Some(TerminalScroll::PageUp)
|
||
);
|
||
assert_eq!(
|
||
terminal_scroll_from_parts("end", KeyModifiers::SHIFT),
|
||
Some(TerminalScroll::Bottom)
|
||
);
|
||
assert_eq!(
|
||
terminal_scroll_from_parts("pageup", KeyModifiers::default()),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|