Build interactive native workspace vertical slice
CI / rust (push) Successful in 1m43s

This commit is contained in:
2026-08-31 16:15:32 -07:00
parent de89b015bc
commit 27beb69ff8
19 changed files with 10535 additions and 200 deletions
Generated
+175 -5
View File
@@ -21,6 +21,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.8" version = "0.7.8"
@@ -99,6 +105,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.1" version = "2.13.1"
@@ -154,7 +166,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2 0.11.0", "sha2 0.11.0",
"subtle", "subtle",
"thiserror", "thiserror 2.0.20",
"url", "url",
"uuid", "uuid",
"zeroize", "zeroize",
@@ -169,7 +181,7 @@ dependencies = [
"nostr", "nostr",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror 2.0.20",
"uuid", "uuid",
] ]
@@ -198,6 +210,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]] [[package]]
name = "chacha20" name = "chacha20"
version = "0.9.1" version = "0.9.1"
@@ -357,6 +375,12 @@ dependencies = [
"syn 3.0.4", "syn 3.0.4",
] ]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@@ -369,6 +393,17 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
dependencies = [
"libc",
"thiserror 1.0.69",
"winapi",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.11" version = "0.1.11"
@@ -661,6 +696,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.189" version = "0.2.189"
@@ -703,7 +744,7 @@ version = "0.0.1"
dependencies = [ dependencies = [
"buzz-sdk", "buzz-sdk",
"nostr", "nostr",
"thiserror", "thiserror 2.0.20",
"uuid", "uuid",
] ]
@@ -711,6 +752,15 @@ dependencies = [
name = "lumbridge-core" name = "lumbridge-core"
version = "0.0.1" version = "0.0.1"
[[package]]
name = "lumbridge-pty"
version = "0.0.1"
dependencies = [
"nix",
"portable-pty",
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "lumbridge-storage" name = "lumbridge-storage"
version = "0.0.1" version = "0.0.1"
@@ -725,6 +775,18 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "nostr" name = "nostr"
version = "0.44.8" version = "0.44.8"
@@ -820,6 +882,27 @@ dependencies = [
"universal-hash", "universal-hash",
] ]
[[package]]
name = "portable-pty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"lazy_static",
"libc",
"log",
"nix",
"serial2",
"shared_library",
"shell-words",
"winapi",
"winreg",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.6" version = "0.1.6"
@@ -915,7 +998,7 @@ version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.13.1",
"fallible-iterator", "fallible-iterator",
"fallible-streaming-iterator", "fallible-streaming-iterator",
"libsqlite3-sys", "libsqlite3-sys",
@@ -1012,6 +1095,17 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serial2"
version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730"
dependencies = [
"cfg-if",
"libc",
"windows-sys",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -1034,6 +1128,22 @@ dependencies = [
"digest 0.11.3", "digest 0.11.3",
] ]
[[package]]
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@@ -1097,13 +1207,33 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.20" version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl 2.0.20",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
] ]
[[package]] [[package]]
@@ -1277,6 +1407,28 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -1336,6 +1488,24 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.4" version = "0.6.4"
+1
View File
@@ -3,6 +3,7 @@ members = [
"apps/lumbridge", "apps/lumbridge",
"crates/lumbridge-buzz", "crates/lumbridge-buzz",
"crates/lumbridge-core", "crates/lumbridge-core",
"crates/lumbridge-pty",
"crates/lumbridge-storage", "crates/lumbridge-storage",
] ]
exclude = ["spikes"] exclude = ["spikes"]
+6 -4
View File
@@ -16,10 +16,12 @@ as installable binaries; building from source will remain supported.
## Status ## Status
This repository is in architecture and product-definition phase. The current This repository is in architecture and vertical-slice phase. The installable
binary is deliberately only a scaffold. We are validating the terminal engine, binary is still a scaffold, while the isolated native UI spikes now exercise an
UI stack, process/session model, ACP integration, packaging, and usage-data interactive six-surface workspace and the root workspace contains the first
contracts before committing to a large implementation. bounded local PTY boundary. We are still validating terminal emulation, the
session runtime, ACP integration, packaging, and usage-data contracts before a
large implementation.
## Product shape ## Product shape
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "lumbridge-pty"
description = "Portable local PTY process boundary for Lumbridge"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
portable-pty = "0.9.0"
thiserror = "2.0"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.28", features = ["process", "signal"] }
[lints]
workspace = true
+716
View File
@@ -0,0 +1,716 @@
//! Portable local PTY process ownership for the Lumbridge runtime.
//!
//! This crate moves unparsed bytes between a child process and its PTY. Terminal
//! emulation, scrollback, persistence, remote sessions, and secret injection are
//! intentionally separate concerns.
#![forbid(unsafe_code)]
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system};
use std::ffi::OsString;
use std::io::{self, Read, Write};
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use thiserror::Error;
const OUTPUT_CHUNK_BYTES: usize = 8 * 1024;
const DEFAULT_OUTPUT_QUEUE_CHUNKS: usize = 64;
const TERMINATION_GRACE: Duration = Duration::from_millis(250);
const TERMINATION_POLL: Duration = Duration::from_millis(10);
/// A validated terminal cell and pixel size.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerminalSize {
rows: u16,
columns: u16,
pixel_width: u16,
pixel_height: u16,
}
impl TerminalSize {
/// Creates a cell-only size. Rows and columns must both be non-zero.
///
/// # Errors
///
/// Returns [`PtyError::InvalidSize`] when either cell dimension is zero.
pub fn new(rows: u16, columns: u16) -> Result<Self, PtyError> {
Self::with_pixels(rows, columns, 0, 0)
}
/// Creates a size that also reports the viewport's pixel dimensions.
///
/// # Errors
///
/// Returns [`PtyError::InvalidSize`] when either cell dimension is zero.
pub fn with_pixels(
rows: u16,
columns: u16,
pixel_width: u16,
pixel_height: u16,
) -> Result<Self, PtyError> {
if rows == 0 || columns == 0 {
return Err(PtyError::InvalidSize);
}
Ok(Self {
rows,
columns,
pixel_width,
pixel_height,
})
}
#[must_use]
pub const fn rows(self) -> u16 {
self.rows
}
#[must_use]
pub const fn columns(self) -> u16 {
self.columns
}
}
impl Default for TerminalSize {
fn default() -> Self {
Self {
rows: 24,
columns: 80,
pixel_width: 0,
pixel_height: 0,
}
}
}
impl From<TerminalSize> for PtySize {
fn from(size: TerminalSize) -> Self {
Self {
rows: size.rows,
cols: size.columns,
pixel_width: size.pixel_width,
pixel_height: size.pixel_height,
}
}
}
/// Credential-free process launch configuration.
///
/// The configuration deliberately has no arbitrary environment-variable API.
/// A child receives only a small fixed allowlist from the Lumbridge process,
/// plus terminal capability variables. Future secret injection belongs behind
/// the runtime's secret-store boundary and must not be represented here.
pub struct CommandConfig {
program: OsString,
arguments: Vec<OsString>,
working_directory: Option<PathBuf>,
}
impl CommandConfig {
/// Creates a launch configuration for a non-empty executable name or path.
///
/// # Errors
///
/// Returns [`PtyError::EmptyProgram`] for an empty executable name.
pub fn new(program: impl Into<OsString>) -> Result<Self, PtyError> {
let program = program.into();
if program.is_empty() {
return Err(PtyError::EmptyProgram);
}
Ok(Self {
program,
arguments: Vec::new(),
working_directory: None,
})
}
#[must_use]
pub fn arg(mut self, argument: impl Into<OsString>) -> Self {
self.arguments.push(argument.into());
self
}
#[must_use]
pub fn args<I, S>(mut self, arguments: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.arguments.extend(arguments.into_iter().map(Into::into));
self
}
#[must_use]
pub fn working_directory(mut self, path: impl Into<PathBuf>) -> Self {
self.working_directory = Some(path.into());
self
}
fn into_builder(self) -> CommandBuilder {
let mut command = CommandBuilder::new(self.program);
command.args(self.arguments);
command.env_clear();
for name in inherited_environment_allowlist() {
if let Some(value) = std::env::var_os(name) {
command.env(name, value);
}
}
command.env("TERM", "xterm-256color");
command.env("COLORTERM", "truecolor");
if let Some(directory) = self.working_directory {
command.cwd(directory);
}
command
}
}
fn inherited_environment_allowlist() -> &'static [&'static str] {
&[
"HOME",
"LANG",
"LC_ALL",
"LC_CTYPE",
"LOGNAME",
"PATH",
"SHELL",
"TMPDIR",
"USER",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_RUNTIME_DIR",
]
}
/// Spawn-time settings for a local PTY.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PtyOptions {
pub size: TerminalSize,
/// Capacity in fixed-size output chunks. The reader blocks when full,
/// propagating backpressure to the kernel PTY rather than growing memory.
pub output_queue_chunks: NonZeroUsize,
}
impl PtyOptions {
#[must_use]
pub const fn new(size: TerminalSize) -> Self {
let output_queue_chunks = match NonZeroUsize::new(DEFAULT_OUTPUT_QUEUE_CHUNKS) {
Some(capacity) => capacity,
None => NonZeroUsize::MIN,
};
Self {
size,
output_queue_chunks,
}
}
#[must_use]
pub const fn with_output_queue_chunks(mut self, capacity: NonZeroUsize) -> Self {
self.output_queue_chunks = capacity;
self
}
}
impl Default for PtyOptions {
fn default() -> Self {
Self::new(TerminalSize::default())
}
}
/// A single item delivered by the bounded PTY output queue.
#[derive(Debug, Eq, PartialEq)]
pub enum OutputEvent {
/// Unparsed bytes read from the PTY. A chunk is never larger than 8 KiB.
Data(Vec<u8>),
/// The PTY slave closed normally.
Eof,
/// The reader stopped because the operating-system read failed.
ReadFailed(PtyReadError),
}
/// A redacted, cloneable description of a PTY reader failure.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PtyReadError {
pub kind: io::ErrorKind,
pub message: String,
}
/// A completed child-process status.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExitStatus {
pub code: u32,
pub signal: Option<String>,
}
impl ExitStatus {
#[must_use]
pub const fn success(&self) -> bool {
self.code == 0 && self.signal.is_none()
}
}
impl From<portable_pty::ExitStatus> for ExitStatus {
fn from(status: portable_pty::ExitStatus) -> Self {
Self {
code: status.exit_code(),
signal: status.signal().map(ToOwned::to_owned),
}
}
}
#[derive(Debug, Error)]
pub enum PtyError {
#[error("PTY program cannot be empty")]
EmptyProgram,
#[error("PTY rows and columns must both be non-zero")]
InvalidSize,
#[error("failed to open the local PTY: {0}")]
Open(String),
#[error("failed to clone the local PTY reader: {0}")]
CloneReader(String),
#[error("failed to take the local PTY writer: {0}")]
TakeWriter(String),
#[error("failed to spawn the local PTY child: {0}")]
Spawn(String),
#[error("the PTY session is closed")]
Closed,
#[error("PTY I/O failed")]
Io(#[source] io::Error),
#[error("PTY child did not exit within {0:?}")]
ExitTimeout(Duration),
#[error("PTY output was not available before the timeout")]
OutputTimeout,
#[error("PTY output reader disconnected without an EOF event")]
OutputDisconnected,
}
/// Owns one local PTY, its child process, and its bounded output queue.
///
/// A session is intentionally single-owner. The future runtime actor performs
/// input ordering and forwards output events to terminal emulation.
pub struct PtySession {
master: Option<Box<dyn MasterPty + Send>>,
writer: Option<Box<dyn Write + Send>>,
child: Box<dyn Child + Send + Sync>,
process_id: Option<u32>,
output: Option<Receiver<OutputEvent>>,
reader_thread: Option<JoinHandle<()>>,
exit_status: Option<ExitStatus>,
}
impl PtySession {
/// Opens a native PTY and spawns the configured child inside it.
///
/// # Errors
///
/// Returns an error when the PTY, reader, writer, child, or reader thread
/// cannot be created.
pub fn spawn(config: CommandConfig, options: PtyOptions) -> Result<Self, PtyError> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(options.size.into())
.map_err(|error| PtyError::Open(error.to_string()))?;
let reader = pair
.master
.try_clone_reader()
.map_err(|error| PtyError::CloneReader(error.to_string()))?;
let writer = pair
.master
.take_writer()
.map_err(|error| PtyError::TakeWriter(error.to_string()))?;
let (sender, output) = mpsc::sync_channel(options.output_queue_chunks.get());
let reader_thread = thread::Builder::new()
.name("lumbridge-pty-reader".to_owned())
.spawn(move || read_output(reader, &sender))
.map_err(PtyError::Io)?;
let child = pair
.slave
.spawn_command(config.into_builder())
.map_err(|error| PtyError::Spawn(error.to_string()))?;
let process_id = child.process_id();
drop(pair.slave);
Ok(Self {
master: Some(pair.master),
writer: Some(writer),
child,
process_id,
output: Some(output),
reader_thread: Some(reader_thread),
exit_status: None,
})
}
#[must_use]
pub const fn process_id(&self) -> Option<u32> {
self.process_id
}
/// Writes ordered raw input bytes to the PTY and flushes the writer.
///
/// # Errors
///
/// Returns [`PtyError::Closed`] after input has closed, or [`PtyError::Io`]
/// when the operating-system write fails.
pub fn write_all(&mut self, bytes: &[u8]) -> Result<(), PtyError> {
let writer = self.writer.as_mut().ok_or(PtyError::Closed)?;
writer.write_all(bytes).map_err(PtyError::Io)?;
writer.flush().map_err(PtyError::Io)
}
/// Applies a validated terminal size to the PTY.
///
/// # Errors
///
/// Returns [`PtyError::Closed`] after session cleanup, or [`PtyError::Io`]
/// when the platform resize operation fails.
pub fn resize(&self, size: TerminalSize) -> Result<(), PtyError> {
self.master
.as_ref()
.ok_or(PtyError::Closed)?
.resize(size.into())
.map_err(|error| PtyError::Io(io::Error::other(error.to_string())))
}
/// Receives the next bounded output event before `timeout` elapses.
///
/// # Errors
///
/// Returns a closed, timeout, or unexpected-disconnection error when no
/// event can be delivered.
pub fn recv_output_timeout(&self, timeout: Duration) -> Result<OutputEvent, PtyError> {
let output = self.output.as_ref().ok_or(PtyError::Closed)?;
match output.recv_timeout(timeout) {
Ok(event) => Ok(event),
Err(RecvTimeoutError::Timeout) => Err(PtyError::OutputTimeout),
Err(RecvTimeoutError::Disconnected) => Err(PtyError::OutputDisconnected),
}
}
/// Polls the child without blocking and caches a completed status.
///
/// # Errors
///
/// Returns [`PtyError::Io`] when the platform child poll fails.
pub fn try_wait(&mut self) -> Result<Option<ExitStatus>, PtyError> {
if let Some(status) = &self.exit_status {
return Ok(Some(status.clone()));
}
let status: Option<ExitStatus> =
self.child.try_wait().map_err(PtyError::Io)?.map(Into::into);
if let Some(status) = &status {
self.exit_status = Some(status.clone());
}
Ok(status)
}
/// Blocks until the session leader exits and caches its status.
///
/// Callers should continue consuming output from another runtime task when
/// a child may produce more than the bounded queue can hold.
///
/// # Errors
///
/// Returns [`PtyError::Io`] when the platform wait fails.
pub fn wait(&mut self) -> Result<ExitStatus, PtyError> {
if let Some(status) = &self.exit_status {
return Ok(status.clone());
}
let status = ExitStatus::from(self.child.wait().map_err(PtyError::Io)?);
self.exit_status = Some(status.clone());
Ok(status)
}
/// Polls until the child exits or the supplied timeout expires.
///
/// # Errors
///
/// Returns [`PtyError::ExitTimeout`] on expiry or [`PtyError::Io`] when a
/// platform child poll fails.
pub fn wait_timeout(&mut self, timeout: Duration) -> Result<ExitStatus, PtyError> {
let deadline = Instant::now() + timeout;
loop {
if let Some(status) = self.try_wait()? {
return Ok(status);
}
if Instant::now() >= deadline {
return Err(PtyError::ExitTimeout(timeout));
}
thread::sleep(TERMINATION_POLL.min(deadline.saturating_duration_since(Instant::now())));
}
}
/// Terminates the PTY's process tree and waits for the session leader.
///
/// Unix children run in the session/process group created by
/// `portable-pty`; the whole group is signalled so a shell's foreground
/// process cannot be orphaned. Other platforms use the portable child
/// killer.
///
/// # Errors
///
/// Returns [`PtyError::Io`] when signalling or waiting for the child fails.
pub fn terminate(&mut self) -> Result<ExitStatus, PtyError> {
self.close_input();
terminate_process_tree(self.process_id, self.child.as_mut())?;
self.wait()
}
/// Closes the input half without terminating the child.
pub fn close_input(&mut self) {
self.writer.take();
}
fn disconnect_output(&mut self) {
self.output.take();
}
fn join_finished_reader(&mut self) {
if self
.reader_thread
.as_ref()
.is_some_and(JoinHandle::is_finished)
&& let Some(reader_thread) = self.reader_thread.take()
{
let _ = reader_thread.join();
}
}
}
impl Drop for PtySession {
fn drop(&mut self) {
// Disconnect first so a reader blocked by queue backpressure can exit.
self.disconnect_output();
self.close_input();
if terminate_process_tree(self.process_id, self.child.as_mut()).is_ok() {
let _ = self.child.wait();
} else {
// Destructors must not hang indefinitely when platform signalling
// itself failed. The child handle is still dropped below.
let _ = self.child.try_wait();
}
self.master.take();
self.join_finished_reader();
}
}
fn read_output(mut reader: Box<dyn Read + Send>, sender: &SyncSender<OutputEvent>) {
let mut buffer = vec![0_u8; OUTPUT_CHUNK_BYTES];
loop {
match reader.read(&mut buffer) {
Ok(0) => {
let _ = sender.send(OutputEvent::Eof);
return;
}
Ok(length) => {
if sender
.send(OutputEvent::Data(buffer[..length].to_vec()))
.is_err()
{
return;
}
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => {
let _ = sender.send(OutputEvent::ReadFailed(PtyReadError {
kind: error.kind(),
message: error.to_string(),
}));
return;
}
}
}
}
#[cfg(unix)]
fn terminate_process_tree(
process_id: Option<u32>,
child: &mut (dyn Child + Send + Sync),
) -> Result<(), PtyError> {
use nix::errno::Errno;
use nix::sys::signal::{Signal, killpg};
use nix::unistd::Pid;
let Some(process_id) = process_id.and_then(|id| i32::try_from(id).ok()) else {
child.kill().map_err(PtyError::Io)?;
return Ok(());
};
let group = Pid::from_raw(process_id);
if let Err(error) = killpg(group, Signal::SIGHUP)
&& error != Errno::ESRCH
{
return Err(PtyError::Io(io::Error::from_raw_os_error(error as i32)));
}
let deadline = Instant::now() + TERMINATION_GRACE;
while Instant::now() < deadline {
if child.try_wait().map_err(PtyError::Io)?.is_some() {
break;
}
thread::sleep(TERMINATION_POLL);
}
// Always address the group. The leader may have exited while a foreground
// descendant that ignored SIGHUP remains attached to the terminal.
if let Err(error) = killpg(group, Signal::SIGKILL)
&& error != Errno::ESRCH
{
return Err(PtyError::Io(io::Error::from_raw_os_error(error as i32)));
}
Ok(())
}
#[cfg(not(unix))]
fn terminate_process_tree(
_process_id: Option<u32>,
child: &mut (dyn Child + Send + Sync),
) -> Result<(), PtyError> {
child.kill().map_err(PtyError::Io)
}
#[cfg(test)]
mod tests {
use super::{
CommandConfig, ExitStatus, OutputEvent, PtyError, PtyOptions, PtySession, TerminalSize,
};
use std::num::NonZeroUsize;
use std::time::{Duration, Instant};
const TEST_TIMEOUT: Duration = Duration::from_secs(3);
fn shell(script: &str) -> CommandConfig {
CommandConfig::new("/bin/sh")
.expect("/bin/sh is a valid program")
.args(["-c", script])
}
fn collect_until_eof(session: &PtySession) -> Vec<u8> {
let deadline = Instant::now() + TEST_TIMEOUT;
let mut output = Vec::new();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
assert!(!remaining.is_zero(), "timed out collecting PTY output");
match session.recv_output_timeout(remaining) {
Ok(OutputEvent::Data(bytes)) => {
assert!(bytes.len() <= super::OUTPUT_CHUNK_BYTES);
output.extend(bytes);
}
Ok(OutputEvent::Eof) => return output,
Ok(OutputEvent::ReadFailed(error)) => panic!("PTY read failed: {error:?}"),
Err(error) => panic!("PTY output failed: {error}"),
}
}
}
#[test]
fn captures_raw_output_and_nonzero_exit() {
let mut session = PtySession::spawn(
shell("printf 'lumbridge-ready\\n'; exit 7"),
PtyOptions::default(),
)
.expect("spawn synthetic shell");
let output = collect_until_eof(&session);
let status = session.wait_timeout(TEST_TIMEOUT).expect("shell exits");
assert!(String::from_utf8_lossy(&output).contains("lumbridge-ready"));
assert_eq!(
status,
ExitStatus {
code: 7,
signal: None
}
);
}
#[test]
fn writes_input_and_reports_resize_to_child() {
let initial = TerminalSize::new(24, 80).expect("valid initial size");
let resized = TerminalSize::new(41, 101).expect("valid resized size");
let mut session = PtySession::spawn(
shell("IFS= read -r line; stty size; printf 'got:%s\\n' \"$line\""),
PtyOptions::new(initial),
)
.expect("spawn synthetic shell");
session.resize(resized).expect("resize PTY");
session
.write_all(b"hello-lumbridge\n")
.expect("write PTY input");
let output = String::from_utf8_lossy(&collect_until_eof(&session)).replace('\r', "");
let status = session.wait_timeout(TEST_TIMEOUT).expect("shell exits");
assert!(output.contains("41 101"), "unexpected output: {output:?}");
assert!(
output.contains("got:hello-lumbridge"),
"unexpected output: {output:?}"
);
assert!(status.success());
}
#[test]
fn bounded_queue_preserves_large_output_when_consumed() {
let options = PtyOptions::default()
.with_output_queue_chunks(NonZeroUsize::new(1).expect("non-zero capacity"));
let mut session = PtySession::spawn(
shell("i=0; while [ $i -lt 2048 ]; do printf '0123456789abcdef'; i=$((i+1)); done"),
options,
)
.expect("spawn synthetic producer");
let output = collect_until_eof(&session);
let status = session.wait_timeout(TEST_TIMEOUT).expect("producer exits");
assert_eq!(output.len(), 32 * 1024);
assert!(status.success());
}
#[test]
fn terminate_stops_a_hung_child_with_a_timeout_bound() {
let mut session =
PtySession::spawn(shell("trap '' HUP; exec sleep 30"), PtyOptions::default())
.expect("spawn synthetic sleeper");
let started = Instant::now();
let status = session.terminate().expect("terminate process tree");
assert!(!status.success());
assert!(started.elapsed() < TEST_TIMEOUT);
}
#[test]
fn rejects_invalid_dimensions_and_empty_program() {
assert!(matches!(
TerminalSize::new(0, 80),
Err(PtyError::InvalidSize)
));
assert!(matches!(
CommandConfig::new(""),
Err(PtyError::EmptyProgram)
));
}
#[test]
fn child_environment_is_minimal_and_excludes_provider_credentials() {
let command = shell("exit 0").into_builder();
assert_eq!(
command.get_env("TERM"),
Some(std::ffi::OsStr::new("xterm-256color"))
);
for forbidden in [
"ANTHROPIC_API_KEY",
"CEREBRAS_API_KEY",
"GEMINI_API_KEY",
"GROQ_API_KEY",
"OPENAI_API_KEY",
] {
assert_eq!(command.get_env(forbidden), None, "inherited {forbidden}");
}
}
}
+13 -6
View File
@@ -41,13 +41,17 @@ messages should be real from the beginning.
- `lumbridge`: installable application entry point. - `lumbridge`: installable application entry point.
The scaffold currently contains `lumbridge-core`, `lumbridge-storage`, The scaffold currently contains `lumbridge-core`, `lumbridge-storage`,
`lumbridge-buzz`, and the entry point. Larger runtime crates are added after `lumbridge-buzz`, `lumbridge-pty`, and the entry point. Larger runtime crates are
their architecture spikes pass. added after their architecture spikes pass.
## Terminal path ## Terminal path
The runtime owns PTYs, child process groups, resize signals, input ordering, and The runtime owns PTYs, child process groups, resize signals, input ordering, and
raw output. The terminal engine turns output into immutable render snapshots and raw output. The first `lumbridge-pty` boundary now uses `portable-pty` behind a
single-owner session, a fixed credential-free child environment, validated
sizes, an 8 KiB bounded output queue, resize/input/wait operations, and Unix
process-group cleanup. It deliberately does not emulate a terminal or persist
scrollback. The terminal engine turns output into immutable render snapshots and
bounded deltas for the UI. Scrollback is chunked and persisted separately from bounded deltas for the UI. Scrollback is chunked and persisted separately from
the live screen to prevent large agent transcripts from blocking input. the live screen to prevent large agent transcripts from blocking input.
@@ -59,15 +63,18 @@ and simultaneous automation plus human input.
## UI decision gate ## UI decision gate
Do not lock the project to a webview or to Zed's private implementation details Do not lock the project to a webview or to Zed's application implementation
before a measured spike. Compare: details. The measured spike compares:
1. GPUI for a Zed-like native model and excellent text-heavy interaction. 1. GPUI for a Zed-like native model and excellent text-heavy interaction.
2. Floem for an independent native Rust model with existing editor primitives. 2. Floem for an independent native Rust model with existing editor primitives.
The winner must render six busy panes smoothly, keep input latency low, support The winner must render six busy panes smoothly, keep input latency low, support
IME/accessibility, package on macOS and both Linux targets, and avoid a license IME/accessibility, package on macOS and both Linux targets, and avoid a license
or upstream-stability trap. or upstream-stability trap. Current Zed GPUI is the provisional winner because
its AccessKit semantics compile while the pinned Floem revision has no semantic
accessibility integration. This remains behind a narrow adapter until platform
assistive-technology, IME, packaging, licensing, and release measurements pass.
## Local-first and remote session path ## Local-first and remote session path
+13
View File
@@ -101,6 +101,19 @@ cargo xtest
2025. `scripts/watch.sh` uses Bacon and retains a cargo-watch fallback for people 2025. `scripts/watch.sh` uses Bacon and retains a cargo-watch fallback for people
who already have the final cargo-watch release installed. who already have the final cargo-watch release installed.
## Implemented vertical-slice coverage
- The framework-neutral six-surface model has deterministic tests for focus,
direct selection, needs-input transitions, command-palette lifecycle,
identical GPUI/Floem action replay, bounded output, and workload counters.
- `lumbridge-pty` runs synthetic `/bin/sh` tests for raw output, non-zero exits,
input, resize, one-chunk backpressure, hung-process termination, invalid
dimensions, and exclusion of provider credentials from the child environment.
- The current-GPUI probe compile-checks real AccessKit element wiring and real
platform input-handler installation. Unit tests cover its semantic tree and
UTF-16/UTF-8 composed-text mutations. OS screen readers, IME candidate windows,
and macOS behavior remain platform tests.
## CI matrix as the project grows ## CI matrix as the project grows
Every change runs format, Clippy, unit, contract, integration, and doctests on Every change runs format, Clippy, unit, contract, integration, and doctests on
+7
View File
@@ -10,6 +10,13 @@ This decision stays open until the finalists run the same workload. Choosing on
repository popularity or a polished demo would hide the risks that matter for a repository popularity or a polished demo would hide the risks that matter for a
terminal-heavy, accessibility-aware, cross-platform IDE. terminal-heavy, accessibility-aware, cross-platform IDE.
The current vertical-slice result is GPUI-first, not GPUI-final: current Zed
GPUI compile-proves the required AccessKit and IME handler paths, while the
pinned Floem revision fails the semantic-accessibility hard gate. The app-facing
UI boundary must stay narrow until platform AT/IME, packaging, license closure,
and release measurements finish. See `UI_SPIKE_SCORECARD.md` for evidence and
remaining gates.
## Finalists ## Finalists
### GPUI ### GPUI
+49 -3
View File
@@ -10,8 +10,8 @@ from macOS and Linux and the hard gates pass.
| Builds on Ubuntu | yes | conditional | pass | | Builds on Ubuntu | yes | conditional | pass |
| Builds on Omarchy/Arch | yes | pending | pending | | Builds on Omarchy/Arch | yes | pending | pending |
| Dependency/license closure permits Apache-2.0 distribution | yes | pending | pending | | Dependency/license closure permits Apache-2.0 distribution | yes | pending | pending |
| Keyboard navigation + AccessKit tree | yes | pending | pending | | Keyboard navigation + AccessKit tree | yes | keyboard pass; current-GPUI semantics compile; platform AT pending | fail: no AccessKit integration at pinned revision |
| IME and composed Unicode input | yes | pending | pending | | IME and composed Unicode input | yes | framework API exists; end-to-end pending | editor API exists; end-to-end pending |
| Isolated system browser child | yes | pending | pending | | Isolated system browser child | yes | pending | pending |
| Cold startup, p50/p95 | record | pending | pending | | Cold startup, p50/p95 | record | pending | pending |
| Idle RSS / six-stream RSS | record | pending | pending | | Idle RSS / six-stream RSS | record | pending | pending |
@@ -43,4 +43,50 @@ performance numbers. GPUI's Ubuntu row remains conditional because the host has
the runtime `libxkbcommon-x11.so.0` but lacks the unversioned development linker the runtime `libxkbcommon-x11.so.0` but lacks the unversioned development linker
name normally supplied by `libxkbcommon-x11-dev`. Floem current main also name normally supplied by `libxkbcommon-x11-dev`. Floem current main also
requires its `editor` feature for the style/label modules used by this shell; requires its `editor` feature for the style/label modules used by this shell;
that feature is now explicit in the spike. Neither finding decides the UI stack. that feature is now explicit in the spike. Neither build result by itself
decides the UI stack.
## Accessibility gate and provisional direction
The published `gpui 0.2.2` used by the visual shell has focus and keyboard
actions but does not expose the current AccessKit element API. An isolated probe
therefore pins GPUI directly to Zed commit
`ce48461eaadd16c65c31f835511ab96bd3b6e746` with its matching Rust 1.97.1
toolchain. The real element wiring compiles with stable accessibility IDs,
workspace/pane/terminal roles, selected state, focus tracking, and a needs-input
description. The same probe installs real `EntityInputHandler` and
`ElementInputHandler` implementations and tests marked-text commit, a composed
accent, and a multi-codepoint emoji without splitting UTF-8. Three deterministic
tests pass. AT-SPI/OS IME on Linux and VoiceOver/IME on macOS remain end-to-end
gates.
The pinned Floem revision has keyboard focus and editor IME plumbing but no
AccessKit dependency or semantic tree. That fails Lumbridge's accessibility
gate without a maintained framework fork or adapter. The provisional direction
is therefore **GPUI-first using current Zed GPUI behind a narrow Lumbridge UI
adapter**. The published GPUI shell remains the lightweight interaction and
visual benchmark until the dependency/toolchain migration is accepted.
This is not the final cross-platform release decision. Current-GPUI validation
on amd-server required a 624 MiB Rust 1.97.1 toolchain, a 691-package lockfile,
and 4.4 GiB of debug artifacts across check, test, Clippy, and build. Packaging,
license closure, IME, platform assistive technology, browser parenting, macOS,
and Omarchy still have to pass.
The comparable Floem shell passes build, test, strict Clippy, live rendering,
streaming, mouse selection, and numeric pane selection on metal. Its command
palette opens through the visible clickable affordance and uses Floem's native
`TextInput`, but Ctrl/Cmd+K did not reach the handler in the X11 automation
probe. That unresolved focus/event-routing behavior is another current strike
against adopting Floem; it is recorded rather than hidden behind the clickable
fallback.
## Measurement semantics
Both renderers consume the same deterministic six-surface action stream. Each
tick invalidates six surfaces, appends one bounded line to each of the three
terminal fixtures, and records stable event/revision counters. The GPUI footer
currently reports dispatch-to-element-build p50/p95 over a bounded 256-sample
window. It is deliberately not called key-to-present or frame-present latency:
neither candidate exposes a reliable public cross-platform post-present
callback. True present latency requires an external platform probe.
+107
View File
@@ -0,0 +1,107 @@
# Decisive native workspace vertical slice
## Outcome
The slice must let an engineer notice one waiting agent, move focus to it from
the keyboard, inspect the request, and choose a safe next action without losing
the state of five other busy surfaces. It is a product interaction test and a
framework decision gate, not a decorative dashboard.
## Current-run audit
The 2026-08-31 GPUI baseline succeeds at calm density: the six surfaces are easy
to scan, typography is consistent, and local/remote targets remain visible. It
fails the task flow in four important ways:
1. all panes have equal visual priority, so `NEEDS INPUT` is too easy to miss;
2. no pane exposes visible keyboard focus or selection;
3. the command palette affordance has no interaction behind it;
4. status and usage text in the footer is too quiet to explain provenance or
confidence at a glance.
Screenshots for the audit are stored outside Git under
`~/shots/2026-08/lumbridge-ui-audit/`. Accessibility and IME correctness cannot
be established from screenshots and remain explicit runtime gates.
## Preserved design language
- Near-black blue surfaces with one cool-blue action accent.
- Thin borders and compact spacing; no elevated card shadows.
- Text carries most hierarchy. Color is supplementary and never the only state
signal.
- Monospace content inside terminal surfaces; system UI typography elsewhere.
- Dense information, but only the focused or waiting surface becomes prominent.
## Orca reference, translated for Lumbridge
The visual reference is the MIT-licensed Orca repository at researched commit
`02a7742406a5a84fb372d6255d5a4367421990bd`. Its screenshots are product
research, not source assets for Lumbridge. We adapt these interaction patterns:
- a worktree/task-first sidebar whose rows show state without becoming large
cards;
- a quiet top tab strip for mixed terminal, Markdown, browser, and review
surfaces;
- one primary work surface with optional splits, instead of forcing every
surface into an equal dashboard tile;
- narrow contextual tools, such as files, review, or Buzz, that can collapse
when the terminal needs the space;
- usage and agent state at the edge of the workspace rather than in modal
account pages.
Lumbridge deliberately differs where its product model differs: remote runtime
ownership is always visible, `needs input` receives a dedicated attention path,
usage includes provenance and uncertainty, and Buzz is a signed collaboration
surface rather than a hosted Lumbridge control plane.
## Visual hierarchy
1. The top bar names the current workspace, repository/branch, and command
palette shortcut.
2. The sidebar starts with a compact `ATTENTION` group, followed by workspaces
and remote hosts. It does not become a notification feed.
3. The active pane receives a two-pixel accent edge plus a quiet tinted header.
4. A waiting pane uses an amber semantic label and remains visible when another
pane is selected. The label always includes words such as `NEEDS INPUT`.
5. Pane headers expose title, execution target, harness state, and unread count
in a stable order.
6. The footer groups connection state, selected-harness identity, usage-window
provenance, and burn forecast into readable regions.
## Keyboard task flow
- Arrow keys or `h/j/k/l`: move focus through the 2×3 pane grid.
- `1` through `6`: focus a pane directly.
- `Cmd+K` on macOS or `Ctrl+K` on Linux: open the command palette.
- Typing while the palette is open changes its query; arrows change the result;
`Enter` runs it; `Escape` closes it and restores pane focus.
- `Enter` on a waiting agent opens its request detail. Approval remains a
separate explicit action and is never triggered by focusing the pane.
- `Escape` closes transient UI before it changes workspace state.
Every command is represented by the shared interaction model so GPUI and Floem
receive the same state transitions and tests.
## Decisive workload
- One deterministic tick invalidates all six surfaces and appends bounded output
to the three terminal panes. Framework adapters consume the same event trace.
- One pane enters and leaves `needs input` through a deterministic event.
- Markdown, browser-boundary, and review panes update counters without using a
web application shell.
- The model records actions, revisions, six-surface updates, and bounded terminal
line counts. Framework adapters label each measured timing stage explicitly;
element-build timing is never presented as display-present timing.
- A later PTY adapter replaces one synthetic stream without changing the UI
contract. Synthetic streams stay available for repeatable performance tests.
## Hard gates
- Every focus movement and palette command is unit tested without a window.
- Focus and `needs input` remain distinguishable in monochrome screenshots.
- The current-GPUI input probe preserves composed Unicode and multi-codepoint
graphemes. Real candidate-window placement still requires Linux/macOS testing.
- The accessibility tree names each pane, selected state, execution target, and
waiting state.
- Six streams remain responsive while the palette is open.
- Closing the UI never implies that a runtime-owned session has stopped.
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
repo_path=${LUMBRIDGE_REPO:-/home/metal/Desktop/Lumbridge Code/lumbridge}
window_title='Lumbridge · GPUI workspace'
bacon_title='Lumbridge · Bacon'
manifest_path="$repo_path/spikes/gpui-shell/Cargo.toml"
binary_path="$repo_path/spikes/gpui-shell/target/debug/lumbridge-spike-gpui"
if [[ ! -d "$repo_path/.git" ]]; then
echo "Lumbridge repository not found at $repo_path" >&2
exit 1
fi
export DISPLAY=${DISPLAY:-:0}
export XAUTHORITY=${XAUTHORITY:-/run/user/1000/gdm/Xauthority}
export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/1000}
export DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS:-unix:path=/run/user/1000/bus}
for command_name in cargo wmctrl; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "required command not found: $command_name" >&2
exit 1
fi
done
if [[ ! -x "$binary_path" ]]; then
cargo build --locked --manifest-path "$manifest_path"
fi
existing_id=$(wmctrl -l | awk -v title="$window_title" 'index($0, title) { print $1; exit }')
if [[ -z "$existing_id" ]]; then
"$binary_path" >"$XDG_RUNTIME_DIR/lumbridge-gpui.log" 2>&1 &
for _attempt in $(seq 1 80); do
existing_id=$(wmctrl -l | awk -v title="$window_title" 'index($0, title) { print $1; exit }')
[[ -n "$existing_id" ]] && break
sleep 0.1
done
fi
if [[ -z "$existing_id" ]]; then
echo "Lumbridge window did not become visible; see $XDG_RUNTIME_DIR/lumbridge-gpui.log" >&2
exit 1
fi
# Gigabyte G34WQC is 3440x1440 at X=0. Lumbridge owns its left half; Bacon
# remains visible beside it on the right half of the same monitor.
bacon_id=$(wmctrl -l | awk -v title="$bacon_title" 'index($0, title) { print $1; exit }')
if [[ -n "$bacon_id" ]]; then
wmctrl -i -r "$bacon_id" -b remove,maximized_vert,maximized_horz
wmctrl -i -r "$bacon_id" -e 0,1720,0,1720,1400
fi
wmctrl -i -r "$existing_id" -b remove,maximized_vert,maximized_horz
wmctrl -i -r "$existing_id" -e 0,0,0,1720,1400
wmctrl -i -a "$existing_id"
echo "Lumbridge is visible in window $existing_id on the left half of the Gigabyte display."
+421 -91
View File
@@ -1,151 +1,481 @@
use floem::{Application, kurbo::Size, peniko::Color, prelude::*, window::WindowConfig}; use std::time::Duration;
use floem::{
Application,
action::exec_after,
event::EventPropagation,
kurbo::Size,
peniko::Color,
prelude::*,
reactive::{Effect, RwSignal, SignalGet, SignalTrack, SignalUpdate, SignalWith},
style::Position,
window::WindowConfig,
};
use lumbridge_spike_model::{ use lumbridge_spike_model::{
FOOTER_CENTER, FOOTER_LEFT, FOOTER_RIGHT, PANES, PaneFixture, WORKSPACES, FOOTER_CENTER, FOOTER_RIGHT, FocusDirection, PaneId, PaneState, ShellAction, ShellModel,
SurfaceKind, WORKSPACES,
}; };
const BG: Color = Color::from_rgb8(12, 14, 19); const BG: Color = Color::from_rgb8(9, 12, 18);
const PANEL: Color = Color::from_rgb8(18, 23, 34); const PANEL: Color = Color::from_rgb8(16, 22, 32);
const PANEL_ALT: Color = Color::from_rgb8(23, 29, 41); const PANEL_ALT: Color = Color::from_rgb8(21, 29, 41);
const BORDER: Color = Color::from_rgb8(41, 50, 68); const PANEL_ACTIVE: Color = Color::from_rgb8(24, 35, 52);
const TEXT: Color = Color::from_rgb8(217, 226, 242); const BORDER: Color = Color::from_rgb8(38, 50, 70);
const MUTED: Color = Color::from_rgb8(127, 139, 163); const BORDER_QUIET: Color = Color::from_rgb8(28, 38, 54);
const ACCENT: Color = Color::from_rgb8(119, 189, 251); const TEXT: Color = Color::from_rgb8(219, 229, 244);
const MUTED: Color = Color::from_rgb8(130, 144, 168);
const ACCENT: Color = Color::from_rgb8(104, 181, 248);
const ATTENTION: Color = Color::from_rgb8(241, 185, 106);
const SUCCESS: Color = Color::from_rgb8(112, 214, 168);
const OS_MOD: Modifiers = if cfg!(target_os = "macos") {
Modifiers::META
} else {
Modifiers::CONTROL
};
fn pane_card(pane: PaneFixture) -> impl IntoView { fn dispatch(model: RwSignal<ShellModel>, action: ShellAction) {
let header = Stack::horizontal(( model.update(|model| {
Label::new(pane.title).style(|s| s.flex_grow(1.0).color(TEXT)), model.dispatch(action);
Label::new(pane.badge).style(|s| s.font_size(11.0).color(ACCENT)),
))
.style(|s| {
s.items_center()
.height(34.0)
.padding_horiz(10.0)
.background(PANEL_ALT)
.border_bottom(1.0)
.border_color(BORDER)
}); });
}
let lines = fn pane_card(id: PaneId, model: RwSignal<ShellModel>) -> impl IntoView {
Stack::vertical(pane.lines.map(|line| { dyn_container(
Label::new(line).style(|s| s.font_size(12.0).color(TEXT).margin_bottom(5.0)) move || model.with(|model| (model.pane(id).clone(), model.selected_pane() == id)),
})); move |(pane, selected)| render_pane_card(pane, selected, model),
)
Stack::vertical((
header,
Label::new(pane.target).style(|s| s.font_size(11.0).color(MUTED).padding(10.0)),
lines.style(|s| s.padding_horiz(10.0).padding_bottom(10.0)),
))
.style(|s| { .style(|s| {
s.flex_basis(0) s.flex_basis(0)
.flex_grow(1.0) .flex_grow(1.0)
.min_width(0.0) .min_width(0.0)
.min_height(0.0) .min_height(0.0)
.margin(4.0) .margin(4.0)
.background(PANEL)
.border(1.0)
.border_color(BORDER)
.border_radius(6.0)
}) })
} }
fn app_view() -> impl IntoView { fn render_pane_card(pane: PaneState, selected: bool, model: RwSignal<ShellModel>) -> impl IntoView {
let sidebar_items = Stack::vertical(WORKSPACES.map(|name| { let id = pane.id();
Label::new(name).style(move |s| { let needs_input = pane.needs_input();
s.width_full() let state_color = if needs_input {
.padding_vert(8.0) ATTENTION
.padding_horiz(12.0) } else if matches!(pane.kind(), SurfaceKind::Terminal) {
.margin_bottom(3.0) SUCCESS
.color(if name == "Lumbridge Code" { } else {
TEXT ACCENT
} else { };
MUTED let state_label = if matches!(pane.kind(), SurfaceKind::Terminal) {
}) pane.status().label()
.apply_if(name == "Lumbridge Code", |s| { } else {
s.background(PANEL_ALT).border_radius(6.0) pane.fixture().badge
}) };
let start = pane.lines().len().saturating_sub(12);
let lines = Stack::from_iter(pane.lines()[start..].iter().cloned().map(|line| {
Label::new(line).style(|s| {
s.font_size(12.0)
.color(TEXT)
.margin_bottom(4.0)
.min_width(0.0)
}) })
})); }))
.style(|s| s.flex_col().padding_horiz(10.0).padding_bottom(10.0));
let sidebar = Stack::vertical(( let header = Stack::horizontal((
Label::new("WORKSPACES").style(|s| s.font_size(11.0).color(MUTED).padding(12.0)), Stack::horizontal((
sidebar_items.style(|s| s.padding_horiz(8.0)), Label::new(format!("{}", id.index() + 1))
Label::new("HOSTS").style(|s| { .style(|s| s.font_size(11.0).color(MUTED).margin_right(8.0)),
s.font_size(11.0) Label::new(pane.fixture().title)
.color(MUTED) .style(|s| s.flex_grow(1.0).font_size(13.0).color(TEXT)),
.padding(12.0) ))
.margin_top(12.0) .style(|s| s.items_center().min_width(0.0).flex_grow(1.0)),
}), Label::new(state_label).style(move |s| s.font_size(10.0).color(state_color)),
Label::new("● metal · connected").style(|s| s.color(ACCENT).padding_horiz(14.0)), ))
Label::new("● amd-server · connected") .style(move |s| {
.style(|s| s.color(MUTED).padding_horiz(14.0).margin_top(8.0)), s.items_center()
Label::new("○ spark-1 · sleeping") .height(36.0)
.style(|s| s.color(MUTED).padding_horiz(14.0).margin_top(8.0)), .padding_horiz(10.0)
.background(if selected { PANEL_ACTIVE } else { PANEL_ALT })
.border_bottom(1.0)
.border_color(BORDER_QUIET)
});
let target = Stack::horizontal((
Label::new(pane.fixture().target).style(|s| s.flex_grow(1.0).color(MUTED)),
Label::new(if selected { "FOCUSED" } else { "" })
.style(|s| s.font_size(10.0).color(ACCENT)),
)) ))
.style(|s| { .style(|s| {
s.width(220.0) s.items_center()
.padding_horiz(10.0)
.padding_vert(8.0)
.font_size(11.0)
});
Stack::vertical((header, target, lines))
.style(move |s| {
s.width_full()
.height_full()
.min_width(0.0)
.min_height(0.0)
.background(if selected { PANEL_ACTIVE } else { PANEL })
.border(if selected { 2.0 } else { 1.0 })
.border_color(if selected || needs_input {
state_color
} else {
BORDER
})
.border_radius(5.0)
.keyboard_navigable()
})
.on_event_stop(listener::Click, move |_, _| {
dispatch(model, ShellAction::SelectPane(id));
})
}
fn sidebar() -> impl IntoView {
let worktrees = Stack::from_iter(WORKSPACES.into_iter().enumerate().map(|(index, name)| {
Stack::vertical((
Label::new(name),
Label::new(if index == 0 { "main · metal" } else { "" })
.style(|s| s.font_size(10.0).color(MUTED).margin_top(2.0)),
))
.style(move |s| {
s.width_full()
.padding_vert(7.0)
.padding_horiz(10.0)
.margin_bottom(3.0)
.color(if index == 0 { TEXT } else { MUTED })
.apply_if(index == 0, |s| s.background(PANEL_ALT).border_radius(5.0))
})
}))
.style(|s| s.flex_col().padding_horiz(8.0));
Stack::vertical((
Label::new("ATTENTION · 1").style(|s| s.font_size(10.0).color(ATTENTION).padding(12.0)),
Stack::vertical((
Label::new("Claude Code · UI").style(|s| s.color(TEXT)),
Label::new("Waiting for a split decision")
.style(|s| s.font_size(10.0).color(MUTED).margin_top(3.0)),
))
.style(|s| {
s.margin_horiz(8.0)
.margin_bottom(12.0)
.padding(10.0)
.background(PANEL_ACTIVE)
.border(1.0)
.border_color(ATTENTION)
.border_radius(5.0)
}),
Label::new("WORKTREES").style(|s| s.font_size(10.0).color(MUTED).padding(12.0)),
worktrees,
Label::new("RUNTIMES").style(|s| {
s.font_size(10.0)
.color(MUTED)
.padding_horiz(12.0)
.padding_vert(9.0)
.margin_top(8.0)
}),
Label::new("● metal · connected").style(|s| s.color(SUCCESS).padding_horiz(12.0)),
Label::new("● amd-server · connected")
.style(|s| s.color(MUTED).padding_horiz(12.0).margin_top(7.0)),
Label::new("○ spark-1 · sleeping")
.style(|s| s.color(MUTED).padding_horiz(12.0).margin_top(7.0)),
Empty::new().style(|s| s.flex_grow(1.0)),
Stack::vertical((
Label::new("Buzz · lumbridgecode"),
Label::new("connected · signed identity").style(|s| s.color(SUCCESS).margin_top(3.0)),
))
.style(|s| {
s.margin(10.0)
.padding(10.0)
.font_size(10.0)
.color(MUTED)
.background(PANEL_ALT)
.border_radius(5.0)
}),
))
.style(|s| {
s.width(248.0)
.height_full() .height_full()
.flex_shrink(0.0) .flex_shrink(0.0)
.background(PANEL) .background(PANEL)
.border_right(1.0) .border_right(1.0)
.border_color(BORDER) .border_color(BORDER_QUIET)
})
}
fn tabs() -> impl IntoView {
Stack::horizontal((
Label::new("Agent workspace").style(|s| {
s.height_full()
.items_center()
.padding_horiz(12.0)
.border_bottom(2.0)
.border_color(ACCENT)
.color(TEXT)
}),
Label::new("Architecture.md").style(|s| s.padding_horiz(12.0).color(MUTED)),
Label::new("Review").style(|s| s.padding_horiz(12.0).color(MUTED)),
Empty::new().style(|s| s.flex_grow(1.0)),
Label::new("6 surfaces · 3 remote · 1 waiting")
.style(|s| s.font_size(10.0).padding_horiz(12.0).color(MUTED)),
))
.style(|s| {
s.height(38.0)
.items_center()
.font_size(12.0)
.background(PANEL)
.border_bottom(1.0)
.border_color(BORDER_QUIET)
})
}
fn command_palette(query: RwSignal<String>) -> impl IntoView {
let input = TextInput::new(query)
.placeholder("Type a command…")
.style(|s| {
s.width_full()
.height(44.0)
.padding_horiz(12.0)
.color(TEXT)
.background(PANEL_ALT)
.border(0.0)
});
let input_id = input.id();
exec_after(Duration::from_millis(1), move |_| input_id.request_focus());
let card = Stack::vertical((
input,
Stack::vertical((
Stack::vertical((
Label::new("Focus next pane").style(|s| s.color(TEXT)),
Label::new("Workspace · navigation")
.style(|s| s.font_size(10.0).color(MUTED).margin_top(3.0)),
))
.style(|s| s.padding(10.0).background(PANEL_ACTIVE).border_radius(5.0)),
Label::new("Open attention request").style(|s| s.padding(10.0).color(MUTED)),
Label::new("Share selected pane to Buzz…").style(|s| s.padding(10.0).color(MUTED)),
))
.style(|s| {
s.padding(8.0)
.border_top(1.0)
.border_bottom(1.0)
.border_color(BORDER)
}),
Stack::horizontal((
Label::new("Enter to run").style(|s| s.flex_grow(1.0)),
Label::new("Esc to close"),
))
.style(|s| {
s.padding_horiz(12.0)
.padding_vert(8.0)
.font_size(10.0)
.color(MUTED)
}),
))
.style(|s| {
s.width(620.0)
.margin_top(92.0)
.background(PANEL_ALT)
.border(1.0)
.border_color(ACCENT)
.border_radius(8.0)
});
Container::new(card).style(|s| {
s.position(Position::Absolute)
.inset(0.0)
.items_start()
.justify_center()
.background(Color::from_rgba8(5, 7, 11, 220))
.z_index(50)
})
}
fn handle_key(
model: RwSignal<ShellModel>,
query: RwSignal<String>,
KeyboardEvent {
code,
key,
modifiers,
..
}: &KeyboardEvent,
) -> EventPropagation {
if *code == Code::KeyK && modifiers.contains(OS_MOD) {
query.set(String::new());
dispatch(model, ShellAction::OpenCommandPalette);
return EventPropagation::Stop;
}
if model.with(|model| model.command_palette().is_open()) {
if *key == Key::Named(NamedKey::Escape) {
query.set(String::new());
dispatch(model, ShellAction::CloseCommandPalette);
return EventPropagation::Stop;
}
return EventPropagation::Continue;
}
let action = match key {
Key::Named(NamedKey::ArrowLeft) => Some(ShellAction::MoveFocus(FocusDirection::Left)),
Key::Named(NamedKey::ArrowRight) => Some(ShellAction::MoveFocus(FocusDirection::Right)),
Key::Named(NamedKey::ArrowUp) => Some(ShellAction::MoveFocus(FocusDirection::Up)),
Key::Named(NamedKey::ArrowDown) => Some(ShellAction::MoveFocus(FocusDirection::Down)),
Key::Character(character) if modifiers.is_empty() => match character.as_str() {
"h" => Some(ShellAction::MoveFocus(FocusDirection::Left)),
"l" => Some(ShellAction::MoveFocus(FocusDirection::Right)),
"k" => Some(ShellAction::MoveFocus(FocusDirection::Up)),
"j" => Some(ShellAction::MoveFocus(FocusDirection::Down)),
"1" => Some(ShellAction::SelectPane(PaneId::CodexRuntime)),
"2" => Some(ShellAction::SelectPane(PaneId::ClaudeUi)),
"3" => Some(ShellAction::SelectPane(PaneId::PiDocs)),
"4" => Some(ShellAction::SelectPane(PaneId::Architecture)),
"5" => Some(ShellAction::SelectPane(PaneId::AcpPreview)),
"6" => Some(ShellAction::SelectPane(PaneId::RuntimeReview)),
_ => None,
},
_ => None,
};
if let Some(action) = action {
dispatch(model, action);
EventPropagation::Stop
} else {
EventPropagation::Continue
}
}
fn app_view() -> impl IntoView {
let model = RwSignal::new(ShellModel::default());
let query = RwSignal::new(String::new());
let timer_pulse = RwSignal::new(());
Effect::new(move |_| {
timer_pulse.track();
exec_after(Duration::from_millis(650), move |_| {
dispatch(model, ShellAction::SyntheticStreamTick);
timer_pulse.set(());
});
});
Effect::new(move |_| {
let query = query.get();
model.update(|model| {
if model.command_palette().is_open() && model.command_palette().query() != query {
model.dispatch(ShellAction::SetCommandPaletteQuery(query));
}
});
}); });
let top_row = Stack::horizontal(( let top_row = Stack::horizontal((
pane_card(PANES[0]), pane_card(PaneId::CodexRuntime, model),
pane_card(PANES[1]), pane_card(PaneId::ClaudeUi, model),
pane_card(PANES[2]), pane_card(PaneId::PiDocs, model),
)) ))
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0).width_full()); .style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0).width_full());
let bottom_row = Stack::horizontal(( let bottom_row = Stack::horizontal((
pane_card(PANES[3]), pane_card(PaneId::Architecture, model),
pane_card(PANES[4]), pane_card(PaneId::AcpPreview, model),
pane_card(PANES[5]), pane_card(PaneId::RuntimeReview, model),
)) ))
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0).width_full()); .style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0).width_full());
let panes = Stack::vertical((top_row, bottom_row)).style(|s| { let grid = Stack::vertical((top_row, bottom_row)).style(|s| {
s.flex_basis(0) s.flex_basis(0)
.flex_grow(1.0) .flex_grow(1.0)
.min_width(0.0) .min_width(0.0)
.height_full() .min_height(0.0)
.padding(4.0) .padding(4.0)
}); });
let header = Stack::horizontal(( let header = Stack::horizontal((
Label::new("Lumbridge").style(|s| s.font_size(18.0).color(TEXT).flex_grow(1.0)), Label::new("Lumbridge").style(|s| s.font_size(18.0).color(TEXT)),
Label::new("UI spike · Floem").style(|s| s.color(MUTED).flex_grow(1.0)), Label::new("Lumbridge Code / main")
Label::new("⌘K Command Palette").style(|s| s.color(ACCENT)), .style(|s| s.color(MUTED).margin_left(12.0).flex_grow(1.0)),
Label::new("metal · runtime online")
.style(|s| s.font_size(10.0).color(SUCCESS).margin_right(18.0)),
Label::new("Ctrl/⌘ K · Commands")
.style(|s| s.color(ACCENT).keyboard_navigable())
.on_event_stop(listener::Click, move |_, _| {
query.set(String::new());
dispatch(model, ShellAction::OpenCommandPalette);
}),
)) ))
.style(|s| { .style(|s| {
s.height(46.0) s.height(48.0)
.padding_horiz(14.0) .padding_horiz(14.0)
.items_center() .items_center()
.background(PANEL_ALT) .background(PANEL_ALT)
.border_bottom(1.0) .border_bottom(1.0)
.border_color(BORDER) .border_color(BORDER_QUIET)
}); });
let body = Stack::horizontal((sidebar, panes)) let body = Stack::horizontal((
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0)); sidebar(),
Stack::vertical((tabs(), grid)).style(|s| {
s.flex_basis(0)
.flex_grow(1.0)
.min_width(0.0)
.min_height(0.0)
}),
))
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0));
let footer = Stack::horizontal(( let footer = Stack::horizontal((
Label::new(FOOTER_LEFT).style(|s| s.flex_grow(1.0)), Label::derived(move || {
model.with(|model| {
let counters = model.counters();
format!(
"rev {} · {} focus moves · {} surface updates · {} lines",
model.revision(),
counters.focus_moves,
counters.surface_updates,
counters.terminal_lines_appended
)
})
})
.style(|s| s.flex_grow(1.0)),
Label::new(FOOTER_CENTER).style(|s| s.flex_grow(1.0)), Label::new(FOOTER_CENTER).style(|s| s.flex_grow(1.0)),
Label::new(FOOTER_RIGHT), Label::new(FOOTER_RIGHT),
)) ))
.style(|s| { .style(|s| {
s.height(30.0) s.height(32.0)
.padding_horiz(10.0) .padding_horiz(10.0)
.items_center() .items_center()
.font_size(11.0) .font_size(10.0)
.color(MUTED) .color(MUTED)
.background(PANEL_ALT) .background(PANEL_ALT)
.border_top(1.0) .border_top(1.0)
.border_color(BORDER) .border_color(BORDER_QUIET)
}); });
Stack::vertical((header, body, footer)) let palette = dyn_container(
.style(|s| s.width_full().height_full().background(BG)) move || model.with(|model| model.command_palette().is_open()),
.window_title(|| "Lumbridge · Floem spike".to_owned()) move |open| {
if open {
command_palette(query).into_any()
} else {
Empty::new().into_any()
}
},
);
let root = Stack::vertical((header, body, footer, palette))
.style(|s| {
s.position(Position::Relative)
.width_full()
.height_full()
.background(BG)
.color(TEXT)
.keyboard_navigable()
})
.on_event(listener::KeyDown, move |_, event| {
handle_key(model, query, event)
})
.window_title(|| "Lumbridge · Floem workspace".to_owned());
let root_id = root.id();
exec_after(Duration::from_millis(1), move |_| root_id.request_focus());
root
} }
fn main() { fn main() {
@@ -154,9 +484,9 @@ fn main() {
|_| app_view(), |_| app_view(),
Some( Some(
WindowConfig::default() WindowConfig::default()
.size(Size::new(1280.0, 800.0)) .size(Size::new(1500.0, 960.0))
.min_size(Size::new(900.0, 600.0)) .min_size(Size::new(900.0, 600.0))
.title("Lumbridge · Floem spike"), .title("Lumbridge · Floem workspace"),
), ),
) )
.run(); .run();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
[package]
name = "lumbridge-spike-gpui-accessibility"
description = "Isolated proof of current GPUI accessibility and IME input for Lumbridge"
version = "0.0.1"
edition = "2024"
rust-version = "1.97.1"
license = "Apache-2.0"
publish = false
[dependencies]
gpui = { git = "https://github.com/zed-industries/zed.git", rev = "ce48461eaadd16c65c31f835511ab96bd3b6e746", default-features = false, features = ["wayland", "x11"] }
gpui_platform = { git = "https://github.com/zed-industries/zed.git", rev = "ce48461eaadd16c65c31f835511ab96bd3b6e746", default-features = false, features = ["font-kit", "wayland", "x11"] }
[workspace]
+83
View File
@@ -0,0 +1,83 @@
# GPUI accessibility and input probe
This independent workspace answers one narrow question: can the GPUI version
currently used by Zed expose the minimum semantic tree Lumbridge needs for a
workspace and terminal pane, while accepting real platform IME input?
## Reproducibility
- Upstream: `https://github.com/zed-industries/zed`
- Commit: `ce48461eaadd16c65c31f835511ab96bd3b6e746`
- GPUI license at this commit: Apache-2.0
- Rust: 1.97.1, matching the upstream `rust-toolchain.toml`
Both `gpui` and `gpui_platform` are pinned to the full commit. This directory is
an independent Cargo workspace, so its toolchain and dependency graph do not
change Lumbridge's root MSRV or release graph.
## Semantics under test
The rendered tree contains:
- an application named `Lumbridge accessibility probe`;
- a stable, externally identifiable `Region` named `Lumbridge workspace`;
- a stable, focusable `Pane` named `Local terminal pane`;
- `selected = true` on the pane;
- real GPUI keyboard focus tracked on the pane;
- the description `Needs input: choose whether to run the proposed command`;
- a `Terminal` child named `Terminal output` for the pane's content.
Current GPUI exposes these through its AccessKit integration using `role`,
`accessibility_id`, `aria_label`, `aria_description`, `aria_selected`,
`focusable`, and `track_focus`. The deterministic unit test checks the exact
AccessKit role and properties. The binary compile-checks GPUI's element wiring;
a platform screen reader remains necessary for end-to-end AT-SPI/VoiceOver
validation.
## Input and IME proof
The focused pane also installs a real `ElementInputHandler<ImeTextReceiver>`
during element paint. `ImeTextReceiver` implements GPUI's
`EntityInputHandler`, including text queries, UTF-16 selection, marked-text
composition, replacement, unmarking, selection updates, editable length, and
IME candidate bounds. This is the code path GPUI's platform adapters call for
composed operating-system text input.
The backing buffer stores UTF-8 only at valid scalar boundaries and translates
the platform's UTF-16 ranges before mutation. Deterministic tests cover:
- replacing a selected decomposed `e` plus combining acute accent with `é`;
- a marked decomposed-accent IME composition and commit;
- inserting, selecting, and replacing the multi-codepoint grapheme `👩🏽‍💻`;
- collapsed selection placement after every replacement.
These tests prove range conversion and composed-text preservation without
splitting UTF-8. An actual keyboard IME on X11/Wayland and macOS remains an
end-to-end platform test, not a unit-test claim.
## Commands
```bash
cargo check --locked
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
```
The first command installs the pinned Rust toolchain if rustup does not already
have it and downloads Zed's GPUI dependency closure.
On Ubuntu hosts that have only the runtime libraries, checks work with
`RUST_FONTCONFIG_DLOPEN=1`. Linking a test or binary additionally needs the
unversioned linker names normally installed by `libxcb1-dev`,
`libxkbcommon-dev`, and `libxkbcommon-x11-dev`. The amd-server validation used
temporary symlinks under the ignored `target/native-libs` directory and set
`LIBRARY_PATH` to that directory; no system packages or root files changed.
## Observed local cost
On amd-server, the first successful check required roughly 90 seconds after
installing/fetching, with some Cargo-cache contention from concurrent work. The
lockfile contains 691 packages. After check, test, Clippy, and a debug build,
the isolated target directory was 4.4 GiB and the debug binary was 535 MiB. The
minimal Rust 1.97.1 toolchain occupies 624 MiB. These are development costs, not
optimized release measurements.
@@ -0,0 +1,4 @@
[toolchain]
channel = "1.97.1"
profile = "minimal"
components = ["clippy", "rustfmt"]
+486
View File
@@ -0,0 +1,486 @@
use std::ops::Range;
use gpui::{
App, Bounds, ClipboardItem, Context, Element, ElementId, ElementInputHandler, Entity,
EntityInputHandler, FocusHandle, GlobalElementId, LayoutId, Pixels, Point, Role, Style,
UTF16Selection, Window, WindowBounds, WindowOptions, div, prelude::*, px, relative, rgb, size,
text,
};
use gpui_platform::application;
const WORKSPACE_LABEL: &str = "Lumbridge workspace";
const PANE_LABEL: &str = "Local terminal pane";
const PANE_DESCRIPTION: &str = "Needs input: choose whether to run the proposed command";
const PANE_ACCESSIBILITY_ID: &str = "lumbridge.pane.local-terminal";
struct AccessibilityProbe {
pane_focus: FocusHandle,
input: Entity<ImeTextReceiver>,
}
impl AccessibilityProbe {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let pane_focus = cx.focus_handle().tab_stop(true);
window.focus(&pane_focus, cx);
Self {
pane_focus,
input: cx.new(|_| ImeTextReceiver::default()),
}
}
}
#[derive(Clone, Copy)]
enum BoundaryBias {
Floor,
Ceil,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct TextBuffer {
text: String,
selection: Range<usize>,
marked: Option<Range<usize>>,
}
impl TextBuffer {
fn utf16_to_utf8(text: &str, offset: usize, bias: BoundaryBias) -> usize {
let mut utf16_offset = 0;
for (utf8_offset, character) in text.char_indices() {
if utf16_offset == offset {
return utf8_offset;
}
let next_utf16 = utf16_offset + character.len_utf16();
if offset < next_utf16 {
return match bias {
BoundaryBias::Floor => utf8_offset,
BoundaryBias::Ceil => utf8_offset + character.len_utf8(),
};
}
utf16_offset = next_utf16;
}
text.len()
}
fn utf8_to_utf16(text: &str, offset: usize) -> usize {
text[..offset].encode_utf16().count()
}
fn range_from_utf16(text: &str, range: &Range<usize>) -> Range<usize> {
if range.is_empty() {
let offset = Self::utf16_to_utf8(text, range.start, BoundaryBias::Floor);
return offset..offset;
}
Self::utf16_to_utf8(text, range.start, BoundaryBias::Floor)
..Self::utf16_to_utf8(text, range.end, BoundaryBias::Ceil)
}
fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
Self::utf8_to_utf16(&self.text, range.start)..Self::utf8_to_utf16(&self.text, range.end)
}
fn selection_utf16(&self) -> Range<usize> {
self.range_to_utf16(&self.selection)
}
fn marked_utf16(&self) -> Option<Range<usize>> {
self.marked.as_ref().map(|range| self.range_to_utf16(range))
}
fn set_selection_utf16(&mut self, range: Range<usize>) {
self.selection = Self::range_from_utf16(&self.text, &range);
}
fn replacement_range(&self, range_utf16: Option<&Range<usize>>) -> Range<usize> {
range_utf16
.map(|range| Self::range_from_utf16(&self.text, range))
.or_else(|| self.marked.clone())
.unwrap_or_else(|| self.selection.clone())
}
fn replace_text_in_range(&mut self, range_utf16: Option<Range<usize>>, new_text: &str) {
let range = self.replacement_range(range_utf16.as_ref());
let caret = range.start + new_text.len();
self.text.replace_range(range, new_text);
self.selection = caret..caret;
self.marked = None;
}
fn replace_and_mark_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
new_selection_utf16: Option<Range<usize>>,
) {
let range = self.replacement_range(range_utf16.as_ref());
let insertion_start = range.start;
self.text.replace_range(range, new_text);
let inserted = insertion_start..insertion_start + new_text.len();
self.marked = (!new_text.is_empty()).then(|| inserted.clone());
self.selection = new_selection_utf16
.map(|selection| Self::range_from_utf16(new_text, &selection))
.map(|selection| insertion_start + selection.start..insertion_start + selection.end)
.unwrap_or(inserted.end..inserted.end);
}
}
#[derive(Debug, Default)]
struct ImeTextReceiver {
buffer: TextBuffer,
}
impl EntityInputHandler for ImeTextReceiver {
fn text_for_range(
&mut self,
range_utf16: Range<usize>,
adjusted_range: &mut Option<Range<usize>>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<String> {
let range = TextBuffer::range_from_utf16(&self.buffer.text, &range_utf16);
adjusted_range.replace(self.buffer.range_to_utf16(&range));
Some(self.buffer.text[range].to_owned())
}
fn selected_text_range(
&mut self,
_ignore_disabled_input: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<UTF16Selection> {
Some(UTF16Selection {
range: self.buffer.selection_utf16(),
reversed: false,
})
}
fn marked_text_range(
&self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Range<usize>> {
self.buffer.marked_utf16()
}
fn unmark_text(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.buffer.marked = None;
cx.notify();
}
fn paste(&mut self, item: ClipboardItem, window: &mut Window, cx: &mut Context<Self>) {
if let Some(text) = item.text() {
self.replace_text_in_range(None, &text, window, cx);
}
}
fn replace_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
text: &str,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.buffer.replace_text_in_range(range_utf16, text);
cx.notify();
}
fn replace_and_mark_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
new_selected_range_utf16: Option<Range<usize>>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.buffer
.replace_and_mark_text_in_range(range_utf16, new_text, new_selected_range_utf16);
cx.notify();
}
fn bounds_for_range(
&mut self,
_range_utf16: Range<usize>,
element_bounds: Bounds<Pixels>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
Some(element_bounds)
}
fn character_index_for_point(
&mut self,
_point: Point<Pixels>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<usize> {
Some(self.buffer.selection_utf16().end)
}
fn set_selected_text_range(
&mut self,
range_utf16: Range<usize>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.buffer.set_selection_utf16(range_utf16);
cx.notify();
}
fn text_length_utf16(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<usize> {
Some(self.buffer.text.encode_utf16().count())
}
}
struct InputCaptureElement {
input: Entity<ImeTextReceiver>,
focus: FocusHandle,
}
impl IntoElement for InputCaptureElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for InputCaptureElement {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
style.size.width = relative(1.).into();
style.size.height = px(32.).into();
(window.request_layout(style, [], cx), ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
window.handle_input(
&self.focus,
ElementInputHandler::new(bounds, self.input.clone()),
cx,
);
}
}
impl Render for AccessibilityProbe {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let input_text = self.input.read(cx).buffer.text.clone();
div()
.id("lumbridge-application")
.role(Role::Application)
.aria_label("Lumbridge accessibility probe")
.size_full()
.p_4()
.bg(rgb(0x0c0e13))
.text_color(rgb(0xd9e2f2))
.child(
div()
.id("workspace")
.accessibility_id("lumbridge.workspace.primary")
.role(Role::Region)
.aria_label(WORKSPACE_LABEL)
.size_full()
.p_3()
.border_1()
.border_color(rgb(0x293244))
.rounded_md()
.child(
div()
.id("pane-local-terminal")
.accessibility_id(PANE_ACCESSIBILITY_ID)
.role(Role::Pane)
.aria_label(PANE_LABEL)
.aria_description(PANE_DESCRIPTION)
.aria_selected(true)
.focusable()
.track_focus(&self.pane_focus)
.size_full()
.p_3()
.bg(rgb(0x121722))
.rounded_md()
.child(
div()
.id("terminal-content")
.role(Role::Terminal)
.aria_label("Terminal output")
.flex()
.flex_col()
.gap_2()
.child(text!("codex is waiting for approval"))
.child(
div()
.id("ime-input-status")
.role(Role::Status)
.aria_label("Composed input")
.child(if input_text.is_empty() {
"Type composed Unicode here…".to_owned()
} else {
input_text
}),
)
.child(InputCaptureElement {
input: self.input.clone(),
focus: self.pane_focus.clone(),
}),
),
),
)
}
}
fn main() {
application().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(760.0), px(420.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(gpui::TitlebarOptions {
title: Some("Lumbridge · GPUI accessibility probe".into()),
..Default::default()
}),
..Default::default()
},
|window, cx| cx.new(|cx| AccessibilityProbe::new(window, cx)),
)
.expect("GPUI accessibility probe window should open");
cx.activate(true);
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_and_focused_pane_map_to_an_accesskit_tree() {
use gpui::accesskit::{Node, NodeId, Tree, TreeId, TreeUpdate};
let workspace_id = NodeId(1);
let pane_id = NodeId(2);
let mut workspace = Node::new(Role::Region);
workspace.set_label(WORKSPACE_LABEL.to_owned());
workspace.set_children(vec![pane_id]);
let mut pane = Node::new(Role::Pane);
pane.set_author_id(PANE_ACCESSIBILITY_ID.to_owned());
pane.set_label(PANE_LABEL.to_owned());
pane.set_description(PANE_DESCRIPTION.to_owned());
pane.set_selected(true);
let update = TreeUpdate {
nodes: vec![(workspace_id, workspace), (pane_id, pane)],
tree: Some(Tree::new(workspace_id)),
tree_id: TreeId::ROOT,
focus: pane_id,
};
let pane = &update.nodes[1].1;
assert_eq!(
update.tree.as_ref().map(|tree| tree.root),
Some(workspace_id)
);
assert_eq!(update.focus, pane_id);
assert_eq!(update.nodes[0].1.role(), Role::Region);
assert_eq!(update.nodes[0].1.label(), Some(WORKSPACE_LABEL));
assert_eq!(pane.role(), Role::Pane);
assert_eq!(pane.author_id(), Some(PANE_ACCESSIBILITY_ID));
assert_eq!(pane.label(), Some(PANE_LABEL));
assert_eq!(pane.description(), Some(PANE_DESCRIPTION));
assert_eq!(pane.is_selected(), Some(true));
}
#[test]
fn replace_selected_composed_accent_without_splitting_utf8() {
let mut buffer = TextBuffer {
text: "Cafe\u{301}".to_owned(),
selection: 0..0,
marked: None,
};
buffer.set_selection_utf16(3..5);
buffer.replace_text_in_range(None, "é");
assert_eq!(buffer.text, "Café");
assert_eq!(buffer.selection_utf16(), 4..4);
assert!(buffer.text.is_char_boundary(buffer.selection.start));
}
#[test]
fn composed_ime_and_multi_codepoint_grapheme_preserve_selection() {
let grapheme = "👩🏽‍💻";
let grapheme_utf16_len = grapheme.encode_utf16().count();
let mut buffer = TextBuffer::default();
buffer.replace_and_mark_text_in_range(Some(0..0), "e\u{301}", Some(2..2));
assert_eq!(buffer.text, "e\u{301}");
assert_eq!(buffer.marked_utf16(), Some(0..2));
assert_eq!(buffer.selection_utf16(), 2..2);
buffer.replace_text_in_range(None, "é");
assert_eq!(buffer.text, "é");
assert_eq!(buffer.marked_utf16(), None);
assert_eq!(buffer.selection_utf16(), 1..1);
buffer.set_selection_utf16(0..1);
buffer.replace_text_in_range(None, grapheme);
assert_eq!(buffer.text, grapheme);
assert_eq!(
buffer.selection_utf16(),
grapheme_utf16_len..grapheme_utf16_len
);
assert!(buffer.text.is_char_boundary(buffer.selection.start));
buffer.set_selection_utf16(0..grapheme_utf16_len);
buffer.replace_text_in_range(None, "done");
assert_eq!(buffer.text, "done");
assert_eq!(buffer.selection_utf16(), 4..4);
}
}
+627 -86
View File
@@ -1,85 +1,445 @@
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use gpui::{ use gpui::{
App, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, App, Application, Bounds, Context, FocusHandle, KeyBinding, KeyDownEvent, Window, WindowBounds,
rgb, size, WindowOptions, actions, div, prelude::*, px, rgb, size,
}; };
use lumbridge_spike_model::{ use lumbridge_spike_model::{
FOOTER_CENTER, FOOTER_LEFT, FOOTER_RIGHT, PANES, PaneFixture, WORKSPACES, ActionOutcome, FOOTER_RIGHT, FocusDirection, PaneId, PaneState, ShellAction, ShellModel,
SurfaceKind, WORKSPACES,
}; };
const BG: u32 = 0x0c0e13; const BG: u32 = 0x090c12;
const PANEL: u32 = 0x121722; const PANEL: u32 = 0x101620;
const PANEL_ALT: u32 = 0x171d29; const PANEL_ALT: u32 = 0x151d29;
const BORDER: u32 = 0x293244; const PANEL_ACTIVE: u32 = 0x182334;
const TEXT: u32 = 0xd9e2f2; const BORDER: u32 = 0x263246;
const MUTED: u32 = 0x7f8ba3; const BORDER_QUIET: u32 = 0x1c2636;
const ACCENT: u32 = 0x77bdfb; 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;
struct LumbridgeShell; actions!(
lumbridge,
[
FocusLeft,
FocusRight,
FocusUp,
FocusDown,
OpenPalette,
ClosePalette,
SelectPane1,
SelectPane2,
SelectPane3,
SelectPane4,
SelectPane5,
SelectPane6,
]
);
fn pane_card(pane: &PaneFixture) -> impl IntoElement { struct LumbridgeShell {
div() model: ShellModel,
.flex() timing: RenderTiming,
.flex_col() root_focus: FocusHandle,
.min_w_0() pane_focus: [FocusHandle; 6],
.min_h_0() }
.overflow_hidden()
.bg(rgb(PANEL)) #[derive(Default)]
.border_1() struct RenderTiming {
.border_color(rgb(BORDER)) pending_dispatch: Option<Instant>,
.rounded_md() dispatch_to_element_micros: VecDeque<u128>,
.child( }
div()
.flex() impl RenderTiming {
.items_center() fn mark_dispatch(&mut self) {
.justify_between() self.pending_dispatch = Some(Instant::now());
.px_3() }
.h(px(34.0))
.bg(rgb(PANEL_ALT)) fn observe_element_build(&mut self) {
.border_b_1() let Some(started) = self.pending_dispatch.take() else {
.border_color(rgb(BORDER)) return;
.text_sm() };
.text_color(rgb(TEXT)) if self.dispatch_to_element_micros.len() == TIMING_SAMPLE_LIMIT {
.child(pane.title) self.dispatch_to_element_micros.pop_front();
.child(div().text_xs().text_color(rgb(ACCENT)).child(pane.badge)), }
) self.dispatch_to_element_micros
.child( .push_back(started.elapsed().as_micros());
div() }
.px_3()
.py_2() fn summary(&self) -> String {
.text_xs() if self.dispatch_to_element_micros.is_empty() {
.text_color(rgb(MUTED)) return "dispatch→element collecting…".to_owned();
.child(pane.target), }
) let mut samples = self
.child( .dispatch_to_element_micros
div() .iter()
.flex() .copied()
.flex_col() .collect::<Vec<_>>();
.gap_1() samples.sort_unstable();
.px_3() let percentile = |percent: usize| {
.pb_3() let index = ((samples.len() - 1) * percent) / 100;
.text_sm() samples[index]
.text_color(rgb(TEXT)) };
.children(pane.lines.into_iter()), 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();
Self {
model: ShellModel::default(),
timing: RenderTiming::default(),
root_focus,
pane_focus,
}
}
fn dispatch(&mut self, action: ShellAction) -> ActionOutcome {
self.timing.mark_dispatch();
self.model.dispatch(action)
}
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 on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
if !self.model.command_palette().is_open() {
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;
}
_ => {
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_card(
pane: &PaneState,
selected: bool,
focus: FocusHandle,
cx: &mut Context<Self>,
) -> gpui::AnyElement {
let id = pane.id();
let needs_input = pane.needs_input();
let label = match pane.kind() {
SurfaceKind::Terminal => pane.status().label(),
_ => pane.fixture().badge,
};
let state_color = if needs_input {
ATTENTION
} else if matches!(pane.kind(), SurfaceKind::Terminal) {
SUCCESS
} 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 }))
.border_1()
.border_color(rgb(if selected || needs_input {
state_color
} else {
BORDER
}))
.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))
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
div()
.text_xs()
.text_color(rgb(MUTED))
.child(format!("{}", id.index() + 1)),
)
.child(pane.fixture().title),
)
.child(div().text_xs().text_color(rgb(state_color)).child(label)),
)
.child(
div()
.flex()
.items_center()
.justify_between()
.px_3()
.py_2()
.text_xs()
.text_color(rgb(MUTED))
.child(pane.fixture().target)
.when(selected, |view| view.child("FOCUSED")),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.px_3()
.pb_3()
.text_sm()
.text_color(rgb(TEXT))
.children(pane.lines()[line_start..].iter().cloned()),
)
.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 { impl Render for LumbridgeShell {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let attention = self.model.pane(PaneId::ClaudeUi);
let sidebar = div() let sidebar = div()
.flex() .flex()
.flex_col() .flex_col()
.w(px(220.0)) .w(px(248.0))
.flex_none() .flex_none()
.bg(rgb(PANEL)) .bg(rgb(PANEL))
.border_r_1() .border_r_1()
.border_color(rgb(BORDER)) .border_color(rgb(BORDER_QUIET))
.child( .child(
div() div()
.px_4() .px_4()
.py_3() .pt_4()
.text_sm() .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)) .text_color(rgb(MUTED))
.child("WORKSPACES"), .child("WORKTREES"),
) )
.children(WORKSPACES.into_iter().enumerate().map(|(index, name)| { .children(WORKSPACES.into_iter().enumerate().map(|(index, name)| {
div() div()
@@ -87,30 +447,130 @@ impl Render for LumbridgeShell {
.mb_1() .mb_1()
.px_3() .px_3()
.py_2() .py_2()
.rounded_md() .rounded(px(5.0))
.when(index == 0, |view| { .when(index == 0, |view| {
view.bg(rgb(PANEL_ALT)).text_color(rgb(TEXT)) view.bg(rgb(PANEL_ALT)).text_color(rgb(TEXT))
}) })
.when(index != 0, |view| view.text_color(rgb(MUTED))) .when(index != 0, |view| view.text_color(rgb(MUTED)))
.child(name) .child(name)
.when(index == 0, |view| {
view.child(
div()
.mt_1()
.text_xs()
.text_color(rgb(MUTED))
.child("main · metal"),
)
})
})) }))
.child( .child(
div() div()
.mt_4() .mt_3()
.px_4() .px_4()
.py_2()
.text_xs() .text_xs()
.text_color(rgb(MUTED)) .text_color(rgb(MUTED))
.child("HOSTS") .child("RUNTIMES"),
)
.child(
div()
.px_4()
.py_1()
.text_sm()
.text_color(rgb(SUCCESS))
.child("metal · connected"),
)
.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( .child(
div() div()
.mt_2() .mt_1()
.text_color(rgb(ACCENT)) .text_color(rgb(SUCCESS))
.child("● metal · connected"), .child("connected · signed identity"),
) ),
.child(div().mt_2().child("● amd-server · connected"))
.child(div().mt_2().child("○ spark-1 · sleeping")),
); );
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("6 surfaces · 3 remote · 1 waiting"),
);
let pane_cards = self
.model
.panes()
.iter()
.map(|pane| {
Self::pane_card(
pane,
self.model.selected_pane() == pane.id(),
self.pane_focus[pane.id().index()].clone(),
cx,
)
})
.collect::<Vec<_>>();
let grid = div() let grid = div()
.grid() .grid()
.grid_cols(3) .grid_cols(3)
@@ -118,9 +578,48 @@ impl Render for LumbridgeShell {
.gap_2() .gap_2()
.p_2() .p_2()
.size_full() .size_full()
.children(PANES.iter().map(pane_card)); .children(pane_cards);
div() let counters = self.model.counters();
let footer_left = format!(
"rev {} · {} focus moves · {} surface updates · {} lines",
self.model.revision(),
counters.focus_moves,
counters.surface_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(|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()
.flex_col() .flex_col()
.size_full() .size_full()
@@ -130,63 +629,105 @@ impl Render for LumbridgeShell {
div() div()
.flex() .flex()
.items_center() .items_center()
.justify_between() .h(px(48.0))
.h(px(46.0))
.flex_none() .flex_none()
.px_4() .px_4()
.bg(rgb(PANEL_ALT)) .bg(rgb(PANEL_ALT))
.border_b_1() .border_b_1()
.border_color(rgb(BORDER)) .border_color(rgb(BORDER_QUIET))
.child(div().text_lg().child("Lumbridge")) .child(div().text_lg().child("Lumbridge"))
.child( .child(
div() div()
.ml_3()
.text_sm() .text_sm()
.text_color(rgb(MUTED)) .text_color(rgb(MUTED))
.child("UI spike · GPUI"), .child("Lumbridge Code / main"),
)
.child(div().flex_1())
.child(
div()
.mr_4()
.text_xs()
.text_color(rgb(SUCCESS))
.child("metal · runtime online"),
) )
.child( .child(
div() div()
.text_sm() .text_sm()
.text_color(rgb(ACCENT)) .text_color(rgb(ACCENT))
.child("K Command Palette"), .child("Ctrl/⌘ K · Commands"),
), ),
) )
.child(div().flex().flex_1().min_h_0().child(sidebar).child(grid)) .child(
div().flex().flex_1().min_h_0().child(sidebar).child(
div()
.flex()
.flex_col()
.flex_1()
.min_w_0()
.child(tabs)
.child(grid),
),
)
.child( .child(
div() div()
.flex() .flex()
.items_center() .items_center()
.justify_between() .justify_between()
.h(px(30.0)) .h(px(32.0))
.flex_none() .flex_none()
.px_3() .px_3()
.bg(rgb(PANEL_ALT)) .bg(rgb(PANEL_ALT))
.border_t_1() .border_t_1()
.border_color(rgb(BORDER)) .border_color(rgb(BORDER_QUIET))
.text_xs() .text_xs()
.text_color(rgb(MUTED)) .text_color(rgb(MUTED))
.child(FOOTER_LEFT) .child(footer_left)
.child(FOOTER_CENTER) .child(timing)
.child(FOOTER_RIGHT), .child(FOOTER_RIGHT),
) )
.when(self.model.command_palette().is_open(), |view| {
view.child(self.command_palette())
});
self.timing.observe_element_build();
root
} }
} }
fn main() { fn main() {
Application::new().run(|cx: &mut App| { Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(1280.0), px(800.0)), cx); cx.bind_keys([
KeyBinding::new("left", FocusLeft, Some("LumbridgeShell")),
KeyBinding::new("right", FocusRight, Some("LumbridgeShell")),
KeyBinding::new("up", FocusUp, Some("LumbridgeShell")),
KeyBinding::new("down", FocusDown, Some("LumbridgeShell")),
KeyBinding::new("h", FocusLeft, Some("LumbridgeShell")),
KeyBinding::new("l", FocusRight, Some("LumbridgeShell")),
KeyBinding::new("k", FocusUp, Some("LumbridgeShell")),
KeyBinding::new("j", FocusDown, Some("LumbridgeShell")),
KeyBinding::new("secondary-k", OpenPalette, Some("LumbridgeShell")),
KeyBinding::new("escape", ClosePalette, Some("LumbridgeShell")),
KeyBinding::new("1", SelectPane1, Some("LumbridgeShell")),
KeyBinding::new("2", SelectPane2, Some("LumbridgeShell")),
KeyBinding::new("3", SelectPane3, Some("LumbridgeShell")),
KeyBinding::new("4", SelectPane4, Some("LumbridgeShell")),
KeyBinding::new("5", SelectPane5, Some("LumbridgeShell")),
KeyBinding::new("6", SelectPane6, Some("LumbridgeShell")),
]);
let bounds = Bounds::centered(None, size(px(1500.0), px(960.0)), cx);
cx.open_window( cx.open_window(
WindowOptions { WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)), window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(gpui::TitlebarOptions { titlebar: Some(gpui::TitlebarOptions {
title: Some("Lumbridge · GPUI spike".into()), title: Some("Lumbridge · GPUI workspace".into()),
..Default::default() ..Default::default()
}), }),
..Default::default() ..Default::default()
}, },
|_, cx| cx.new(|_| LumbridgeShell), |window, cx| cx.new(|cx| LumbridgeShell::new(window, cx)),
) )
.expect("GPUI spike window should open"); .expect("GPUI workspace window should open");
cx.activate(true); cx.activate(true);
}); });
} }
+618 -5
View File
@@ -1,3 +1,70 @@
//! Deterministic state and fixtures shared by the GPUI and Floem spikes.
//!
//! UI candidates translate framework input into [`ShellAction`] and render the
//! resulting [`ShellModel`]. This makes their interaction traces comparable.
use std::fmt;
pub const GRID_ROWS: usize = 2;
pub const GRID_COLUMNS: usize = 3;
pub const DEFAULT_TERMINAL_LINE_LIMIT: usize = 64;
/// Stable identity used in traces and accessibility identifiers.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[repr(u8)]
pub enum PaneId {
CodexRuntime = 0,
ClaudeUi = 1,
PiDocs = 2,
Architecture = 3,
AcpPreview = 4,
RuntimeReview = 5,
}
impl PaneId {
pub const ALL: [Self; GRID_ROWS * GRID_COLUMNS] = [
Self::CodexRuntime,
Self::ClaudeUi,
Self::PiDocs,
Self::Architecture,
Self::AcpPreview,
Self::RuntimeReview,
];
#[must_use]
pub const fn index(self) -> usize {
self as usize
}
#[must_use]
pub const fn stable_name(self) -> &'static str {
match self {
Self::CodexRuntime => "codex-runtime",
Self::ClaudeUi => "claude-ui",
Self::PiDocs => "pi-docs",
Self::Architecture => "architecture",
Self::AcpPreview => "acp-preview",
Self::RuntimeReview => "runtime-review",
}
}
#[must_use]
pub const fn row(self) -> usize {
self.index() / GRID_COLUMNS
}
#[must_use]
pub const fn column(self) -> usize {
self.index() % GRID_COLUMNS
}
}
impl fmt::Display for PaneId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.stable_name())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SurfaceKind { pub enum SurfaceKind {
Terminal, Terminal,
@@ -6,21 +73,45 @@ pub enum SurfaceKind {
Review, Review,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PaneStatus {
Working,
NeedsInput,
Streaming,
Ready,
}
impl PaneStatus {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Working => "WORKING",
Self::NeedsInput => "NEEDS INPUT",
Self::Streaming => "STREAMING",
Self::Ready => "READY",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PaneFixture { pub struct PaneFixture {
pub id: PaneId,
pub title: &'static str, pub title: &'static str,
pub badge: &'static str, pub badge: &'static str,
pub target: &'static str, pub target: &'static str,
pub kind: SurfaceKind, pub kind: SurfaceKind,
pub status: PaneStatus,
pub lines: [&'static str; 4], pub lines: [&'static str; 4],
} }
pub const PANES: [PaneFixture; 6] = [ pub const PANES: [PaneFixture; 6] = [
PaneFixture { PaneFixture {
id: PaneId::CodexRuntime,
title: "Codex · runtime", title: "Codex · runtime",
badge: "WORKING", badge: "WORKING",
target: "metal · Tailscale SSH", target: "metal · Tailscale SSH",
kind: SurfaceKind::Terminal, kind: SurfaceKind::Terminal,
status: PaneStatus::Working,
lines: [ lines: [
"$ cargo nextest run -p lumbridge-runtime", "$ cargo nextest run -p lumbridge-runtime",
"PASS remote::reconnect_replays_output", "PASS remote::reconnect_replays_output",
@@ -29,10 +120,12 @@ pub const PANES: [PaneFixture; 6] = [
], ],
}, },
PaneFixture { PaneFixture {
id: PaneId::ClaudeUi,
title: "Claude Code · UI", title: "Claude Code · UI",
badge: "NEEDS INPUT", badge: "NEEDS INPUT",
target: "MacBook Air · local", target: "MacBook Air · local",
kind: SurfaceKind::Terminal, kind: SurfaceKind::Terminal,
status: PaneStatus::NeedsInput,
lines: [ lines: [
"$ bacon clippy", "$ bacon clippy",
"finished in 0.42s", "finished in 0.42s",
@@ -41,10 +134,12 @@ pub const PANES: [PaneFixture; 6] = [
], ],
}, },
PaneFixture { PaneFixture {
id: PaneId::PiDocs,
title: "Pi · docs", title: "Pi · docs",
badge: "STREAMING", badge: "STREAMING",
target: "amd-server · OpenSSH", target: "amd-server · OpenSSH",
kind: SurfaceKind::Terminal, kind: SurfaceKind::Terminal,
status: PaneStatus::Streaming,
lines: [ lines: [
"$ cargo watch --why", "$ cargo watch --why",
"ACP session resumed at event 1842", "ACP session resumed at event 1842",
@@ -53,10 +148,12 @@ pub const PANES: [PaneFixture; 6] = [
], ],
}, },
PaneFixture { PaneFixture {
id: PaneId::Architecture,
title: "Architecture.md", title: "Architecture.md",
badge: "MARKDOWN", badge: "MARKDOWN",
target: "lumbridge-code · worktree", target: "lumbridge-code · worktree",
kind: SurfaceKind::Markdown, kind: SurfaceKind::Markdown,
status: PaneStatus::Ready,
lines: [ lines: [
"## Remote session path", "## Remote session path",
"The destination runtime owns the PTY.", "The destination runtime owns the PTY.",
@@ -65,10 +162,12 @@ pub const PANES: [PaneFixture; 6] = [
], ],
}, },
PaneFixture { PaneFixture {
id: PaneId::AcpPreview,
title: "Preview · ACP docs", title: "Preview · ACP docs",
badge: "BROWSER", badge: "BROWSER",
target: "isolated system web engine", target: "isolated system web engine",
kind: SurfaceKind::Browser, kind: SurfaceKind::Browser,
status: PaneStatus::Ready,
lines: [ lines: [
"https://agentclientprotocol.com", "https://agentclientprotocol.com",
"Content process: sandboxed", "Content process: sandboxed",
@@ -77,10 +176,12 @@ pub const PANES: [PaneFixture; 6] = [
], ],
}, },
PaneFixture { PaneFixture {
id: PaneId::RuntimeReview,
title: "Changes · lumbridge-runtime", title: "Changes · lumbridge-runtime",
badge: "REVIEW", badge: "REVIEW",
target: "metal · worktree remote-runtime", target: "metal · worktree remote-runtime",
kind: SurfaceKind::Review, kind: SurfaceKind::Review,
status: PaneStatus::Ready,
lines: [ lines: [
"+ 184 remote transport", "+ 184 remote transport",
"+ 96 SQLite migrations", "+ 96 SQLite migrations",
@@ -97,26 +198,538 @@ pub const WORKSPACES: [&str; 5] = [
"ACP adapters", "ACP adapters",
"Usage telemetry", "Usage telemetry",
]; ];
pub const FOOTER_LEFT: &str = "6 panes · 3 remote · 1 needs input"; pub const FOOTER_LEFT: &str = "6 panes · 3 remote · 1 needs input";
pub const FOOTER_CENTER: &str = "Codex · ChatGPT subscription · 62% window remaining"; pub const FOOTER_CENTER: &str = "Codex · ChatGPT subscription · 62% window remaining";
pub const FOOTER_RIGHT: &str = "burn 8.4%/hr · resets in 2h 14m"; pub const FOOTER_RIGHT: &str = "burn 8.4%/hr · resets in 2h 14m";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PaneState {
fixture: PaneFixture,
status: PaneStatus,
status_before_input: Option<PaneStatus>,
lines: Vec<String>,
synthetic_line_sequence: u64,
}
impl PaneState {
#[must_use]
pub const fn id(&self) -> PaneId {
self.fixture.id
}
#[must_use]
pub const fn fixture(&self) -> &PaneFixture {
&self.fixture
}
#[must_use]
pub const fn kind(&self) -> SurfaceKind {
self.fixture.kind
}
#[must_use]
pub const fn status(&self) -> PaneStatus {
self.status
}
#[must_use]
pub const fn needs_input(&self) -> bool {
matches!(self.status, PaneStatus::NeedsInput)
}
#[must_use]
pub fn lines(&self) -> &[String] {
&self.lines
}
#[must_use]
pub const fn synthetic_line_sequence(&self) -> u64 {
self.synthetic_line_sequence
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct CommandPaletteState {
open: bool,
query: String,
}
impl CommandPaletteState {
#[must_use]
pub const fn is_open(&self) -> bool {
self.open
}
#[must_use]
pub fn query(&self) -> &str {
&self.query
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FocusDirection {
Left,
Right,
Up,
Down,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ShellAction {
MoveFocus(FocusDirection),
SelectPane(PaneId),
SetNeedsInput { pane: PaneId, needs_input: bool },
OpenCommandPalette,
CloseCommandPalette,
SetCommandPaletteQuery(String),
SyntheticStreamTick,
}
/// Counters prove both candidates replayed the same workload. Framework
/// adapters may use the event sequence and revision for explicitly labelled
/// timing stages; these counters do not imply pixels reached the display.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct MeasurementCounters {
pub events_dispatched: u64,
pub state_changes: u64,
pub focus_moves: u64,
pub blocked_focus_moves: u64,
pub direct_selections: u64,
pub needs_input_changes: u64,
pub palette_changes: u64,
pub synthetic_ticks: u64,
/// Total surfaces invalidated by the deterministic six-surface workload.
pub surface_updates: u64,
pub terminal_lines_appended: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ActionOutcome {
pub event_sequence: u64,
pub revision: u64,
pub changed: bool,
pub selected_pane: PaneId,
pub terminal_lines_appended: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct InvalidTerminalLineLimit;
impl fmt::Display for InvalidTerminalLineLimit {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("terminal line limit must be greater than zero")
}
}
impl std::error::Error for InvalidTerminalLineLimit {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ShellModel {
panes: [PaneState; GRID_ROWS * GRID_COLUMNS],
selected_pane: PaneId,
command_palette: CommandPaletteState,
terminal_line_limit: usize,
synthetic_tick: u64,
revision: u64,
counters: MeasurementCounters,
}
impl Default for ShellModel {
fn default() -> Self {
Self::with_terminal_line_limit(DEFAULT_TERMINAL_LINE_LIMIT)
.expect("the default terminal line limit is non-zero")
}
}
impl ShellModel {
pub fn with_terminal_line_limit(limit: usize) -> Result<Self, InvalidTerminalLineLimit> {
if limit == 0 {
return Err(InvalidTerminalLineLimit);
}
let panes = PANES.map(|fixture| {
let mut lines = fixture.lines.map(String::from).to_vec();
if fixture.kind == SurfaceKind::Terminal && lines.len() > limit {
lines.drain(..lines.len() - limit);
}
PaneState {
fixture,
status: fixture.status,
status_before_input: (fixture.status == PaneStatus::NeedsInput)
.then_some(PaneStatus::Working),
lines,
synthetic_line_sequence: 0,
}
});
Ok(Self {
panes,
selected_pane: PaneId::CodexRuntime,
command_palette: CommandPaletteState::default(),
terminal_line_limit: limit,
synthetic_tick: 0,
revision: 0,
counters: MeasurementCounters::default(),
})
}
#[must_use]
pub fn panes(&self) -> &[PaneState; GRID_ROWS * GRID_COLUMNS] {
&self.panes
}
#[must_use]
pub fn pane(&self, id: PaneId) -> &PaneState {
&self.panes[id.index()]
}
#[must_use]
pub const fn selected_pane(&self) -> PaneId {
self.selected_pane
}
#[must_use]
pub const fn command_palette(&self) -> &CommandPaletteState {
&self.command_palette
}
#[must_use]
pub const fn terminal_line_limit(&self) -> usize {
self.terminal_line_limit
}
#[must_use]
pub const fn synthetic_tick(&self) -> u64 {
self.synthetic_tick
}
#[must_use]
pub const fn revision(&self) -> u64 {
self.revision
}
#[must_use]
pub const fn counters(&self) -> MeasurementCounters {
self.counters
}
pub fn dispatch(&mut self, action: ShellAction) -> ActionOutcome {
self.counters.events_dispatched += 1;
let mut changed = false;
let mut appended = 0;
match action {
ShellAction::MoveFocus(direction) => {
if let Some(next) = focus_neighbor(self.selected_pane, direction) {
self.selected_pane = next;
self.counters.focus_moves += 1;
changed = true;
} else {
self.counters.blocked_focus_moves += 1;
}
}
ShellAction::SelectPane(pane) => {
if self.selected_pane != pane {
self.selected_pane = pane;
self.counters.direct_selections += 1;
changed = true;
}
}
ShellAction::SetNeedsInput { pane, needs_input } => {
let pane = &mut self.panes[pane.index()];
if needs_input && !pane.needs_input() {
pane.status_before_input = Some(pane.status);
pane.status = PaneStatus::NeedsInput;
self.counters.needs_input_changes += 1;
changed = true;
} else if !needs_input && pane.needs_input() {
pane.status = pane.status_before_input.take().unwrap_or(PaneStatus::Ready);
self.counters.needs_input_changes += 1;
changed = true;
}
}
ShellAction::OpenCommandPalette => {
if !self.command_palette.open {
self.command_palette.open = true;
self.command_palette.query.clear();
self.counters.palette_changes += 1;
changed = true;
}
}
ShellAction::CloseCommandPalette => {
if self.command_palette.open {
self.command_palette.open = false;
self.command_palette.query.clear();
self.counters.palette_changes += 1;
changed = true;
}
}
ShellAction::SetCommandPaletteQuery(query) => {
if self.command_palette.open && self.command_palette.query != query {
self.command_palette.query = query;
self.counters.palette_changes += 1;
changed = true;
}
}
ShellAction::SyntheticStreamTick => {
self.synthetic_tick += 1;
self.counters.synthetic_ticks += 1;
for pane in &mut self.panes {
pane.synthetic_line_sequence += 1;
if pane.kind() != SurfaceKind::Terminal {
continue;
}
pane.lines.push(format!(
"[tick {:06}] {} · synthetic line {:06}",
self.synthetic_tick,
pane.id(),
pane.synthetic_line_sequence
));
if pane.lines.len() > self.terminal_line_limit {
pane.lines.remove(0);
}
appended += 1;
}
self.counters.surface_updates += self.panes.len() as u64;
self.counters.terminal_lines_appended += appended as u64;
changed = true;
}
}
if changed {
self.revision += 1;
self.counters.state_changes += 1;
}
ActionOutcome {
event_sequence: self.counters.events_dispatched,
revision: self.revision,
changed,
selected_pane: self.selected_pane,
terminal_lines_appended: appended,
}
}
}
#[must_use]
pub const fn focus_neighbor(pane: PaneId, direction: FocusDirection) -> Option<PaneId> {
let row = pane.row();
let column = pane.column();
let next = match direction {
FocusDirection::Left if column > 0 => Some(pane.index() - 1),
FocusDirection::Right if column + 1 < GRID_COLUMNS => Some(pane.index() + 1),
FocusDirection::Up if row > 0 => Some(pane.index() - GRID_COLUMNS),
FocusDirection::Down if row + 1 < GRID_ROWS => Some(pane.index() + GRID_COLUMNS),
_ => None,
};
match next {
Some(index) => Some(PaneId::ALL[index]),
None => None,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{PANES, SurfaceKind}; use super::{
FocusDirection, GRID_COLUMNS, GRID_ROWS, PANES, PaneId, PaneStatus, ShellAction,
ShellModel, SurfaceKind, focus_neighbor,
};
#[test] #[test]
fn comparison_fixture_has_six_mixed_surfaces() { fn fixture_has_stable_ids_and_mixed_surfaces() {
assert_eq!(PANES.len(), 6); assert_eq!(PANES.len(), GRID_ROWS * GRID_COLUMNS);
assert_eq!(PANES.map(|pane| pane.id), PaneId::ALL);
assert_eq!(PaneId::CodexRuntime.stable_name(), "codex-runtime");
assert_eq!(PaneId::RuntimeReview.to_string(), "runtime-review");
assert!(PANES.iter().any(|pane| pane.kind == SurfaceKind::Markdown)); assert!(PANES.iter().any(|pane| pane.kind == SurfaceKind::Markdown));
assert!(PANES.iter().any(|pane| pane.kind == SurfaceKind::Browser)); assert!(PANES.iter().any(|pane| pane.kind == SurfaceKind::Browser));
assert_eq!( assert_eq!(
PANES PANES
.iter() .iter()
.filter(|pane| pane.kind == SurfaceKind::Terminal) .filter(|p| p.kind == SurfaceKind::Terminal)
.count(), .count(),
3 3
); );
} }
#[test]
fn initial_state_has_one_explicit_needs_input_pane() {
let model = ShellModel::default();
assert_eq!(model.selected_pane(), PaneId::CodexRuntime);
assert_eq!(model.revision(), 0);
assert!(!model.command_palette().is_open());
assert_eq!(
model
.panes()
.iter()
.filter(|pane| pane.needs_input())
.map(|pane| pane.id())
.collect::<Vec<_>>(),
vec![PaneId::ClaudeUi]
);
}
#[test]
fn neighbors_match_two_by_three_grid_without_wrapping() {
assert_eq!(
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Right),
Some(PaneId::ClaudeUi)
);
assert_eq!(
focus_neighbor(PaneId::ClaudeUi, FocusDirection::Down),
Some(PaneId::AcpPreview)
);
assert_eq!(
focus_neighbor(PaneId::RuntimeReview, FocusDirection::Up),
Some(PaneId::PiDocs)
);
assert_eq!(
focus_neighbor(PaneId::Architecture, FocusDirection::Left),
None
);
assert_eq!(focus_neighbor(PaneId::PiDocs, FocusDirection::Right), None);
assert_eq!(
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Up),
None
);
}
#[test]
fn focus_updates_selection_revision_and_blocked_counter() {
let mut model = ShellModel::default();
let blocked = model.dispatch(ShellAction::MoveFocus(FocusDirection::Left));
assert!(!blocked.changed);
assert_eq!((blocked.event_sequence, blocked.revision), (1, 0));
let moved = model.dispatch(ShellAction::MoveFocus(FocusDirection::Right));
assert!(moved.changed);
assert_eq!(moved.selected_pane, PaneId::ClaudeUi);
assert_eq!(moved.revision, 1);
model.dispatch(ShellAction::MoveFocus(FocusDirection::Down));
assert_eq!(model.selected_pane(), PaneId::AcpPreview);
assert_eq!(model.counters().focus_moves, 2);
assert_eq!(model.counters().blocked_focus_moves, 1);
}
#[test]
fn direct_selection_is_idempotent_and_counted_separately() {
let mut model = ShellModel::default();
assert!(
!model
.dispatch(ShellAction::SelectPane(PaneId::CodexRuntime))
.changed
);
assert!(
model
.dispatch(ShellAction::SelectPane(PaneId::RuntimeReview))
.changed
);
assert_eq!(model.selected_pane(), PaneId::RuntimeReview);
assert_eq!(model.counters().direct_selections, 1);
assert_eq!(model.counters().events_dispatched, 2);
assert_eq!(model.counters().state_changes, 1);
}
#[test]
fn needs_input_restores_previous_status() {
let mut model = ShellModel::default();
model.dispatch(ShellAction::SetNeedsInput {
pane: PaneId::PiDocs,
needs_input: true,
});
assert_eq!(model.pane(PaneId::PiDocs).status(), PaneStatus::NeedsInput);
assert!(
!model
.dispatch(ShellAction::SetNeedsInput {
pane: PaneId::PiDocs,
needs_input: true,
})
.changed
);
model.dispatch(ShellAction::SetNeedsInput {
pane: PaneId::PiDocs,
needs_input: false,
});
assert_eq!(model.pane(PaneId::PiDocs).status(), PaneStatus::Streaming);
model.dispatch(ShellAction::SetNeedsInput {
pane: PaneId::ClaudeUi,
needs_input: false,
});
assert_eq!(model.pane(PaneId::ClaudeUi).status(), PaneStatus::Working);
assert_eq!(model.counters().needs_input_changes, 3);
}
#[test]
fn command_palette_has_explicit_lifecycle() {
let mut model = ShellModel::default();
assert!(
!model
.dispatch(ShellAction::SetCommandPaletteQuery("pane".into()))
.changed
);
model.dispatch(ShellAction::OpenCommandPalette);
model.dispatch(ShellAction::SetCommandPaletteQuery("pane: next".into()));
assert!(model.command_palette().is_open());
assert_eq!(model.command_palette().query(), "pane: next");
model.dispatch(ShellAction::CloseCommandPalette);
assert!(!model.command_palette().is_open());
assert_eq!(model.command_palette().query(), "");
assert_eq!(model.counters().palette_changes, 3);
}
#[test]
fn ticks_append_predictable_lines_to_terminals_only() {
let mut model = ShellModel::default();
let markdown = model.pane(PaneId::Architecture).lines().to_vec();
let outcome = model.dispatch(ShellAction::SyntheticStreamTick);
assert_eq!(outcome.terminal_lines_appended, 3);
assert_eq!(model.synthetic_tick(), 1);
assert_eq!(
model.pane(PaneId::CodexRuntime).lines().last().unwrap(),
"[tick 000001] codex-runtime · synthetic line 000001"
);
assert_eq!(model.pane(PaneId::Architecture).lines(), markdown);
assert!(
model
.panes()
.iter()
.all(|pane| pane.synthetic_line_sequence() == 1)
);
assert_eq!(model.counters().synthetic_ticks, 1);
assert_eq!(model.counters().surface_updates, 6);
assert_eq!(model.counters().terminal_lines_appended, 3);
}
#[test]
fn terminal_scrollback_is_bounded_below_fixture_size() {
let mut model = ShellModel::with_terminal_line_limit(2).unwrap();
assert_eq!(model.pane(PaneId::CodexRuntime).lines().len(), 2);
for _ in 0..10 {
model.dispatch(ShellAction::SyntheticStreamTick);
}
for pane in model
.panes()
.iter()
.filter(|pane| pane.kind() == SurfaceKind::Terminal)
{
assert_eq!(pane.lines().len(), 2);
assert_eq!(pane.synthetic_line_sequence(), 10);
assert!(pane.lines()[0].starts_with("[tick 000009]"));
assert!(pane.lines()[1].starts_with("[tick 000010]"));
}
assert!(ShellModel::with_terminal_line_limit(0).is_err());
}
#[test]
fn identical_action_replays_produce_identical_models_and_counters() {
let actions = [
ShellAction::MoveFocus(FocusDirection::Right),
ShellAction::MoveFocus(FocusDirection::Down),
ShellAction::OpenCommandPalette,
ShellAction::SetCommandPaletteQuery("workspace".into()),
ShellAction::CloseCommandPalette,
ShellAction::SetNeedsInput {
pane: PaneId::CodexRuntime,
needs_input: true,
},
ShellAction::SyntheticStreamTick,
ShellAction::SyntheticStreamTick,
];
let mut gpui = ShellModel::default();
let mut floem = ShellModel::default();
let gpui_outcomes = actions
.iter()
.cloned()
.map(|action| gpui.dispatch(action))
.collect::<Vec<_>>();
let floem_outcomes = actions
.iter()
.cloned()
.map(|action| floem.dispatch(action))
.collect::<Vec<_>>();
assert_eq!(gpui_outcomes, floem_outcomes);
assert_eq!(gpui, floem);
assert_eq!(gpui.counters().events_dispatched, actions.len() as u64);
assert_eq!(gpui.counters().surface_updates, 12);
assert_eq!(gpui.counters().terminal_lines_appended, 6);
}
} }