feat: make terminal the primary workspace surface
CI / rust (push) Successful in 2m7s

This commit is contained in:
2026-08-31 17:18:55 -07:00
parent b838d000db
commit 1204689de0
11 changed files with 643 additions and 157 deletions
+552 -121
View File
@@ -2,8 +2,8 @@ use std::collections::VecDeque;
use std::time::{Duration, Instant};
use gpui::{
App, Application, Bounds, Context, FocusHandle, KeyBinding, KeyDownEvent, Window, WindowBounds,
WindowOptions, actions, div, prelude::*, px, rgb, size,
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,
@@ -14,8 +14,9 @@ use lumbridge_spike_model::{
ShellModel, SurfaceKind, WORKSPACES,
};
use lumbridge_terminal::{
KeyModifiers, TerminalDimensions, TerminalEngine, TerminalEngineOptions, TerminalKey,
TerminalKeyEvent,
KeyModifiers, TerminalCellStyle, TerminalColor, TerminalCursorShape, TerminalDimensions,
TerminalEngine, TerminalEngineOptions, TerminalKey, TerminalKeyEvent, TerminalNamedColor,
TerminalSnapshot,
};
const BG: u32 = 0x090c12;
@@ -34,6 +35,14 @@ 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;
@@ -65,6 +74,7 @@ struct LumbridgeShell {
runtime: Option<RuntimeActor>,
runtime_status: LiveRuntimeStatus,
terminal: TerminalEngine,
terminal_snapshot: TerminalSnapshot,
last_runtime_sequence: u64,
root_focus: FocusHandle,
pane_focus: [FocusHandle; 6],
@@ -123,6 +133,171 @@ struct RenderTiming {
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());
@@ -207,12 +382,23 @@ impl LumbridgeShell {
})
.detach();
let terminal = TerminalEngine::new(TerminalEngineOptions::default());
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"),
@@ -220,6 +406,7 @@ impl LumbridgeShell {
runtime,
runtime_status,
terminal,
terminal_snapshot,
last_runtime_sequence: 0,
root_focus,
pane_focus,
@@ -316,7 +503,9 @@ impl LumbridgeShell {
}
fn publish_terminal_snapshot(&mut self) {
let lines = self.terminal.snapshot().plain_rows();
let snapshot = self.terminal.snapshot();
let lines = snapshot.plain_rows();
self.terminal_snapshot = snapshot;
self.dispatch(ShellAction::ReplaceExternalOutput {
pane: LIVE_PANE,
lines,
@@ -474,11 +663,10 @@ impl LumbridgeShell {
cx.notify();
}
fn pane_card(
fn pane_selector(
pane: &PaneState,
selected: bool,
runtime_status: LiveRuntimeStatus,
terminal_dimensions: TerminalDimensions,
focus: FocusHandle,
cx: &mut Context<Self>,
) -> gpui::AnyElement {
@@ -500,147 +688,296 @@ impl LumbridgeShell {
} else {
ACCENT
};
let line_start = pane.lines().len().saturating_sub(12);
div()
.id(("pane", id.index()))
.track_focus(&focus)
.tab_index(id.index() as isize + 1)
.flex()
.flex_col()
.min_w_0()
.min_h_0()
.overflow_hidden()
.bg(rgb(if selected { PANEL_ACTIVE } else { PANEL }))
.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
BORDER_QUIET
}))
.rounded(px(5.0))
.when(selected, |view| view.border_2())
.on_click(cx.listener(move |shell, _, window, cx| {
shell.select_pane(id, window, cx);
}))
.child(
div()
.flex()
.items_center()
.justify_between()
.px_3()
.h(px(36.0))
.flex_none()
.bg(rgb(if selected { PANEL_ACTIVE } else { PANEL_ALT }))
.border_b_1()
.border_color(rgb(BORDER_QUIET))
.text_sm()
.text_color(rgb(TEXT))
.flex_col()
.min_w_0()
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
div()
.text_xs()
.text_color(rgb(MUTED))
.child(format!("{}", id.index() + 1)),
)
.truncate()
.text_sm()
.text_color(rgb(TEXT))
.child(if external {
"Shell · VT session"
"Terminal · local shell"
} else {
pane.fixture().title
}),
)
.child(div().text_xs().text_color(rgb(state_color)).child(label)),
.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()
.py_2()
.text_xs()
.text_color(rgb(MUTED))
.child(if external {
format!(
"{} · {}×{} · Alt+Shift+arrows resize",
runtime_status.detail(),
terminal_dimensions.columns(),
terminal_dimensions.rows()
)
} else {
pane.fixture().target.to_owned()
})
.when(selected, |view| view.child("FOCUSED")),
.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),
),
)
.when(external && selected, |view| {
view.child(
div()
.flex()
.items_center()
.gap_3()
.h(px(26.0))
.flex_none()
.px_3()
.border_y_1()
.border_color(rgb(BORDER_QUIET))
.bg(rgb(PANEL_ALT))
.text_xs()
.child(div().text_color(rgb(ACCENT)).child("TERMINAL"))
.child(div().text_color(rgb(MUTED)).child("BROWSER"))
.child(div().text_color(rgb(MUTED)).child("TOOLS"))
.child(div().text_color(rgb(MUTED)).child("CONTEXT"))
.child(div().text_color(rgb(MUTED)).child("GOAL"))
.child(div().text_color(rgb(MUTED)).child("REVIEW")),
)
})
.child(
div()
.flex()
.flex_col()
.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()
.gap_1()
.px_3()
.pb_3()
.text_sm()
.text_color(rgb(TEXT))
.children(pane.lines()[line_start..].iter().cloned()),
.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")),
)
.when(external && selected, |view| {
view.child(
div()
.flex()
.items_center()
.justify_between()
.gap_2()
.h(px(38.0))
.flex_none()
.px_3()
.border_t_1()
.border_color(rgb(BORDER_QUIET))
.bg(rgb(PANEL_ALT))
.text_xs()
.child(
div()
.text_color(rgb(MUTED))
.child("LOCAL ANALYST · OFF · suggestions only"),
)
.child(
div()
.flex()
.gap_2()
.text_color(rgb(TEXT))
.child("Continue")
.child("Review plan")
.child("Ask…"),
),
)
})
.into_any_element()
}
@@ -899,29 +1236,91 @@ impl Render for LumbridgeShell {
.child("1 interactive VT · 5 deterministic · 1 waiting"),
);
let pane_cards = self
let pane_selectors = self
.model
.panes()
.iter()
.map(|pane| {
Self::pane_card(
Self::pane_selector(
pane,
self.model.selected_pane() == pane.id(),
self.runtime_status.clone(),
self.terminal.dimensions(),
self.pane_focus[pane.id().index()].clone(),
cx,
)
})
.collect::<Vec<_>>();
let grid = div()
.grid()
.grid_cols(3)
.grid_rows(2)
.gap_2()
.p_2()
let selected = self.model.pane(self.model.selected_pane());
let workspace_stack = div()
.flex()
.flex_col()
.size_full()
.children(pane_cards);
.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!(
@@ -1014,7 +1413,7 @@ impl Render for LumbridgeShell {
.flex_1()
.min_w_0()
.child(tabs)
.child(grid),
.child(workspace_stack),
),
)
.child(
@@ -1141,7 +1540,11 @@ fn main() {
#[cfg(test)]
mod tests {
use super::{KeyModifiers, TerminalKey, terminal_key_from_parts};
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() {
@@ -1158,4 +1561,32 @@ mod tests {
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);
}
}