This commit is contained in:
Generated
+175
-5
@@ -21,6 +21,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -99,6 +105,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
@@ -154,7 +166,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"subtle",
|
||||
"thiserror",
|
||||
"thiserror 2.0.20",
|
||||
"url",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
@@ -169,7 +181,7 @@ dependencies = [
|
||||
"nostr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"thiserror 2.0.20",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -198,6 +210,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
@@ -357,6 +375,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
@@ -369,6 +393,17 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
@@ -661,6 +696,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
@@ -703,7 +744,7 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"buzz-sdk",
|
||||
"nostr",
|
||||
"thiserror",
|
||||
"thiserror 2.0.20",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -711,6 +752,15 @@ dependencies = [
|
||||
name = "lumbridge-core"
|
||||
version = "0.0.1"
|
||||
|
||||
[[package]]
|
||||
name = "lumbridge-pty"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"nix",
|
||||
"portable-pty",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lumbridge-storage"
|
||||
version = "0.0.1"
|
||||
@@ -725,6 +775,18 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "nostr"
|
||||
version = "0.44.8"
|
||||
@@ -820,6 +882,27 @@ dependencies = [
|
||||
"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]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
@@ -915,7 +998,7 @@ version = "0.40.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.1",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"libsqlite3-sys",
|
||||
@@ -1012,6 +1095,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -1034,6 +1128,22 @@ dependencies = [
|
||||
"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]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -1097,13 +1207,33 @@ dependencies = [
|
||||
"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]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
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]]
|
||||
@@ -1277,6 +1407,28 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
@@ -1336,6 +1488,24 @@ dependencies = [
|
||||
"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]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
|
||||
@@ -3,6 +3,7 @@ members = [
|
||||
"apps/lumbridge",
|
||||
"crates/lumbridge-buzz",
|
||||
"crates/lumbridge-core",
|
||||
"crates/lumbridge-pty",
|
||||
"crates/lumbridge-storage",
|
||||
]
|
||||
exclude = ["spikes"]
|
||||
|
||||
@@ -16,10 +16,12 @@ as installable binaries; building from source will remain supported.
|
||||
|
||||
## Status
|
||||
|
||||
This repository is in architecture and product-definition phase. The current
|
||||
binary is deliberately only a scaffold. We are validating the terminal engine,
|
||||
UI stack, process/session model, ACP integration, packaging, and usage-data
|
||||
contracts before committing to a large implementation.
|
||||
This repository is in architecture and vertical-slice phase. The installable
|
||||
binary is still a scaffold, while the isolated native UI spikes now exercise an
|
||||
interactive six-surface workspace and the root workspace contains the first
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -41,13 +41,17 @@ messages should be real from the beginning.
|
||||
- `lumbridge`: installable application entry point.
|
||||
|
||||
The scaffold currently contains `lumbridge-core`, `lumbridge-storage`,
|
||||
`lumbridge-buzz`, and the entry point. Larger runtime crates are added after
|
||||
their architecture spikes pass.
|
||||
`lumbridge-buzz`, `lumbridge-pty`, and the entry point. Larger runtime crates are
|
||||
added after their architecture spikes pass.
|
||||
|
||||
## Terminal path
|
||||
|
||||
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
|
||||
the live screen to prevent large agent transcripts from blocking input.
|
||||
|
||||
@@ -59,15 +63,18 @@ and simultaneous automation plus human input.
|
||||
|
||||
## UI decision gate
|
||||
|
||||
Do not lock the project to a webview or to Zed's private implementation details
|
||||
before a measured spike. Compare:
|
||||
Do not lock the project to a webview or to Zed's application implementation
|
||||
details. The measured spike compares:
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -101,6 +101,19 @@ cargo xtest
|
||||
2025. `scripts/watch.sh` uses Bacon and retains a cargo-watch fallback for people
|
||||
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
|
||||
|
||||
Every change runs format, Clippy, unit, contract, integration, and doctests on
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
### GPUI
|
||||
|
||||
@@ -10,8 +10,8 @@ from macOS and Linux and the hard gates pass.
|
||||
| Builds on Ubuntu | yes | conditional | pass |
|
||||
| Builds on Omarchy/Arch | yes | pending | pending |
|
||||
| Dependency/license closure permits Apache-2.0 distribution | yes | pending | pending |
|
||||
| Keyboard navigation + AccessKit tree | yes | pending | pending |
|
||||
| IME and composed Unicode input | 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 | framework API exists; end-to-end pending | editor API exists; end-to-end pending |
|
||||
| Isolated system browser child | yes | pending | pending |
|
||||
| Cold startup, p50/p95 | 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
|
||||
name normally supplied by `libxkbcommon-x11-dev`. Floem current main also
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
Executable
+58
@@ -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."
|
||||
+418
-88
@@ -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::{
|
||||
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 PANEL: Color = Color::from_rgb8(18, 23, 34);
|
||||
const PANEL_ALT: Color = Color::from_rgb8(23, 29, 41);
|
||||
const BORDER: Color = Color::from_rgb8(41, 50, 68);
|
||||
const TEXT: Color = Color::from_rgb8(217, 226, 242);
|
||||
const MUTED: Color = Color::from_rgb8(127, 139, 163);
|
||||
const ACCENT: Color = Color::from_rgb8(119, 189, 251);
|
||||
const BG: Color = Color::from_rgb8(9, 12, 18);
|
||||
const PANEL: Color = Color::from_rgb8(16, 22, 32);
|
||||
const PANEL_ALT: Color = Color::from_rgb8(21, 29, 41);
|
||||
const PANEL_ACTIVE: Color = Color::from_rgb8(24, 35, 52);
|
||||
const BORDER: Color = Color::from_rgb8(38, 50, 70);
|
||||
const BORDER_QUIET: Color = Color::from_rgb8(28, 38, 54);
|
||||
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 {
|
||||
let header = Stack::horizontal((
|
||||
Label::new(pane.title).style(|s| s.flex_grow(1.0).color(TEXT)),
|
||||
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)
|
||||
fn dispatch(model: RwSignal<ShellModel>, action: ShellAction) {
|
||||
model.update(|model| {
|
||||
model.dispatch(action);
|
||||
});
|
||||
}
|
||||
|
||||
let lines =
|
||||
Stack::vertical(pane.lines.map(|line| {
|
||||
Label::new(line).style(|s| s.font_size(12.0).color(TEXT).margin_bottom(5.0))
|
||||
}));
|
||||
|
||||
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)),
|
||||
))
|
||||
fn pane_card(id: PaneId, model: RwSignal<ShellModel>) -> impl IntoView {
|
||||
dyn_container(
|
||||
move || model.with(|model| (model.pane(id).clone(), model.selected_pane() == id)),
|
||||
move |(pane, selected)| render_pane_card(pane, selected, model),
|
||||
)
|
||||
.style(|s| {
|
||||
s.flex_basis(0)
|
||||
.flex_grow(1.0)
|
||||
.min_width(0.0)
|
||||
.min_height(0.0)
|
||||
.margin(4.0)
|
||||
.background(PANEL)
|
||||
.border(1.0)
|
||||
.border_color(BORDER)
|
||||
.border_radius(6.0)
|
||||
})
|
||||
}
|
||||
|
||||
fn app_view() -> impl IntoView {
|
||||
let sidebar_items = Stack::vertical(WORKSPACES.map(|name| {
|
||||
Label::new(name).style(move |s| {
|
||||
s.width_full()
|
||||
.padding_vert(8.0)
|
||||
.padding_horiz(12.0)
|
||||
.margin_bottom(3.0)
|
||||
.color(if name == "Lumbridge Code" {
|
||||
TEXT
|
||||
fn render_pane_card(pane: PaneState, selected: bool, model: RwSignal<ShellModel>) -> impl IntoView {
|
||||
let id = pane.id();
|
||||
let needs_input = pane.needs_input();
|
||||
let state_color = if needs_input {
|
||||
ATTENTION
|
||||
} else if matches!(pane.kind(), SurfaceKind::Terminal) {
|
||||
SUCCESS
|
||||
} else {
|
||||
MUTED
|
||||
ACCENT
|
||||
};
|
||||
let state_label = if matches!(pane.kind(), SurfaceKind::Terminal) {
|
||||
pane.status().label()
|
||||
} else {
|
||||
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)
|
||||
})
|
||||
.apply_if(name == "Lumbridge Code", |s| {
|
||||
s.background(PANEL_ALT).border_radius(6.0)
|
||||
})
|
||||
})
|
||||
}));
|
||||
}))
|
||||
.style(|s| s.flex_col().padding_horiz(10.0).padding_bottom(10.0));
|
||||
|
||||
let sidebar = Stack::vertical((
|
||||
Label::new("WORKSPACES").style(|s| s.font_size(11.0).color(MUTED).padding(12.0)),
|
||||
sidebar_items.style(|s| s.padding_horiz(8.0)),
|
||||
Label::new("HOSTS").style(|s| {
|
||||
s.font_size(11.0)
|
||||
.color(MUTED)
|
||||
.padding(12.0)
|
||||
.margin_top(12.0)
|
||||
}),
|
||||
Label::new("● metal · connected").style(|s| s.color(ACCENT).padding_horiz(14.0)),
|
||||
Label::new("● amd-server · connected")
|
||||
.style(|s| s.color(MUTED).padding_horiz(14.0).margin_top(8.0)),
|
||||
Label::new("○ spark-1 · sleeping")
|
||||
.style(|s| s.color(MUTED).padding_horiz(14.0).margin_top(8.0)),
|
||||
let header = Stack::horizontal((
|
||||
Stack::horizontal((
|
||||
Label::new(format!("{}", id.index() + 1))
|
||||
.style(|s| s.font_size(11.0).color(MUTED).margin_right(8.0)),
|
||||
Label::new(pane.fixture().title)
|
||||
.style(|s| s.flex_grow(1.0).font_size(13.0).color(TEXT)),
|
||||
))
|
||||
.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)),
|
||||
))
|
||||
.style(move |s| {
|
||||
s.items_center()
|
||||
.height(36.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| {
|
||||
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()
|
||||
.flex_shrink(0.0)
|
||||
.background(PANEL)
|
||||
.border_right(1.0)
|
||||
.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((
|
||||
pane_card(PANES[0]),
|
||||
pane_card(PANES[1]),
|
||||
pane_card(PANES[2]),
|
||||
pane_card(PaneId::CodexRuntime, model),
|
||||
pane_card(PaneId::ClaudeUi, model),
|
||||
pane_card(PaneId::PiDocs, model),
|
||||
))
|
||||
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0).width_full());
|
||||
let bottom_row = Stack::horizontal((
|
||||
pane_card(PANES[3]),
|
||||
pane_card(PANES[4]),
|
||||
pane_card(PANES[5]),
|
||||
pane_card(PaneId::Architecture, model),
|
||||
pane_card(PaneId::AcpPreview, model),
|
||||
pane_card(PaneId::RuntimeReview, model),
|
||||
))
|
||||
.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)
|
||||
.flex_grow(1.0)
|
||||
.min_width(0.0)
|
||||
.height_full()
|
||||
.min_height(0.0)
|
||||
.padding(4.0)
|
||||
});
|
||||
|
||||
let header = Stack::horizontal((
|
||||
Label::new("Lumbridge").style(|s| s.font_size(18.0).color(TEXT).flex_grow(1.0)),
|
||||
Label::new("UI spike · Floem").style(|s| s.color(MUTED).flex_grow(1.0)),
|
||||
Label::new("⌘K Command Palette").style(|s| s.color(ACCENT)),
|
||||
Label::new("Lumbridge").style(|s| s.font_size(18.0).color(TEXT)),
|
||||
Label::new("Lumbridge Code / main")
|
||||
.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| {
|
||||
s.height(46.0)
|
||||
s.height(48.0)
|
||||
.padding_horiz(14.0)
|
||||
.items_center()
|
||||
.background(PANEL_ALT)
|
||||
.border_bottom(1.0)
|
||||
.border_color(BORDER)
|
||||
.border_color(BORDER_QUIET)
|
||||
});
|
||||
|
||||
let body = Stack::horizontal((sidebar, panes))
|
||||
let body = Stack::horizontal((
|
||||
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((
|
||||
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_RIGHT),
|
||||
))
|
||||
.style(|s| {
|
||||
s.height(30.0)
|
||||
s.height(32.0)
|
||||
.padding_horiz(10.0)
|
||||
.items_center()
|
||||
.font_size(11.0)
|
||||
.font_size(10.0)
|
||||
.color(MUTED)
|
||||
.background(PANEL_ALT)
|
||||
.border_top(1.0)
|
||||
.border_color(BORDER)
|
||||
.border_color(BORDER_QUIET)
|
||||
});
|
||||
|
||||
Stack::vertical((header, body, footer))
|
||||
.style(|s| s.width_full().height_full().background(BG))
|
||||
.window_title(|| "Lumbridge · Floem spike".to_owned())
|
||||
let palette = dyn_container(
|
||||
move || model.with(|model| model.command_palette().is_open()),
|
||||
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() {
|
||||
@@ -154,9 +484,9 @@ fn main() {
|
||||
|_| app_view(),
|
||||
Some(
|
||||
WindowConfig::default()
|
||||
.size(Size::new(1280.0, 800.0))
|
||||
.size(Size::new(1500.0, 960.0))
|
||||
.min_size(Size::new(900.0, 600.0))
|
||||
.title("Lumbridge · Floem spike"),
|
||||
.title("Lumbridge · Floem workspace"),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
|
||||
+7119
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]
|
||||
@@ -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"]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+598
-57
@@ -1,54 +1,293 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::{
|
||||
App, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px,
|
||||
rgb, size,
|
||||
App, Application, Bounds, Context, FocusHandle, KeyBinding, KeyDownEvent, Window, WindowBounds,
|
||||
WindowOptions, actions, div, prelude::*, px, rgb, size,
|
||||
};
|
||||
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 PANEL: u32 = 0x121722;
|
||||
const PANEL_ALT: u32 = 0x171d29;
|
||||
const BORDER: u32 = 0x293244;
|
||||
const TEXT: u32 = 0xd9e2f2;
|
||||
const MUTED: u32 = 0x7f8ba3;
|
||||
const ACCENT: u32 = 0x77bdfb;
|
||||
const BG: u32 = 0x090c12;
|
||||
const PANEL: u32 = 0x101620;
|
||||
const PANEL_ALT: u32 = 0x151d29;
|
||||
const PANEL_ACTIVE: u32 = 0x182334;
|
||||
const BORDER: u32 = 0x263246;
|
||||
const BORDER_QUIET: u32 = 0x1c2636;
|
||||
const TEXT: u32 = 0xdbe5f4;
|
||||
const MUTED: u32 = 0x8290a8;
|
||||
const ACCENT: u32 = 0x68b5f8;
|
||||
const ATTENTION: u32 = 0xf1b96a;
|
||||
const SUCCESS: u32 = 0x70d6a8;
|
||||
const TIMING_SAMPLE_LIMIT: usize = 256;
|
||||
|
||||
struct LumbridgeShell;
|
||||
actions!(
|
||||
lumbridge,
|
||||
[
|
||||
FocusLeft,
|
||||
FocusRight,
|
||||
FocusUp,
|
||||
FocusDown,
|
||||
OpenPalette,
|
||||
ClosePalette,
|
||||
SelectPane1,
|
||||
SelectPane2,
|
||||
SelectPane3,
|
||||
SelectPane4,
|
||||
SelectPane5,
|
||||
SelectPane6,
|
||||
]
|
||||
);
|
||||
|
||||
struct LumbridgeShell {
|
||||
model: ShellModel,
|
||||
timing: RenderTiming,
|
||||
root_focus: FocusHandle,
|
||||
pane_focus: [FocusHandle; 6],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RenderTiming {
|
||||
pending_dispatch: Option<Instant>,
|
||||
dispatch_to_element_micros: VecDeque<u128>,
|
||||
}
|
||||
|
||||
impl RenderTiming {
|
||||
fn mark_dispatch(&mut self) {
|
||||
self.pending_dispatch = Some(Instant::now());
|
||||
}
|
||||
|
||||
fn observe_element_build(&mut self) {
|
||||
let Some(started) = self.pending_dispatch.take() else {
|
||||
return;
|
||||
};
|
||||
if self.dispatch_to_element_micros.len() == TIMING_SAMPLE_LIMIT {
|
||||
self.dispatch_to_element_micros.pop_front();
|
||||
}
|
||||
self.dispatch_to_element_micros
|
||||
.push_back(started.elapsed().as_micros());
|
||||
}
|
||||
|
||||
fn summary(&self) -> String {
|
||||
if self.dispatch_to_element_micros.is_empty() {
|
||||
return "dispatch→element collecting…".to_owned();
|
||||
}
|
||||
let mut samples = self
|
||||
.dispatch_to_element_micros
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
samples.sort_unstable();
|
||||
let percentile = |percent: usize| {
|
||||
let index = ((samples.len() - 1) * percent) / 100;
|
||||
samples[index]
|
||||
};
|
||||
format!(
|
||||
"dispatch→element p50 {}µs · p95 {}µs · n{}",
|
||||
percentile(50),
|
||||
percentile(95),
|
||||
samples.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl LumbridgeShell {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let pane_focus = std::array::from_fn(|index| {
|
||||
cx.focus_handle()
|
||||
.tab_index(index as isize + 1)
|
||||
.tab_stop(true)
|
||||
});
|
||||
let root_focus = cx.focus_handle();
|
||||
window.focus(&pane_focus[0]);
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(650))
|
||||
.await;
|
||||
if this
|
||||
.update(cx, |shell, cx| {
|
||||
shell.dispatch(ShellAction::SyntheticStreamTick);
|
||||
cx.notify();
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
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);
|
||||
|
||||
fn pane_card(pane: &PaneFixture) -> impl IntoElement {
|
||||
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(PANEL))
|
||||
.bg(rgb(if selected { PANEL_ACTIVE } else { PANEL }))
|
||||
.border_1()
|
||||
.border_color(rgb(BORDER))
|
||||
.rounded_md()
|
||||
.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(34.0))
|
||||
.bg(rgb(PANEL_ALT))
|
||||
.h(px(36.0))
|
||||
.flex_none()
|
||||
.bg(rgb(if selected { PANEL_ACTIVE } else { PANEL_ALT }))
|
||||
.border_b_1()
|
||||
.border_color(rgb(BORDER))
|
||||
.border_color(rgb(BORDER_QUIET))
|
||||
.text_sm()
|
||||
.text_color(rgb(TEXT))
|
||||
.child(pane.title)
|
||||
.child(div().text_xs().text_color(rgb(ACCENT)).child(pane.badge)),
|
||||
.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.target),
|
||||
.child(pane.fixture().target)
|
||||
.when(selected, |view| view.child("FOCUSED")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -59,27 +298,148 @@ fn pane_card(pane: &PaneFixture) -> impl IntoElement {
|
||||
.pb_3()
|
||||
.text_sm()
|
||||
.text_color(rgb(TEXT))
|
||||
.children(pane.lines.into_iter()),
|
||||
.children(pane.lines()[line_start..].iter().cloned()),
|
||||
)
|
||||
}
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl Render for LumbridgeShell {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let sidebar = div()
|
||||
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()
|
||||
.flex_col()
|
||||
.w(px(220.0))
|
||||
.flex_none()
|
||||
.bg(rgb(PANEL))
|
||||
.border_r_1()
|
||||
.border_color(rgb(BORDER))
|
||||
.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("WORKSPACES"),
|
||||
.child("Workspace · navigation"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("Open attention request"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("Share selected pane to Buzz…"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.border_t_1()
|
||||
.border_color(rgb(BORDER))
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("Enter to run")
|
||||
.child("Esc to close"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for LumbridgeShell {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let attention = self.model.pane(PaneId::ClaudeUi);
|
||||
let sidebar = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.w(px(248.0))
|
||||
.flex_none()
|
||||
.bg(rgb(PANEL))
|
||||
.border_r_1()
|
||||
.border_color(rgb(BORDER_QUIET))
|
||||
.child(
|
||||
div()
|
||||
.px_4()
|
||||
.pt_4()
|
||||
.pb_2()
|
||||
.text_xs()
|
||||
.text_color(rgb(ATTENTION))
|
||||
.child("ATTENTION · 1"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.mx_2()
|
||||
.mb_3()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(px(5.0))
|
||||
.bg(rgb(PANEL_ACTIVE))
|
||||
.border_1()
|
||||
.border_color(rgb(ATTENTION))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(rgb(TEXT))
|
||||
.child(attention.fixture().title),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.mt_1()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("Waiting for a split decision"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("WORKTREES"),
|
||||
)
|
||||
.children(WORKSPACES.into_iter().enumerate().map(|(index, name)| {
|
||||
div()
|
||||
@@ -87,30 +447,130 @@ impl Render for LumbridgeShell {
|
||||
.mb_1()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded_md()
|
||||
.rounded(px(5.0))
|
||||
.when(index == 0, |view| {
|
||||
view.bg(rgb(PANEL_ALT)).text_color(rgb(TEXT))
|
||||
})
|
||||
.when(index != 0, |view| view.text_color(rgb(MUTED)))
|
||||
.child(name)
|
||||
.when(index == 0, |view| {
|
||||
view.child(
|
||||
div()
|
||||
.mt_1()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("main · metal"),
|
||||
)
|
||||
})
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.mt_4()
|
||||
.mt_3()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("HOSTS")
|
||||
.child("RUNTIMES"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.mt_2()
|
||||
.text_color(rgb(ACCENT))
|
||||
.child("● metal · connected"),
|
||||
.px_4()
|
||||
.py_1()
|
||||
.text_sm()
|
||||
.text_color(rgb(SUCCESS))
|
||||
.child("metal · connected"),
|
||||
)
|
||||
.child(div().mt_2().child("● amd-server · connected"))
|
||||
.child(div().mt_2().child("○ spark-1 · sleeping")),
|
||||
.child(
|
||||
div()
|
||||
.px_4()
|
||||
.py_1()
|
||||
.text_sm()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("amd-server · connected"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_4()
|
||||
.py_1()
|
||||
.text_sm()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("spark-1 · sleeping"),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
div()
|
||||
.m_3()
|
||||
.p_3()
|
||||
.rounded(px(5.0))
|
||||
.bg(rgb(PANEL_ALT))
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("Buzz · lumbridgecode")
|
||||
.child(
|
||||
div()
|
||||
.mt_1()
|
||||
.text_color(rgb(SUCCESS))
|
||||
.child("connected · signed identity"),
|
||||
),
|
||||
);
|
||||
|
||||
let tabs = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.h(px(38.0))
|
||||
.flex_none()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.bg(rgb(PANEL))
|
||||
.border_b_1()
|
||||
.border_color(rgb(BORDER_QUIET))
|
||||
.child(
|
||||
div()
|
||||
.h_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.px_3()
|
||||
.border_b_2()
|
||||
.border_color(rgb(ACCENT))
|
||||
.text_sm()
|
||||
.child("Agent workspace"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.text_sm()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("Architecture.md"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.text_sm()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("Review"),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("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()
|
||||
.grid()
|
||||
.grid_cols(3)
|
||||
@@ -118,9 +578,48 @@ impl Render for LumbridgeShell {
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.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_col()
|
||||
.size_full()
|
||||
@@ -130,63 +629,105 @@ impl Render for LumbridgeShell {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.h(px(46.0))
|
||||
.h(px(48.0))
|
||||
.flex_none()
|
||||
.px_4()
|
||||
.bg(rgb(PANEL_ALT))
|
||||
.border_b_1()
|
||||
.border_color(rgb(BORDER))
|
||||
.border_color(rgb(BORDER_QUIET))
|
||||
.child(div().text_lg().child("Lumbridge"))
|
||||
.child(
|
||||
div()
|
||||
.ml_3()
|
||||
.text_sm()
|
||||
.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(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(rgb(ACCENT))
|
||||
.child("⌘K Command Palette"),
|
||||
.child("Ctrl/⌘ K · Commands"),
|
||||
),
|
||||
)
|
||||
.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(div().flex().flex_1().min_h_0().child(sidebar).child(grid))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.h(px(30.0))
|
||||
.h(px(32.0))
|
||||
.flex_none()
|
||||
.px_3()
|
||||
.bg(rgb(PANEL_ALT))
|
||||
.border_t_1()
|
||||
.border_color(rgb(BORDER))
|
||||
.border_color(rgb(BORDER_QUIET))
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child(FOOTER_LEFT)
|
||||
.child(FOOTER_CENTER)
|
||||
.child(footer_left)
|
||||
.child(timing)
|
||||
.child(FOOTER_RIGHT),
|
||||
)
|
||||
.when(self.model.command_palette().is_open(), |view| {
|
||||
view.child(self.command_palette())
|
||||
});
|
||||
self.timing.observe_element_build();
|
||||
root
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
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(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
titlebar: Some(gpui::TitlebarOptions {
|
||||
title: Some("Lumbridge · GPUI spike".into()),
|
||||
title: Some("Lumbridge · GPUI workspace".into()),
|
||||
..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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
pub enum SurfaceKind {
|
||||
Terminal,
|
||||
@@ -6,21 +73,45 @@ pub enum SurfaceKind {
|
||||
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)]
|
||||
pub struct PaneFixture {
|
||||
pub id: PaneId,
|
||||
pub title: &'static str,
|
||||
pub badge: &'static str,
|
||||
pub target: &'static str,
|
||||
pub kind: SurfaceKind,
|
||||
pub status: PaneStatus,
|
||||
pub lines: [&'static str; 4],
|
||||
}
|
||||
|
||||
pub const PANES: [PaneFixture; 6] = [
|
||||
PaneFixture {
|
||||
id: PaneId::CodexRuntime,
|
||||
title: "Codex · runtime",
|
||||
badge: "WORKING",
|
||||
target: "metal · Tailscale SSH",
|
||||
kind: SurfaceKind::Terminal,
|
||||
status: PaneStatus::Working,
|
||||
lines: [
|
||||
"$ cargo nextest run -p lumbridge-runtime",
|
||||
"PASS remote::reconnect_replays_output",
|
||||
@@ -29,10 +120,12 @@ pub const PANES: [PaneFixture; 6] = [
|
||||
],
|
||||
},
|
||||
PaneFixture {
|
||||
id: PaneId::ClaudeUi,
|
||||
title: "Claude Code · UI",
|
||||
badge: "NEEDS INPUT",
|
||||
target: "MacBook Air · local",
|
||||
kind: SurfaceKind::Terminal,
|
||||
status: PaneStatus::NeedsInput,
|
||||
lines: [
|
||||
"$ bacon clippy",
|
||||
"finished in 0.42s",
|
||||
@@ -41,10 +134,12 @@ pub const PANES: [PaneFixture; 6] = [
|
||||
],
|
||||
},
|
||||
PaneFixture {
|
||||
id: PaneId::PiDocs,
|
||||
title: "Pi · docs",
|
||||
badge: "STREAMING",
|
||||
target: "amd-server · OpenSSH",
|
||||
kind: SurfaceKind::Terminal,
|
||||
status: PaneStatus::Streaming,
|
||||
lines: [
|
||||
"$ cargo watch --why",
|
||||
"ACP session resumed at event 1842",
|
||||
@@ -53,10 +148,12 @@ pub const PANES: [PaneFixture; 6] = [
|
||||
],
|
||||
},
|
||||
PaneFixture {
|
||||
id: PaneId::Architecture,
|
||||
title: "Architecture.md",
|
||||
badge: "MARKDOWN",
|
||||
target: "lumbridge-code · worktree",
|
||||
kind: SurfaceKind::Markdown,
|
||||
status: PaneStatus::Ready,
|
||||
lines: [
|
||||
"## Remote session path",
|
||||
"The destination runtime owns the PTY.",
|
||||
@@ -65,10 +162,12 @@ pub const PANES: [PaneFixture; 6] = [
|
||||
],
|
||||
},
|
||||
PaneFixture {
|
||||
id: PaneId::AcpPreview,
|
||||
title: "Preview · ACP docs",
|
||||
badge: "BROWSER",
|
||||
target: "isolated system web engine",
|
||||
kind: SurfaceKind::Browser,
|
||||
status: PaneStatus::Ready,
|
||||
lines: [
|
||||
"https://agentclientprotocol.com",
|
||||
"Content process: sandboxed",
|
||||
@@ -77,10 +176,12 @@ pub const PANES: [PaneFixture; 6] = [
|
||||
],
|
||||
},
|
||||
PaneFixture {
|
||||
id: PaneId::RuntimeReview,
|
||||
title: "Changes · lumbridge-runtime",
|
||||
badge: "REVIEW",
|
||||
target: "metal · worktree remote-runtime",
|
||||
kind: SurfaceKind::Review,
|
||||
status: PaneStatus::Ready,
|
||||
lines: [
|
||||
"+ 184 remote transport",
|
||||
"+ 96 SQLite migrations",
|
||||
@@ -97,26 +198,538 @@ pub const WORKSPACES: [&str; 5] = [
|
||||
"ACP adapters",
|
||||
"Usage telemetry",
|
||||
];
|
||||
|
||||
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_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)]
|
||||
mod tests {
|
||||
use super::{PANES, SurfaceKind};
|
||||
use super::{
|
||||
FocusDirection, GRID_COLUMNS, GRID_ROWS, PANES, PaneId, PaneStatus, ShellAction,
|
||||
ShellModel, SurfaceKind, focus_neighbor,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn comparison_fixture_has_six_mixed_surfaces() {
|
||||
assert_eq!(PANES.len(), 6);
|
||||
fn fixture_has_stable_ids_and_mixed_surfaces() {
|
||||
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::Browser));
|
||||
assert_eq!(
|
||||
PANES
|
||||
.iter()
|
||||
.filter(|pane| pane.kind == SurfaceKind::Terminal)
|
||||
.filter(|p| p.kind == SurfaceKind::Terminal)
|
||||
.count(),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user