This commit is contained in:
Generated
+175
-5
@@ -21,6 +21,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.104"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayvec"
|
name = "arrayvec"
|
||||||
version = "0.7.8"
|
version = "0.7.8"
|
||||||
@@ -99,6 +105,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.1"
|
version = "2.13.1"
|
||||||
@@ -154,7 +166,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -169,7 +181,7 @@ dependencies = [
|
|||||||
"nostr",
|
"nostr",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -198,6 +210,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20"
|
name = "chacha20"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -357,6 +375,12 @@ dependencies = [
|
|||||||
"syn 3.0.4",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "downcast-rs"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fallible-iterator"
|
name = "fallible-iterator"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -369,6 +393,17 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filedescriptor"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -661,6 +696,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy_static"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.189"
|
version = "0.2.189"
|
||||||
@@ -703,7 +744,7 @@ version = "0.0.1"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"buzz-sdk",
|
"buzz-sdk",
|
||||||
"nostr",
|
"nostr",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -711,6 +752,15 @@ dependencies = [
|
|||||||
name = "lumbridge-core"
|
name = "lumbridge-core"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lumbridge-pty"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"nix",
|
||||||
|
"portable-pty",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lumbridge-storage"
|
name = "lumbridge-storage"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
@@ -725,6 +775,18 @@ version = "2.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr"
|
name = "nostr"
|
||||||
version = "0.44.8"
|
version = "0.44.8"
|
||||||
@@ -820,6 +882,27 @@ dependencies = [
|
|||||||
"universal-hash",
|
"universal-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-pty"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"downcast-rs",
|
||||||
|
"filedescriptor",
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"nix",
|
||||||
|
"serial2",
|
||||||
|
"shared_library",
|
||||||
|
"shell-words",
|
||||||
|
"winapi",
|
||||||
|
"winreg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -915,7 +998,7 @@ version = "0.40.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
|
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.13.1",
|
||||||
"fallible-iterator",
|
"fallible-iterator",
|
||||||
"fallible-streaming-iterator",
|
"fallible-streaming-iterator",
|
||||||
"libsqlite3-sys",
|
"libsqlite3-sys",
|
||||||
@@ -1012,6 +1095,17 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serial2"
|
||||||
|
version = "0.2.38"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -1034,6 +1128,22 @@ dependencies = [
|
|||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shared_library"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shell-words"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -1097,13 +1207,33 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.20"
|
version = "2.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1277,6 +1407,28 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
@@ -1336,6 +1488,24 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winreg"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ members = [
|
|||||||
"apps/lumbridge",
|
"apps/lumbridge",
|
||||||
"crates/lumbridge-buzz",
|
"crates/lumbridge-buzz",
|
||||||
"crates/lumbridge-core",
|
"crates/lumbridge-core",
|
||||||
|
"crates/lumbridge-pty",
|
||||||
"crates/lumbridge-storage",
|
"crates/lumbridge-storage",
|
||||||
]
|
]
|
||||||
exclude = ["spikes"]
|
exclude = ["spikes"]
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ as installable binaries; building from source will remain supported.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
This repository is in architecture and product-definition phase. The current
|
This repository is in architecture and vertical-slice phase. The installable
|
||||||
binary is deliberately only a scaffold. We are validating the terminal engine,
|
binary is still a scaffold, while the isolated native UI spikes now exercise an
|
||||||
UI stack, process/session model, ACP integration, packaging, and usage-data
|
interactive six-surface workspace and the root workspace contains the first
|
||||||
contracts before committing to a large implementation.
|
bounded local PTY boundary. We are still validating terminal emulation, the
|
||||||
|
session runtime, ACP integration, packaging, and usage-data contracts before a
|
||||||
|
large implementation.
|
||||||
|
|
||||||
## Product shape
|
## Product shape
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- `lumbridge`: installable application entry point.
|
||||||
|
|
||||||
The scaffold currently contains `lumbridge-core`, `lumbridge-storage`,
|
The scaffold currently contains `lumbridge-core`, `lumbridge-storage`,
|
||||||
`lumbridge-buzz`, and the entry point. Larger runtime crates are added after
|
`lumbridge-buzz`, `lumbridge-pty`, and the entry point. Larger runtime crates are
|
||||||
their architecture spikes pass.
|
added after their architecture spikes pass.
|
||||||
|
|
||||||
## Terminal path
|
## Terminal path
|
||||||
|
|
||||||
The runtime owns PTYs, child process groups, resize signals, input ordering, and
|
The runtime owns PTYs, child process groups, resize signals, input ordering, and
|
||||||
raw output. The terminal engine turns output into immutable render snapshots and
|
raw output. The first `lumbridge-pty` boundary now uses `portable-pty` behind a
|
||||||
|
single-owner session, a fixed credential-free child environment, validated
|
||||||
|
sizes, an 8 KiB bounded output queue, resize/input/wait operations, and Unix
|
||||||
|
process-group cleanup. It deliberately does not emulate a terminal or persist
|
||||||
|
scrollback. The terminal engine turns output into immutable render snapshots and
|
||||||
bounded deltas for the UI. Scrollback is chunked and persisted separately from
|
bounded deltas for the UI. Scrollback is chunked and persisted separately from
|
||||||
the live screen to prevent large agent transcripts from blocking input.
|
the live screen to prevent large agent transcripts from blocking input.
|
||||||
|
|
||||||
@@ -59,15 +63,18 @@ and simultaneous automation plus human input.
|
|||||||
|
|
||||||
## UI decision gate
|
## UI decision gate
|
||||||
|
|
||||||
Do not lock the project to a webview or to Zed's private implementation details
|
Do not lock the project to a webview or to Zed's application implementation
|
||||||
before a measured spike. Compare:
|
details. The measured spike compares:
|
||||||
|
|
||||||
1. GPUI for a Zed-like native model and excellent text-heavy interaction.
|
1. GPUI for a Zed-like native model and excellent text-heavy interaction.
|
||||||
2. Floem for an independent native Rust model with existing editor primitives.
|
2. Floem for an independent native Rust model with existing editor primitives.
|
||||||
|
|
||||||
The winner must render six busy panes smoothly, keep input latency low, support
|
The winner must render six busy panes smoothly, keep input latency low, support
|
||||||
IME/accessibility, package on macOS and both Linux targets, and avoid a license
|
IME/accessibility, package on macOS and both Linux targets, and avoid a license
|
||||||
or upstream-stability trap.
|
or upstream-stability trap. Current Zed GPUI is the provisional winner because
|
||||||
|
its AccessKit semantics compile while the pinned Floem revision has no semantic
|
||||||
|
accessibility integration. This remains behind a narrow adapter until platform
|
||||||
|
assistive-technology, IME, packaging, licensing, and release measurements pass.
|
||||||
|
|
||||||
## Local-first and remote session path
|
## Local-first and remote session path
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,19 @@ cargo xtest
|
|||||||
2025. `scripts/watch.sh` uses Bacon and retains a cargo-watch fallback for people
|
2025. `scripts/watch.sh` uses Bacon and retains a cargo-watch fallback for people
|
||||||
who already have the final cargo-watch release installed.
|
who already have the final cargo-watch release installed.
|
||||||
|
|
||||||
|
## Implemented vertical-slice coverage
|
||||||
|
|
||||||
|
- The framework-neutral six-surface model has deterministic tests for focus,
|
||||||
|
direct selection, needs-input transitions, command-palette lifecycle,
|
||||||
|
identical GPUI/Floem action replay, bounded output, and workload counters.
|
||||||
|
- `lumbridge-pty` runs synthetic `/bin/sh` tests for raw output, non-zero exits,
|
||||||
|
input, resize, one-chunk backpressure, hung-process termination, invalid
|
||||||
|
dimensions, and exclusion of provider credentials from the child environment.
|
||||||
|
- The current-GPUI probe compile-checks real AccessKit element wiring and real
|
||||||
|
platform input-handler installation. Unit tests cover its semantic tree and
|
||||||
|
UTF-16/UTF-8 composed-text mutations. OS screen readers, IME candidate windows,
|
||||||
|
and macOS behavior remain platform tests.
|
||||||
|
|
||||||
## CI matrix as the project grows
|
## CI matrix as the project grows
|
||||||
|
|
||||||
Every change runs format, Clippy, unit, contract, integration, and doctests on
|
Every change runs format, Clippy, unit, contract, integration, and doctests on
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ This decision stays open until the finalists run the same workload. Choosing on
|
|||||||
repository popularity or a polished demo would hide the risks that matter for a
|
repository popularity or a polished demo would hide the risks that matter for a
|
||||||
terminal-heavy, accessibility-aware, cross-platform IDE.
|
terminal-heavy, accessibility-aware, cross-platform IDE.
|
||||||
|
|
||||||
|
The current vertical-slice result is GPUI-first, not GPUI-final: current Zed
|
||||||
|
GPUI compile-proves the required AccessKit and IME handler paths, while the
|
||||||
|
pinned Floem revision fails the semantic-accessibility hard gate. The app-facing
|
||||||
|
UI boundary must stay narrow until platform AT/IME, packaging, license closure,
|
||||||
|
and release measurements finish. See `UI_SPIKE_SCORECARD.md` for evidence and
|
||||||
|
remaining gates.
|
||||||
|
|
||||||
## Finalists
|
## Finalists
|
||||||
|
|
||||||
### GPUI
|
### GPUI
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from macOS and Linux and the hard gates pass.
|
|||||||
| Builds on Ubuntu | yes | conditional | pass |
|
| Builds on Ubuntu | yes | conditional | pass |
|
||||||
| Builds on Omarchy/Arch | yes | pending | pending |
|
| Builds on Omarchy/Arch | yes | pending | pending |
|
||||||
| Dependency/license closure permits Apache-2.0 distribution | yes | pending | pending |
|
| Dependency/license closure permits Apache-2.0 distribution | yes | pending | pending |
|
||||||
| Keyboard navigation + AccessKit tree | yes | pending | pending |
|
| Keyboard navigation + AccessKit tree | yes | keyboard pass; current-GPUI semantics compile; platform AT pending | fail: no AccessKit integration at pinned revision |
|
||||||
| IME and composed Unicode input | yes | pending | pending |
|
| IME and composed Unicode input | yes | framework API exists; end-to-end pending | editor API exists; end-to-end pending |
|
||||||
| Isolated system browser child | yes | pending | pending |
|
| Isolated system browser child | yes | pending | pending |
|
||||||
| Cold startup, p50/p95 | record | pending | pending |
|
| Cold startup, p50/p95 | record | pending | pending |
|
||||||
| Idle RSS / six-stream RSS | record | pending | pending |
|
| Idle RSS / six-stream RSS | record | pending | pending |
|
||||||
@@ -43,4 +43,50 @@ performance numbers. GPUI's Ubuntu row remains conditional because the host has
|
|||||||
the runtime `libxkbcommon-x11.so.0` but lacks the unversioned development linker
|
the runtime `libxkbcommon-x11.so.0` but lacks the unversioned development linker
|
||||||
name normally supplied by `libxkbcommon-x11-dev`. Floem current main also
|
name normally supplied by `libxkbcommon-x11-dev`. Floem current main also
|
||||||
requires its `editor` feature for the style/label modules used by this shell;
|
requires its `editor` feature for the style/label modules used by this shell;
|
||||||
that feature is now explicit in the spike. Neither finding decides the UI stack.
|
that feature is now explicit in the spike. Neither build result by itself
|
||||||
|
decides the UI stack.
|
||||||
|
|
||||||
|
## Accessibility gate and provisional direction
|
||||||
|
|
||||||
|
The published `gpui 0.2.2` used by the visual shell has focus and keyboard
|
||||||
|
actions but does not expose the current AccessKit element API. An isolated probe
|
||||||
|
therefore pins GPUI directly to Zed commit
|
||||||
|
`ce48461eaadd16c65c31f835511ab96bd3b6e746` with its matching Rust 1.97.1
|
||||||
|
toolchain. The real element wiring compiles with stable accessibility IDs,
|
||||||
|
workspace/pane/terminal roles, selected state, focus tracking, and a needs-input
|
||||||
|
description. The same probe installs real `EntityInputHandler` and
|
||||||
|
`ElementInputHandler` implementations and tests marked-text commit, a composed
|
||||||
|
accent, and a multi-codepoint emoji without splitting UTF-8. Three deterministic
|
||||||
|
tests pass. AT-SPI/OS IME on Linux and VoiceOver/IME on macOS remain end-to-end
|
||||||
|
gates.
|
||||||
|
|
||||||
|
The pinned Floem revision has keyboard focus and editor IME plumbing but no
|
||||||
|
AccessKit dependency or semantic tree. That fails Lumbridge's accessibility
|
||||||
|
gate without a maintained framework fork or adapter. The provisional direction
|
||||||
|
is therefore **GPUI-first using current Zed GPUI behind a narrow Lumbridge UI
|
||||||
|
adapter**. The published GPUI shell remains the lightweight interaction and
|
||||||
|
visual benchmark until the dependency/toolchain migration is accepted.
|
||||||
|
|
||||||
|
This is not the final cross-platform release decision. Current-GPUI validation
|
||||||
|
on amd-server required a 624 MiB Rust 1.97.1 toolchain, a 691-package lockfile,
|
||||||
|
and 4.4 GiB of debug artifacts across check, test, Clippy, and build. Packaging,
|
||||||
|
license closure, IME, platform assistive technology, browser parenting, macOS,
|
||||||
|
and Omarchy still have to pass.
|
||||||
|
|
||||||
|
The comparable Floem shell passes build, test, strict Clippy, live rendering,
|
||||||
|
streaming, mouse selection, and numeric pane selection on metal. Its command
|
||||||
|
palette opens through the visible clickable affordance and uses Floem's native
|
||||||
|
`TextInput`, but Ctrl/Cmd+K did not reach the handler in the X11 automation
|
||||||
|
probe. That unresolved focus/event-routing behavior is another current strike
|
||||||
|
against adopting Floem; it is recorded rather than hidden behind the clickable
|
||||||
|
fallback.
|
||||||
|
|
||||||
|
## Measurement semantics
|
||||||
|
|
||||||
|
Both renderers consume the same deterministic six-surface action stream. Each
|
||||||
|
tick invalidates six surfaces, appends one bounded line to each of the three
|
||||||
|
terminal fixtures, and records stable event/revision counters. The GPUI footer
|
||||||
|
currently reports dispatch-to-element-build p50/p95 over a bounded 256-sample
|
||||||
|
window. It is deliberately not called key-to-present or frame-present latency:
|
||||||
|
neither candidate exposes a reliable public cross-platform post-present
|
||||||
|
callback. True present latency requires an external platform probe.
|
||||||
|
|||||||
@@ -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."
|
||||||
+421
-91
@@ -1,151 +1,481 @@
|
|||||||
use floem::{Application, kurbo::Size, peniko::Color, prelude::*, window::WindowConfig};
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use floem::{
|
||||||
|
Application,
|
||||||
|
action::exec_after,
|
||||||
|
event::EventPropagation,
|
||||||
|
kurbo::Size,
|
||||||
|
peniko::Color,
|
||||||
|
prelude::*,
|
||||||
|
reactive::{Effect, RwSignal, SignalGet, SignalTrack, SignalUpdate, SignalWith},
|
||||||
|
style::Position,
|
||||||
|
window::WindowConfig,
|
||||||
|
};
|
||||||
use lumbridge_spike_model::{
|
use lumbridge_spike_model::{
|
||||||
FOOTER_CENTER, FOOTER_LEFT, FOOTER_RIGHT, PANES, PaneFixture, WORKSPACES,
|
FOOTER_CENTER, FOOTER_RIGHT, FocusDirection, PaneId, PaneState, ShellAction, ShellModel,
|
||||||
|
SurfaceKind, WORKSPACES,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BG: Color = Color::from_rgb8(12, 14, 19);
|
const BG: Color = Color::from_rgb8(9, 12, 18);
|
||||||
const PANEL: Color = Color::from_rgb8(18, 23, 34);
|
const PANEL: Color = Color::from_rgb8(16, 22, 32);
|
||||||
const PANEL_ALT: Color = Color::from_rgb8(23, 29, 41);
|
const PANEL_ALT: Color = Color::from_rgb8(21, 29, 41);
|
||||||
const BORDER: Color = Color::from_rgb8(41, 50, 68);
|
const PANEL_ACTIVE: Color = Color::from_rgb8(24, 35, 52);
|
||||||
const TEXT: Color = Color::from_rgb8(217, 226, 242);
|
const BORDER: Color = Color::from_rgb8(38, 50, 70);
|
||||||
const MUTED: Color = Color::from_rgb8(127, 139, 163);
|
const BORDER_QUIET: Color = Color::from_rgb8(28, 38, 54);
|
||||||
const ACCENT: Color = Color::from_rgb8(119, 189, 251);
|
const TEXT: Color = Color::from_rgb8(219, 229, 244);
|
||||||
|
const MUTED: Color = Color::from_rgb8(130, 144, 168);
|
||||||
|
const ACCENT: Color = Color::from_rgb8(104, 181, 248);
|
||||||
|
const ATTENTION: Color = Color::from_rgb8(241, 185, 106);
|
||||||
|
const SUCCESS: Color = Color::from_rgb8(112, 214, 168);
|
||||||
|
const OS_MOD: Modifiers = if cfg!(target_os = "macos") {
|
||||||
|
Modifiers::META
|
||||||
|
} else {
|
||||||
|
Modifiers::CONTROL
|
||||||
|
};
|
||||||
|
|
||||||
fn pane_card(pane: PaneFixture) -> impl IntoView {
|
fn dispatch(model: RwSignal<ShellModel>, action: ShellAction) {
|
||||||
let header = Stack::horizontal((
|
model.update(|model| {
|
||||||
Label::new(pane.title).style(|s| s.flex_grow(1.0).color(TEXT)),
|
model.dispatch(action);
|
||||||
Label::new(pane.badge).style(|s| s.font_size(11.0).color(ACCENT)),
|
|
||||||
))
|
|
||||||
.style(|s| {
|
|
||||||
s.items_center()
|
|
||||||
.height(34.0)
|
|
||||||
.padding_horiz(10.0)
|
|
||||||
.background(PANEL_ALT)
|
|
||||||
.border_bottom(1.0)
|
|
||||||
.border_color(BORDER)
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let lines =
|
fn pane_card(id: PaneId, model: RwSignal<ShellModel>) -> impl IntoView {
|
||||||
Stack::vertical(pane.lines.map(|line| {
|
dyn_container(
|
||||||
Label::new(line).style(|s| s.font_size(12.0).color(TEXT).margin_bottom(5.0))
|
move || model.with(|model| (model.pane(id).clone(), model.selected_pane() == id)),
|
||||||
}));
|
move |(pane, selected)| render_pane_card(pane, selected, model),
|
||||||
|
)
|
||||||
Stack::vertical((
|
|
||||||
header,
|
|
||||||
Label::new(pane.target).style(|s| s.font_size(11.0).color(MUTED).padding(10.0)),
|
|
||||||
lines.style(|s| s.padding_horiz(10.0).padding_bottom(10.0)),
|
|
||||||
))
|
|
||||||
.style(|s| {
|
.style(|s| {
|
||||||
s.flex_basis(0)
|
s.flex_basis(0)
|
||||||
.flex_grow(1.0)
|
.flex_grow(1.0)
|
||||||
.min_width(0.0)
|
.min_width(0.0)
|
||||||
.min_height(0.0)
|
.min_height(0.0)
|
||||||
.margin(4.0)
|
.margin(4.0)
|
||||||
.background(PANEL)
|
|
||||||
.border(1.0)
|
|
||||||
.border_color(BORDER)
|
|
||||||
.border_radius(6.0)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn app_view() -> impl IntoView {
|
fn render_pane_card(pane: PaneState, selected: bool, model: RwSignal<ShellModel>) -> impl IntoView {
|
||||||
let sidebar_items = Stack::vertical(WORKSPACES.map(|name| {
|
let id = pane.id();
|
||||||
Label::new(name).style(move |s| {
|
let needs_input = pane.needs_input();
|
||||||
s.width_full()
|
let state_color = if needs_input {
|
||||||
.padding_vert(8.0)
|
ATTENTION
|
||||||
.padding_horiz(12.0)
|
} else if matches!(pane.kind(), SurfaceKind::Terminal) {
|
||||||
.margin_bottom(3.0)
|
SUCCESS
|
||||||
.color(if name == "Lumbridge Code" {
|
} else {
|
||||||
TEXT
|
ACCENT
|
||||||
} else {
|
};
|
||||||
MUTED
|
let state_label = if matches!(pane.kind(), SurfaceKind::Terminal) {
|
||||||
})
|
pane.status().label()
|
||||||
.apply_if(name == "Lumbridge Code", |s| {
|
} else {
|
||||||
s.background(PANEL_ALT).border_radius(6.0)
|
pane.fixture().badge
|
||||||
})
|
};
|
||||||
|
let start = pane.lines().len().saturating_sub(12);
|
||||||
|
let lines = Stack::from_iter(pane.lines()[start..].iter().cloned().map(|line| {
|
||||||
|
Label::new(line).style(|s| {
|
||||||
|
s.font_size(12.0)
|
||||||
|
.color(TEXT)
|
||||||
|
.margin_bottom(4.0)
|
||||||
|
.min_width(0.0)
|
||||||
})
|
})
|
||||||
}));
|
}))
|
||||||
|
.style(|s| s.flex_col().padding_horiz(10.0).padding_bottom(10.0));
|
||||||
|
|
||||||
let sidebar = Stack::vertical((
|
let header = Stack::horizontal((
|
||||||
Label::new("WORKSPACES").style(|s| s.font_size(11.0).color(MUTED).padding(12.0)),
|
Stack::horizontal((
|
||||||
sidebar_items.style(|s| s.padding_horiz(8.0)),
|
Label::new(format!("{}", id.index() + 1))
|
||||||
Label::new("HOSTS").style(|s| {
|
.style(|s| s.font_size(11.0).color(MUTED).margin_right(8.0)),
|
||||||
s.font_size(11.0)
|
Label::new(pane.fixture().title)
|
||||||
.color(MUTED)
|
.style(|s| s.flex_grow(1.0).font_size(13.0).color(TEXT)),
|
||||||
.padding(12.0)
|
))
|
||||||
.margin_top(12.0)
|
.style(|s| s.items_center().min_width(0.0).flex_grow(1.0)),
|
||||||
}),
|
Label::new(state_label).style(move |s| s.font_size(10.0).color(state_color)),
|
||||||
Label::new("● metal · connected").style(|s| s.color(ACCENT).padding_horiz(14.0)),
|
))
|
||||||
Label::new("● amd-server · connected")
|
.style(move |s| {
|
||||||
.style(|s| s.color(MUTED).padding_horiz(14.0).margin_top(8.0)),
|
s.items_center()
|
||||||
Label::new("○ spark-1 · sleeping")
|
.height(36.0)
|
||||||
.style(|s| s.color(MUTED).padding_horiz(14.0).margin_top(8.0)),
|
.padding_horiz(10.0)
|
||||||
|
.background(if selected { PANEL_ACTIVE } else { PANEL_ALT })
|
||||||
|
.border_bottom(1.0)
|
||||||
|
.border_color(BORDER_QUIET)
|
||||||
|
});
|
||||||
|
|
||||||
|
let target = Stack::horizontal((
|
||||||
|
Label::new(pane.fixture().target).style(|s| s.flex_grow(1.0).color(MUTED)),
|
||||||
|
Label::new(if selected { "FOCUSED" } else { "" })
|
||||||
|
.style(|s| s.font_size(10.0).color(ACCENT)),
|
||||||
))
|
))
|
||||||
.style(|s| {
|
.style(|s| {
|
||||||
s.width(220.0)
|
s.items_center()
|
||||||
|
.padding_horiz(10.0)
|
||||||
|
.padding_vert(8.0)
|
||||||
|
.font_size(11.0)
|
||||||
|
});
|
||||||
|
|
||||||
|
Stack::vertical((header, target, lines))
|
||||||
|
.style(move |s| {
|
||||||
|
s.width_full()
|
||||||
|
.height_full()
|
||||||
|
.min_width(0.0)
|
||||||
|
.min_height(0.0)
|
||||||
|
.background(if selected { PANEL_ACTIVE } else { PANEL })
|
||||||
|
.border(if selected { 2.0 } else { 1.0 })
|
||||||
|
.border_color(if selected || needs_input {
|
||||||
|
state_color
|
||||||
|
} else {
|
||||||
|
BORDER
|
||||||
|
})
|
||||||
|
.border_radius(5.0)
|
||||||
|
.keyboard_navigable()
|
||||||
|
})
|
||||||
|
.on_event_stop(listener::Click, move |_, _| {
|
||||||
|
dispatch(model, ShellAction::SelectPane(id));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sidebar() -> impl IntoView {
|
||||||
|
let worktrees = Stack::from_iter(WORKSPACES.into_iter().enumerate().map(|(index, name)| {
|
||||||
|
Stack::vertical((
|
||||||
|
Label::new(name),
|
||||||
|
Label::new(if index == 0 { "main · metal" } else { "" })
|
||||||
|
.style(|s| s.font_size(10.0).color(MUTED).margin_top(2.0)),
|
||||||
|
))
|
||||||
|
.style(move |s| {
|
||||||
|
s.width_full()
|
||||||
|
.padding_vert(7.0)
|
||||||
|
.padding_horiz(10.0)
|
||||||
|
.margin_bottom(3.0)
|
||||||
|
.color(if index == 0 { TEXT } else { MUTED })
|
||||||
|
.apply_if(index == 0, |s| s.background(PANEL_ALT).border_radius(5.0))
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
.style(|s| s.flex_col().padding_horiz(8.0));
|
||||||
|
|
||||||
|
Stack::vertical((
|
||||||
|
Label::new("ATTENTION · 1").style(|s| s.font_size(10.0).color(ATTENTION).padding(12.0)),
|
||||||
|
Stack::vertical((
|
||||||
|
Label::new("Claude Code · UI").style(|s| s.color(TEXT)),
|
||||||
|
Label::new("Waiting for a split decision")
|
||||||
|
.style(|s| s.font_size(10.0).color(MUTED).margin_top(3.0)),
|
||||||
|
))
|
||||||
|
.style(|s| {
|
||||||
|
s.margin_horiz(8.0)
|
||||||
|
.margin_bottom(12.0)
|
||||||
|
.padding(10.0)
|
||||||
|
.background(PANEL_ACTIVE)
|
||||||
|
.border(1.0)
|
||||||
|
.border_color(ATTENTION)
|
||||||
|
.border_radius(5.0)
|
||||||
|
}),
|
||||||
|
Label::new("WORKTREES").style(|s| s.font_size(10.0).color(MUTED).padding(12.0)),
|
||||||
|
worktrees,
|
||||||
|
Label::new("RUNTIMES").style(|s| {
|
||||||
|
s.font_size(10.0)
|
||||||
|
.color(MUTED)
|
||||||
|
.padding_horiz(12.0)
|
||||||
|
.padding_vert(9.0)
|
||||||
|
.margin_top(8.0)
|
||||||
|
}),
|
||||||
|
Label::new("● metal · connected").style(|s| s.color(SUCCESS).padding_horiz(12.0)),
|
||||||
|
Label::new("● amd-server · connected")
|
||||||
|
.style(|s| s.color(MUTED).padding_horiz(12.0).margin_top(7.0)),
|
||||||
|
Label::new("○ spark-1 · sleeping")
|
||||||
|
.style(|s| s.color(MUTED).padding_horiz(12.0).margin_top(7.0)),
|
||||||
|
Empty::new().style(|s| s.flex_grow(1.0)),
|
||||||
|
Stack::vertical((
|
||||||
|
Label::new("Buzz · lumbridgecode"),
|
||||||
|
Label::new("connected · signed identity").style(|s| s.color(SUCCESS).margin_top(3.0)),
|
||||||
|
))
|
||||||
|
.style(|s| {
|
||||||
|
s.margin(10.0)
|
||||||
|
.padding(10.0)
|
||||||
|
.font_size(10.0)
|
||||||
|
.color(MUTED)
|
||||||
|
.background(PANEL_ALT)
|
||||||
|
.border_radius(5.0)
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.style(|s| {
|
||||||
|
s.width(248.0)
|
||||||
.height_full()
|
.height_full()
|
||||||
.flex_shrink(0.0)
|
.flex_shrink(0.0)
|
||||||
.background(PANEL)
|
.background(PANEL)
|
||||||
.border_right(1.0)
|
.border_right(1.0)
|
||||||
.border_color(BORDER)
|
.border_color(BORDER_QUIET)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tabs() -> impl IntoView {
|
||||||
|
Stack::horizontal((
|
||||||
|
Label::new("Agent workspace").style(|s| {
|
||||||
|
s.height_full()
|
||||||
|
.items_center()
|
||||||
|
.padding_horiz(12.0)
|
||||||
|
.border_bottom(2.0)
|
||||||
|
.border_color(ACCENT)
|
||||||
|
.color(TEXT)
|
||||||
|
}),
|
||||||
|
Label::new("Architecture.md").style(|s| s.padding_horiz(12.0).color(MUTED)),
|
||||||
|
Label::new("Review").style(|s| s.padding_horiz(12.0).color(MUTED)),
|
||||||
|
Empty::new().style(|s| s.flex_grow(1.0)),
|
||||||
|
Label::new("6 surfaces · 3 remote · 1 waiting")
|
||||||
|
.style(|s| s.font_size(10.0).padding_horiz(12.0).color(MUTED)),
|
||||||
|
))
|
||||||
|
.style(|s| {
|
||||||
|
s.height(38.0)
|
||||||
|
.items_center()
|
||||||
|
.font_size(12.0)
|
||||||
|
.background(PANEL)
|
||||||
|
.border_bottom(1.0)
|
||||||
|
.border_color(BORDER_QUIET)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_palette(query: RwSignal<String>) -> impl IntoView {
|
||||||
|
let input = TextInput::new(query)
|
||||||
|
.placeholder("Type a command…")
|
||||||
|
.style(|s| {
|
||||||
|
s.width_full()
|
||||||
|
.height(44.0)
|
||||||
|
.padding_horiz(12.0)
|
||||||
|
.color(TEXT)
|
||||||
|
.background(PANEL_ALT)
|
||||||
|
.border(0.0)
|
||||||
|
});
|
||||||
|
let input_id = input.id();
|
||||||
|
exec_after(Duration::from_millis(1), move |_| input_id.request_focus());
|
||||||
|
|
||||||
|
let card = Stack::vertical((
|
||||||
|
input,
|
||||||
|
Stack::vertical((
|
||||||
|
Stack::vertical((
|
||||||
|
Label::new("Focus next pane").style(|s| s.color(TEXT)),
|
||||||
|
Label::new("Workspace · navigation")
|
||||||
|
.style(|s| s.font_size(10.0).color(MUTED).margin_top(3.0)),
|
||||||
|
))
|
||||||
|
.style(|s| s.padding(10.0).background(PANEL_ACTIVE).border_radius(5.0)),
|
||||||
|
Label::new("Open attention request").style(|s| s.padding(10.0).color(MUTED)),
|
||||||
|
Label::new("Share selected pane to Buzz…").style(|s| s.padding(10.0).color(MUTED)),
|
||||||
|
))
|
||||||
|
.style(|s| {
|
||||||
|
s.padding(8.0)
|
||||||
|
.border_top(1.0)
|
||||||
|
.border_bottom(1.0)
|
||||||
|
.border_color(BORDER)
|
||||||
|
}),
|
||||||
|
Stack::horizontal((
|
||||||
|
Label::new("Enter to run").style(|s| s.flex_grow(1.0)),
|
||||||
|
Label::new("Esc to close"),
|
||||||
|
))
|
||||||
|
.style(|s| {
|
||||||
|
s.padding_horiz(12.0)
|
||||||
|
.padding_vert(8.0)
|
||||||
|
.font_size(10.0)
|
||||||
|
.color(MUTED)
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.style(|s| {
|
||||||
|
s.width(620.0)
|
||||||
|
.margin_top(92.0)
|
||||||
|
.background(PANEL_ALT)
|
||||||
|
.border(1.0)
|
||||||
|
.border_color(ACCENT)
|
||||||
|
.border_radius(8.0)
|
||||||
|
});
|
||||||
|
|
||||||
|
Container::new(card).style(|s| {
|
||||||
|
s.position(Position::Absolute)
|
||||||
|
.inset(0.0)
|
||||||
|
.items_start()
|
||||||
|
.justify_center()
|
||||||
|
.background(Color::from_rgba8(5, 7, 11, 220))
|
||||||
|
.z_index(50)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(
|
||||||
|
model: RwSignal<ShellModel>,
|
||||||
|
query: RwSignal<String>,
|
||||||
|
KeyboardEvent {
|
||||||
|
code,
|
||||||
|
key,
|
||||||
|
modifiers,
|
||||||
|
..
|
||||||
|
}: &KeyboardEvent,
|
||||||
|
) -> EventPropagation {
|
||||||
|
if *code == Code::KeyK && modifiers.contains(OS_MOD) {
|
||||||
|
query.set(String::new());
|
||||||
|
dispatch(model, ShellAction::OpenCommandPalette);
|
||||||
|
return EventPropagation::Stop;
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.with(|model| model.command_palette().is_open()) {
|
||||||
|
if *key == Key::Named(NamedKey::Escape) {
|
||||||
|
query.set(String::new());
|
||||||
|
dispatch(model, ShellAction::CloseCommandPalette);
|
||||||
|
return EventPropagation::Stop;
|
||||||
|
}
|
||||||
|
return EventPropagation::Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let action = match key {
|
||||||
|
Key::Named(NamedKey::ArrowLeft) => Some(ShellAction::MoveFocus(FocusDirection::Left)),
|
||||||
|
Key::Named(NamedKey::ArrowRight) => Some(ShellAction::MoveFocus(FocusDirection::Right)),
|
||||||
|
Key::Named(NamedKey::ArrowUp) => Some(ShellAction::MoveFocus(FocusDirection::Up)),
|
||||||
|
Key::Named(NamedKey::ArrowDown) => Some(ShellAction::MoveFocus(FocusDirection::Down)),
|
||||||
|
Key::Character(character) if modifiers.is_empty() => match character.as_str() {
|
||||||
|
"h" => Some(ShellAction::MoveFocus(FocusDirection::Left)),
|
||||||
|
"l" => Some(ShellAction::MoveFocus(FocusDirection::Right)),
|
||||||
|
"k" => Some(ShellAction::MoveFocus(FocusDirection::Up)),
|
||||||
|
"j" => Some(ShellAction::MoveFocus(FocusDirection::Down)),
|
||||||
|
"1" => Some(ShellAction::SelectPane(PaneId::CodexRuntime)),
|
||||||
|
"2" => Some(ShellAction::SelectPane(PaneId::ClaudeUi)),
|
||||||
|
"3" => Some(ShellAction::SelectPane(PaneId::PiDocs)),
|
||||||
|
"4" => Some(ShellAction::SelectPane(PaneId::Architecture)),
|
||||||
|
"5" => Some(ShellAction::SelectPane(PaneId::AcpPreview)),
|
||||||
|
"6" => Some(ShellAction::SelectPane(PaneId::RuntimeReview)),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(action) = action {
|
||||||
|
dispatch(model, action);
|
||||||
|
EventPropagation::Stop
|
||||||
|
} else {
|
||||||
|
EventPropagation::Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_view() -> impl IntoView {
|
||||||
|
let model = RwSignal::new(ShellModel::default());
|
||||||
|
let query = RwSignal::new(String::new());
|
||||||
|
let timer_pulse = RwSignal::new(());
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
timer_pulse.track();
|
||||||
|
exec_after(Duration::from_millis(650), move |_| {
|
||||||
|
dispatch(model, ShellAction::SyntheticStreamTick);
|
||||||
|
timer_pulse.set(());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let query = query.get();
|
||||||
|
model.update(|model| {
|
||||||
|
if model.command_palette().is_open() && model.command_palette().query() != query {
|
||||||
|
model.dispatch(ShellAction::SetCommandPaletteQuery(query));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let top_row = Stack::horizontal((
|
let top_row = Stack::horizontal((
|
||||||
pane_card(PANES[0]),
|
pane_card(PaneId::CodexRuntime, model),
|
||||||
pane_card(PANES[1]),
|
pane_card(PaneId::ClaudeUi, model),
|
||||||
pane_card(PANES[2]),
|
pane_card(PaneId::PiDocs, model),
|
||||||
))
|
))
|
||||||
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0).width_full());
|
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0).width_full());
|
||||||
let bottom_row = Stack::horizontal((
|
let bottom_row = Stack::horizontal((
|
||||||
pane_card(PANES[3]),
|
pane_card(PaneId::Architecture, model),
|
||||||
pane_card(PANES[4]),
|
pane_card(PaneId::AcpPreview, model),
|
||||||
pane_card(PANES[5]),
|
pane_card(PaneId::RuntimeReview, model),
|
||||||
))
|
))
|
||||||
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0).width_full());
|
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0).width_full());
|
||||||
let panes = Stack::vertical((top_row, bottom_row)).style(|s| {
|
let grid = Stack::vertical((top_row, bottom_row)).style(|s| {
|
||||||
s.flex_basis(0)
|
s.flex_basis(0)
|
||||||
.flex_grow(1.0)
|
.flex_grow(1.0)
|
||||||
.min_width(0.0)
|
.min_width(0.0)
|
||||||
.height_full()
|
.min_height(0.0)
|
||||||
.padding(4.0)
|
.padding(4.0)
|
||||||
});
|
});
|
||||||
|
|
||||||
let header = Stack::horizontal((
|
let header = Stack::horizontal((
|
||||||
Label::new("Lumbridge").style(|s| s.font_size(18.0).color(TEXT).flex_grow(1.0)),
|
Label::new("Lumbridge").style(|s| s.font_size(18.0).color(TEXT)),
|
||||||
Label::new("UI spike · Floem").style(|s| s.color(MUTED).flex_grow(1.0)),
|
Label::new("Lumbridge Code / main")
|
||||||
Label::new("⌘K Command Palette").style(|s| s.color(ACCENT)),
|
.style(|s| s.color(MUTED).margin_left(12.0).flex_grow(1.0)),
|
||||||
|
Label::new("metal · runtime online")
|
||||||
|
.style(|s| s.font_size(10.0).color(SUCCESS).margin_right(18.0)),
|
||||||
|
Label::new("Ctrl/⌘ K · Commands")
|
||||||
|
.style(|s| s.color(ACCENT).keyboard_navigable())
|
||||||
|
.on_event_stop(listener::Click, move |_, _| {
|
||||||
|
query.set(String::new());
|
||||||
|
dispatch(model, ShellAction::OpenCommandPalette);
|
||||||
|
}),
|
||||||
))
|
))
|
||||||
.style(|s| {
|
.style(|s| {
|
||||||
s.height(46.0)
|
s.height(48.0)
|
||||||
.padding_horiz(14.0)
|
.padding_horiz(14.0)
|
||||||
.items_center()
|
.items_center()
|
||||||
.background(PANEL_ALT)
|
.background(PANEL_ALT)
|
||||||
.border_bottom(1.0)
|
.border_bottom(1.0)
|
||||||
.border_color(BORDER)
|
.border_color(BORDER_QUIET)
|
||||||
});
|
});
|
||||||
|
|
||||||
let body = Stack::horizontal((sidebar, panes))
|
let body = Stack::horizontal((
|
||||||
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0));
|
sidebar(),
|
||||||
|
Stack::vertical((tabs(), grid)).style(|s| {
|
||||||
|
s.flex_basis(0)
|
||||||
|
.flex_grow(1.0)
|
||||||
|
.min_width(0.0)
|
||||||
|
.min_height(0.0)
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0));
|
||||||
|
|
||||||
let footer = Stack::horizontal((
|
let footer = Stack::horizontal((
|
||||||
Label::new(FOOTER_LEFT).style(|s| s.flex_grow(1.0)),
|
Label::derived(move || {
|
||||||
|
model.with(|model| {
|
||||||
|
let counters = model.counters();
|
||||||
|
format!(
|
||||||
|
"rev {} · {} focus moves · {} surface updates · {} lines",
|
||||||
|
model.revision(),
|
||||||
|
counters.focus_moves,
|
||||||
|
counters.surface_updates,
|
||||||
|
counters.terminal_lines_appended
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.style(|s| s.flex_grow(1.0)),
|
||||||
Label::new(FOOTER_CENTER).style(|s| s.flex_grow(1.0)),
|
Label::new(FOOTER_CENTER).style(|s| s.flex_grow(1.0)),
|
||||||
Label::new(FOOTER_RIGHT),
|
Label::new(FOOTER_RIGHT),
|
||||||
))
|
))
|
||||||
.style(|s| {
|
.style(|s| {
|
||||||
s.height(30.0)
|
s.height(32.0)
|
||||||
.padding_horiz(10.0)
|
.padding_horiz(10.0)
|
||||||
.items_center()
|
.items_center()
|
||||||
.font_size(11.0)
|
.font_size(10.0)
|
||||||
.color(MUTED)
|
.color(MUTED)
|
||||||
.background(PANEL_ALT)
|
.background(PANEL_ALT)
|
||||||
.border_top(1.0)
|
.border_top(1.0)
|
||||||
.border_color(BORDER)
|
.border_color(BORDER_QUIET)
|
||||||
});
|
});
|
||||||
|
|
||||||
Stack::vertical((header, body, footer))
|
let palette = dyn_container(
|
||||||
.style(|s| s.width_full().height_full().background(BG))
|
move || model.with(|model| model.command_palette().is_open()),
|
||||||
.window_title(|| "Lumbridge · Floem spike".to_owned())
|
move |open| {
|
||||||
|
if open {
|
||||||
|
command_palette(query).into_any()
|
||||||
|
} else {
|
||||||
|
Empty::new().into_any()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let root = Stack::vertical((header, body, footer, palette))
|
||||||
|
.style(|s| {
|
||||||
|
s.position(Position::Relative)
|
||||||
|
.width_full()
|
||||||
|
.height_full()
|
||||||
|
.background(BG)
|
||||||
|
.color(TEXT)
|
||||||
|
.keyboard_navigable()
|
||||||
|
})
|
||||||
|
.on_event(listener::KeyDown, move |_, event| {
|
||||||
|
handle_key(model, query, event)
|
||||||
|
})
|
||||||
|
.window_title(|| "Lumbridge · Floem workspace".to_owned());
|
||||||
|
|
||||||
|
let root_id = root.id();
|
||||||
|
exec_after(Duration::from_millis(1), move |_| root_id.request_focus());
|
||||||
|
root
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -154,9 +484,9 @@ fn main() {
|
|||||||
|_| app_view(),
|
|_| app_view(),
|
||||||
Some(
|
Some(
|
||||||
WindowConfig::default()
|
WindowConfig::default()
|
||||||
.size(Size::new(1280.0, 800.0))
|
.size(Size::new(1500.0, 960.0))
|
||||||
.min_size(Size::new(900.0, 600.0))
|
.min_size(Size::new(900.0, 600.0))
|
||||||
.title("Lumbridge · Floem spike"),
|
.title("Lumbridge · Floem workspace"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+627
-86
@@ -1,85 +1,445 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px,
|
App, Application, Bounds, Context, FocusHandle, KeyBinding, KeyDownEvent, Window, WindowBounds,
|
||||||
rgb, size,
|
WindowOptions, actions, div, prelude::*, px, rgb, size,
|
||||||
};
|
};
|
||||||
use lumbridge_spike_model::{
|
use lumbridge_spike_model::{
|
||||||
FOOTER_CENTER, FOOTER_LEFT, FOOTER_RIGHT, PANES, PaneFixture, WORKSPACES,
|
ActionOutcome, FOOTER_RIGHT, FocusDirection, PaneId, PaneState, ShellAction, ShellModel,
|
||||||
|
SurfaceKind, WORKSPACES,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BG: u32 = 0x0c0e13;
|
const BG: u32 = 0x090c12;
|
||||||
const PANEL: u32 = 0x121722;
|
const PANEL: u32 = 0x101620;
|
||||||
const PANEL_ALT: u32 = 0x171d29;
|
const PANEL_ALT: u32 = 0x151d29;
|
||||||
const BORDER: u32 = 0x293244;
|
const PANEL_ACTIVE: u32 = 0x182334;
|
||||||
const TEXT: u32 = 0xd9e2f2;
|
const BORDER: u32 = 0x263246;
|
||||||
const MUTED: u32 = 0x7f8ba3;
|
const BORDER_QUIET: u32 = 0x1c2636;
|
||||||
const ACCENT: u32 = 0x77bdfb;
|
const TEXT: u32 = 0xdbe5f4;
|
||||||
|
const MUTED: u32 = 0x8290a8;
|
||||||
|
const ACCENT: u32 = 0x68b5f8;
|
||||||
|
const ATTENTION: u32 = 0xf1b96a;
|
||||||
|
const SUCCESS: u32 = 0x70d6a8;
|
||||||
|
const TIMING_SAMPLE_LIMIT: usize = 256;
|
||||||
|
|
||||||
struct LumbridgeShell;
|
actions!(
|
||||||
|
lumbridge,
|
||||||
|
[
|
||||||
|
FocusLeft,
|
||||||
|
FocusRight,
|
||||||
|
FocusUp,
|
||||||
|
FocusDown,
|
||||||
|
OpenPalette,
|
||||||
|
ClosePalette,
|
||||||
|
SelectPane1,
|
||||||
|
SelectPane2,
|
||||||
|
SelectPane3,
|
||||||
|
SelectPane4,
|
||||||
|
SelectPane5,
|
||||||
|
SelectPane6,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
fn pane_card(pane: &PaneFixture) -> impl IntoElement {
|
struct LumbridgeShell {
|
||||||
div()
|
model: ShellModel,
|
||||||
.flex()
|
timing: RenderTiming,
|
||||||
.flex_col()
|
root_focus: FocusHandle,
|
||||||
.min_w_0()
|
pane_focus: [FocusHandle; 6],
|
||||||
.min_h_0()
|
}
|
||||||
.overflow_hidden()
|
|
||||||
.bg(rgb(PANEL))
|
#[derive(Default)]
|
||||||
.border_1()
|
struct RenderTiming {
|
||||||
.border_color(rgb(BORDER))
|
pending_dispatch: Option<Instant>,
|
||||||
.rounded_md()
|
dispatch_to_element_micros: VecDeque<u128>,
|
||||||
.child(
|
}
|
||||||
div()
|
|
||||||
.flex()
|
impl RenderTiming {
|
||||||
.items_center()
|
fn mark_dispatch(&mut self) {
|
||||||
.justify_between()
|
self.pending_dispatch = Some(Instant::now());
|
||||||
.px_3()
|
}
|
||||||
.h(px(34.0))
|
|
||||||
.bg(rgb(PANEL_ALT))
|
fn observe_element_build(&mut self) {
|
||||||
.border_b_1()
|
let Some(started) = self.pending_dispatch.take() else {
|
||||||
.border_color(rgb(BORDER))
|
return;
|
||||||
.text_sm()
|
};
|
||||||
.text_color(rgb(TEXT))
|
if self.dispatch_to_element_micros.len() == TIMING_SAMPLE_LIMIT {
|
||||||
.child(pane.title)
|
self.dispatch_to_element_micros.pop_front();
|
||||||
.child(div().text_xs().text_color(rgb(ACCENT)).child(pane.badge)),
|
}
|
||||||
)
|
self.dispatch_to_element_micros
|
||||||
.child(
|
.push_back(started.elapsed().as_micros());
|
||||||
div()
|
}
|
||||||
.px_3()
|
|
||||||
.py_2()
|
fn summary(&self) -> String {
|
||||||
.text_xs()
|
if self.dispatch_to_element_micros.is_empty() {
|
||||||
.text_color(rgb(MUTED))
|
return "dispatch→element collecting…".to_owned();
|
||||||
.child(pane.target),
|
}
|
||||||
)
|
let mut samples = self
|
||||||
.child(
|
.dispatch_to_element_micros
|
||||||
div()
|
.iter()
|
||||||
.flex()
|
.copied()
|
||||||
.flex_col()
|
.collect::<Vec<_>>();
|
||||||
.gap_1()
|
samples.sort_unstable();
|
||||||
.px_3()
|
let percentile = |percent: usize| {
|
||||||
.pb_3()
|
let index = ((samples.len() - 1) * percent) / 100;
|
||||||
.text_sm()
|
samples[index]
|
||||||
.text_color(rgb(TEXT))
|
};
|
||||||
.children(pane.lines.into_iter()),
|
format!(
|
||||||
|
"dispatch→element p50 {}µs · p95 {}µs · n{}",
|
||||||
|
percentile(50),
|
||||||
|
percentile(95),
|
||||||
|
samples.len()
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LumbridgeShell {
|
||||||
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let pane_focus = std::array::from_fn(|index| {
|
||||||
|
cx.focus_handle()
|
||||||
|
.tab_index(index as isize + 1)
|
||||||
|
.tab_stop(true)
|
||||||
|
});
|
||||||
|
let root_focus = cx.focus_handle();
|
||||||
|
window.focus(&pane_focus[0]);
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
loop {
|
||||||
|
cx.background_executor()
|
||||||
|
.timer(Duration::from_millis(650))
|
||||||
|
.await;
|
||||||
|
if this
|
||||||
|
.update(cx, |shell, cx| {
|
||||||
|
shell.dispatch(ShellAction::SyntheticStreamTick);
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
model: ShellModel::default(),
|
||||||
|
timing: RenderTiming::default(),
|
||||||
|
root_focus,
|
||||||
|
pane_focus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch(&mut self, action: ShellAction) -> ActionOutcome {
|
||||||
|
self.timing.mark_dispatch();
|
||||||
|
self.model.dispatch(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_pane(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.dispatch(ShellAction::SelectPane(pane));
|
||||||
|
window.focus(&self.pane_focus[pane.index()]);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_focus(
|
||||||
|
&mut self,
|
||||||
|
direction: FocusDirection,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let outcome = self.dispatch(ShellAction::MoveFocus(direction));
|
||||||
|
window.focus(&self.pane_focus[outcome.selected_pane.index()]);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focus_left(&mut self, _: &FocusLeft, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.move_focus(FocusDirection::Left, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focus_right(&mut self, _: &FocusRight, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.move_focus(FocusDirection::Right, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focus_up(&mut self, _: &FocusUp, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.move_focus(FocusDirection::Up, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focus_down(&mut self, _: &FocusDown, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.move_focus(FocusDirection::Down, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_palette(&mut self, _: &OpenPalette, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.dispatch(ShellAction::OpenCommandPalette);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_palette(&mut self, _: &ClosePalette, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.dispatch(ShellAction::CloseCommandPalette);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if !self.model.command_palette().is_open() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut query = self.model.command_palette().query().to_owned();
|
||||||
|
match event.keystroke.key.as_str() {
|
||||||
|
"backspace" => {
|
||||||
|
query.pop();
|
||||||
|
}
|
||||||
|
"enter" => {
|
||||||
|
self.dispatch(ShellAction::CloseCommandPalette);
|
||||||
|
cx.notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some(character) = event.keystroke.key_char.as_deref()
|
||||||
|
&& !event.keystroke.modifiers.control
|
||||||
|
&& !event.keystroke.modifiers.platform
|
||||||
|
{
|
||||||
|
query.push_str(character);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.dispatch(ShellAction::SetCommandPaletteQuery(query));
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pane_card(
|
||||||
|
pane: &PaneState,
|
||||||
|
selected: bool,
|
||||||
|
focus: FocusHandle,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> gpui::AnyElement {
|
||||||
|
let id = pane.id();
|
||||||
|
let needs_input = pane.needs_input();
|
||||||
|
let label = match pane.kind() {
|
||||||
|
SurfaceKind::Terminal => pane.status().label(),
|
||||||
|
_ => pane.fixture().badge,
|
||||||
|
};
|
||||||
|
let state_color = if needs_input {
|
||||||
|
ATTENTION
|
||||||
|
} else if matches!(pane.kind(), SurfaceKind::Terminal) {
|
||||||
|
SUCCESS
|
||||||
|
} else {
|
||||||
|
ACCENT
|
||||||
|
};
|
||||||
|
let line_start = pane.lines().len().saturating_sub(12);
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id(("pane", id.index()))
|
||||||
|
.track_focus(&focus)
|
||||||
|
.tab_index(id.index() as isize + 1)
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.min_w_0()
|
||||||
|
.min_h_0()
|
||||||
|
.overflow_hidden()
|
||||||
|
.bg(rgb(if selected { PANEL_ACTIVE } else { PANEL }))
|
||||||
|
.border_1()
|
||||||
|
.border_color(rgb(if selected || needs_input {
|
||||||
|
state_color
|
||||||
|
} else {
|
||||||
|
BORDER
|
||||||
|
}))
|
||||||
|
.rounded(px(5.0))
|
||||||
|
.when(selected, |view| view.border_2())
|
||||||
|
.on_click(cx.listener(move |shell, _, window, cx| {
|
||||||
|
shell.select_pane(id, window, cx);
|
||||||
|
}))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_between()
|
||||||
|
.px_3()
|
||||||
|
.h(px(36.0))
|
||||||
|
.flex_none()
|
||||||
|
.bg(rgb(if selected { PANEL_ACTIVE } else { PANEL_ALT }))
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(rgb(BORDER_QUIET))
|
||||||
|
.text_sm()
|
||||||
|
.text_color(rgb(TEXT))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child(format!("{}", id.index() + 1)),
|
||||||
|
)
|
||||||
|
.child(pane.fixture().title),
|
||||||
|
)
|
||||||
|
.child(div().text_xs().text_color(rgb(state_color)).child(label)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_between()
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child(pane.fixture().target)
|
||||||
|
.when(selected, |view| view.child("FOCUSED")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_1()
|
||||||
|
.px_3()
|
||||||
|
.pb_3()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(rgb(TEXT))
|
||||||
|
.children(pane.lines()[line_start..].iter().cloned()),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_palette(&self) -> impl IntoElement {
|
||||||
|
let query = self.model.command_palette().query();
|
||||||
|
let prompt = if query.is_empty() {
|
||||||
|
"Type a command…".to_owned()
|
||||||
|
} else {
|
||||||
|
query.to_owned()
|
||||||
|
};
|
||||||
|
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.inset_0()
|
||||||
|
.flex()
|
||||||
|
.justify_center()
|
||||||
|
.items_start()
|
||||||
|
.pt(px(92.0))
|
||||||
|
.bg(gpui::black().opacity(0.72))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(px(620.0))
|
||||||
|
.overflow_hidden()
|
||||||
|
.bg(rgb(PANEL_ALT))
|
||||||
|
.border_1()
|
||||||
|
.border_color(rgb(ACCENT))
|
||||||
|
.rounded(px(8.0))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.px_4()
|
||||||
|
.py_3()
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(rgb(BORDER))
|
||||||
|
.text_color(rgb(if query.is_empty() { MUTED } else { TEXT }))
|
||||||
|
.child(prompt),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.p_2()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.rounded(px(5.0))
|
||||||
|
.bg(rgb(PANEL_ACTIVE))
|
||||||
|
.child("Focus next pane")
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.mt_1()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child("Workspace · navigation"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child("Open attention request"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child("Share selected pane to Buzz…"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.justify_between()
|
||||||
|
.px_4()
|
||||||
|
.py_2()
|
||||||
|
.border_t_1()
|
||||||
|
.border_color(rgb(BORDER))
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child("Enter to run")
|
||||||
|
.child("Esc to close"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for LumbridgeShell {
|
impl Render for LumbridgeShell {
|
||||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let attention = self.model.pane(PaneId::ClaudeUi);
|
||||||
let sidebar = div()
|
let sidebar = div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.w(px(220.0))
|
.w(px(248.0))
|
||||||
.flex_none()
|
.flex_none()
|
||||||
.bg(rgb(PANEL))
|
.bg(rgb(PANEL))
|
||||||
.border_r_1()
|
.border_r_1()
|
||||||
.border_color(rgb(BORDER))
|
.border_color(rgb(BORDER_QUIET))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.px_4()
|
.px_4()
|
||||||
.py_3()
|
.pt_4()
|
||||||
.text_sm()
|
.pb_2()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(ATTENTION))
|
||||||
|
.child("ATTENTION · 1"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.mx_2()
|
||||||
|
.mb_3()
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.rounded(px(5.0))
|
||||||
|
.bg(rgb(PANEL_ACTIVE))
|
||||||
|
.border_1()
|
||||||
|
.border_color(rgb(ATTENTION))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(rgb(TEXT))
|
||||||
|
.child(attention.fixture().title),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.mt_1()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child("Waiting for a split decision"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.px_4()
|
||||||
|
.py_2()
|
||||||
|
.text_xs()
|
||||||
.text_color(rgb(MUTED))
|
.text_color(rgb(MUTED))
|
||||||
.child("WORKSPACES"),
|
.child("WORKTREES"),
|
||||||
)
|
)
|
||||||
.children(WORKSPACES.into_iter().enumerate().map(|(index, name)| {
|
.children(WORKSPACES.into_iter().enumerate().map(|(index, name)| {
|
||||||
div()
|
div()
|
||||||
@@ -87,30 +447,130 @@ impl Render for LumbridgeShell {
|
|||||||
.mb_1()
|
.mb_1()
|
||||||
.px_3()
|
.px_3()
|
||||||
.py_2()
|
.py_2()
|
||||||
.rounded_md()
|
.rounded(px(5.0))
|
||||||
.when(index == 0, |view| {
|
.when(index == 0, |view| {
|
||||||
view.bg(rgb(PANEL_ALT)).text_color(rgb(TEXT))
|
view.bg(rgb(PANEL_ALT)).text_color(rgb(TEXT))
|
||||||
})
|
})
|
||||||
.when(index != 0, |view| view.text_color(rgb(MUTED)))
|
.when(index != 0, |view| view.text_color(rgb(MUTED)))
|
||||||
.child(name)
|
.child(name)
|
||||||
|
.when(index == 0, |view| {
|
||||||
|
view.child(
|
||||||
|
div()
|
||||||
|
.mt_1()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child("main · metal"),
|
||||||
|
)
|
||||||
|
})
|
||||||
}))
|
}))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.mt_4()
|
.mt_3()
|
||||||
.px_4()
|
.px_4()
|
||||||
|
.py_2()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(rgb(MUTED))
|
.text_color(rgb(MUTED))
|
||||||
.child("HOSTS")
|
.child("RUNTIMES"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.px_4()
|
||||||
|
.py_1()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(rgb(SUCCESS))
|
||||||
|
.child("metal · connected"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.px_4()
|
||||||
|
.py_1()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child("amd-server · connected"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.px_4()
|
||||||
|
.py_1()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child("spark-1 · sleeping"),
|
||||||
|
)
|
||||||
|
.child(div().flex_1())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.m_3()
|
||||||
|
.p_3()
|
||||||
|
.rounded(px(5.0))
|
||||||
|
.bg(rgb(PANEL_ALT))
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child("Buzz · lumbridgecode")
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.mt_2()
|
.mt_1()
|
||||||
.text_color(rgb(ACCENT))
|
.text_color(rgb(SUCCESS))
|
||||||
.child("● metal · connected"),
|
.child("connected · signed identity"),
|
||||||
)
|
),
|
||||||
.child(div().mt_2().child("● amd-server · connected"))
|
|
||||||
.child(div().mt_2().child("○ spark-1 · sleeping")),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let tabs = div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.h(px(38.0))
|
||||||
|
.flex_none()
|
||||||
|
.px_2()
|
||||||
|
.gap_1()
|
||||||
|
.bg(rgb(PANEL))
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(rgb(BORDER_QUIET))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.h_full()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.px_3()
|
||||||
|
.border_b_2()
|
||||||
|
.border_color(rgb(ACCENT))
|
||||||
|
.text_sm()
|
||||||
|
.child("Agent workspace"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.px_3()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child("Architecture.md"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.px_3()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child("Review"),
|
||||||
|
)
|
||||||
|
.child(div().flex_1())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.px_3()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child("6 surfaces · 3 remote · 1 waiting"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let pane_cards = self
|
||||||
|
.model
|
||||||
|
.panes()
|
||||||
|
.iter()
|
||||||
|
.map(|pane| {
|
||||||
|
Self::pane_card(
|
||||||
|
pane,
|
||||||
|
self.model.selected_pane() == pane.id(),
|
||||||
|
self.pane_focus[pane.id().index()].clone(),
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let grid = div()
|
let grid = div()
|
||||||
.grid()
|
.grid()
|
||||||
.grid_cols(3)
|
.grid_cols(3)
|
||||||
@@ -118,9 +578,48 @@ impl Render for LumbridgeShell {
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.p_2()
|
.p_2()
|
||||||
.size_full()
|
.size_full()
|
||||||
.children(PANES.iter().map(pane_card));
|
.children(pane_cards);
|
||||||
|
|
||||||
div()
|
let counters = self.model.counters();
|
||||||
|
let footer_left = format!(
|
||||||
|
"rev {} · {} focus moves · {} surface updates · {} lines",
|
||||||
|
self.model.revision(),
|
||||||
|
counters.focus_moves,
|
||||||
|
counters.surface_updates,
|
||||||
|
counters.terminal_lines_appended
|
||||||
|
);
|
||||||
|
let timing = self.timing.summary();
|
||||||
|
|
||||||
|
let root = div()
|
||||||
|
.id("lumbridge-shell")
|
||||||
|
.relative()
|
||||||
|
.track_focus(&self.root_focus)
|
||||||
|
.key_context("LumbridgeShell")
|
||||||
|
.on_action(cx.listener(Self::focus_left))
|
||||||
|
.on_action(cx.listener(Self::focus_right))
|
||||||
|
.on_action(cx.listener(Self::focus_up))
|
||||||
|
.on_action(cx.listener(Self::focus_down))
|
||||||
|
.on_action(cx.listener(Self::open_palette))
|
||||||
|
.on_action(cx.listener(Self::close_palette))
|
||||||
|
.on_action(cx.listener(|shell, _: &SelectPane1, window, cx| {
|
||||||
|
shell.select_pane(PaneId::CodexRuntime, window, cx);
|
||||||
|
}))
|
||||||
|
.on_action(cx.listener(|shell, _: &SelectPane2, window, cx| {
|
||||||
|
shell.select_pane(PaneId::ClaudeUi, window, cx);
|
||||||
|
}))
|
||||||
|
.on_action(cx.listener(|shell, _: &SelectPane3, window, cx| {
|
||||||
|
shell.select_pane(PaneId::PiDocs, window, cx);
|
||||||
|
}))
|
||||||
|
.on_action(cx.listener(|shell, _: &SelectPane4, window, cx| {
|
||||||
|
shell.select_pane(PaneId::Architecture, window, cx);
|
||||||
|
}))
|
||||||
|
.on_action(cx.listener(|shell, _: &SelectPane5, window, cx| {
|
||||||
|
shell.select_pane(PaneId::AcpPreview, window, cx);
|
||||||
|
}))
|
||||||
|
.on_action(cx.listener(|shell, _: &SelectPane6, window, cx| {
|
||||||
|
shell.select_pane(PaneId::RuntimeReview, window, cx);
|
||||||
|
}))
|
||||||
|
.on_key_down(cx.listener(Self::on_key_down))
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -130,63 +629,105 @@ impl Render for LumbridgeShell {
|
|||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_between()
|
.h(px(48.0))
|
||||||
.h(px(46.0))
|
|
||||||
.flex_none()
|
.flex_none()
|
||||||
.px_4()
|
.px_4()
|
||||||
.bg(rgb(PANEL_ALT))
|
.bg(rgb(PANEL_ALT))
|
||||||
.border_b_1()
|
.border_b_1()
|
||||||
.border_color(rgb(BORDER))
|
.border_color(rgb(BORDER_QUIET))
|
||||||
.child(div().text_lg().child("Lumbridge"))
|
.child(div().text_lg().child("Lumbridge"))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
|
.ml_3()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.text_color(rgb(MUTED))
|
.text_color(rgb(MUTED))
|
||||||
.child("UI spike · GPUI"),
|
.child("Lumbridge Code / main"),
|
||||||
|
)
|
||||||
|
.child(div().flex_1())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.mr_4()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(SUCCESS))
|
||||||
|
.child("metal · runtime online"),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.text_color(rgb(ACCENT))
|
.text_color(rgb(ACCENT))
|
||||||
.child("⌘K Command Palette"),
|
.child("Ctrl/⌘ K · Commands"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(div().flex().flex_1().min_h_0().child(sidebar).child(grid))
|
.child(
|
||||||
|
div().flex().flex_1().min_h_0().child(sidebar).child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.child(tabs)
|
||||||
|
.child(grid),
|
||||||
|
),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.h(px(30.0))
|
.h(px(32.0))
|
||||||
.flex_none()
|
.flex_none()
|
||||||
.px_3()
|
.px_3()
|
||||||
.bg(rgb(PANEL_ALT))
|
.bg(rgb(PANEL_ALT))
|
||||||
.border_t_1()
|
.border_t_1()
|
||||||
.border_color(rgb(BORDER))
|
.border_color(rgb(BORDER_QUIET))
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(rgb(MUTED))
|
.text_color(rgb(MUTED))
|
||||||
.child(FOOTER_LEFT)
|
.child(footer_left)
|
||||||
.child(FOOTER_CENTER)
|
.child(timing)
|
||||||
.child(FOOTER_RIGHT),
|
.child(FOOTER_RIGHT),
|
||||||
)
|
)
|
||||||
|
.when(self.model.command_palette().is_open(), |view| {
|
||||||
|
view.child(self.command_palette())
|
||||||
|
});
|
||||||
|
self.timing.observe_element_build();
|
||||||
|
root
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
Application::new().run(|cx: &mut App| {
|
Application::new().run(|cx: &mut App| {
|
||||||
let bounds = Bounds::centered(None, size(px(1280.0), px(800.0)), cx);
|
cx.bind_keys([
|
||||||
|
KeyBinding::new("left", FocusLeft, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("right", FocusRight, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("up", FocusUp, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("down", FocusDown, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("h", FocusLeft, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("l", FocusRight, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("k", FocusUp, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("j", FocusDown, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("secondary-k", OpenPalette, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("escape", ClosePalette, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("1", SelectPane1, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("2", SelectPane2, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("3", SelectPane3, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("4", SelectPane4, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("5", SelectPane5, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("6", SelectPane6, Some("LumbridgeShell")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let bounds = Bounds::centered(None, size(px(1500.0), px(960.0)), cx);
|
||||||
cx.open_window(
|
cx.open_window(
|
||||||
WindowOptions {
|
WindowOptions {
|
||||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||||
titlebar: Some(gpui::TitlebarOptions {
|
titlebar: Some(gpui::TitlebarOptions {
|
||||||
title: Some("Lumbridge · GPUI spike".into()),
|
title: Some("Lumbridge · GPUI workspace".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
|_, cx| cx.new(|_| LumbridgeShell),
|
|window, cx| cx.new(|cx| LumbridgeShell::new(window, cx)),
|
||||||
)
|
)
|
||||||
.expect("GPUI spike window should open");
|
.expect("GPUI workspace window should open");
|
||||||
cx.activate(true);
|
cx.activate(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,70 @@
|
|||||||
|
//! Deterministic state and fixtures shared by the GPUI and Floem spikes.
|
||||||
|
//!
|
||||||
|
//! UI candidates translate framework input into [`ShellAction`] and render the
|
||||||
|
//! resulting [`ShellModel`]. This makes their interaction traces comparable.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
pub const GRID_ROWS: usize = 2;
|
||||||
|
pub const GRID_COLUMNS: usize = 3;
|
||||||
|
pub const DEFAULT_TERMINAL_LINE_LIMIT: usize = 64;
|
||||||
|
|
||||||
|
/// Stable identity used in traces and accessibility identifiers.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub enum PaneId {
|
||||||
|
CodexRuntime = 0,
|
||||||
|
ClaudeUi = 1,
|
||||||
|
PiDocs = 2,
|
||||||
|
Architecture = 3,
|
||||||
|
AcpPreview = 4,
|
||||||
|
RuntimeReview = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PaneId {
|
||||||
|
pub const ALL: [Self; GRID_ROWS * GRID_COLUMNS] = [
|
||||||
|
Self::CodexRuntime,
|
||||||
|
Self::ClaudeUi,
|
||||||
|
Self::PiDocs,
|
||||||
|
Self::Architecture,
|
||||||
|
Self::AcpPreview,
|
||||||
|
Self::RuntimeReview,
|
||||||
|
];
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn index(self) -> usize {
|
||||||
|
self as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn stable_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::CodexRuntime => "codex-runtime",
|
||||||
|
Self::ClaudeUi => "claude-ui",
|
||||||
|
Self::PiDocs => "pi-docs",
|
||||||
|
Self::Architecture => "architecture",
|
||||||
|
Self::AcpPreview => "acp-preview",
|
||||||
|
Self::RuntimeReview => "runtime-review",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn row(self) -> usize {
|
||||||
|
self.index() / GRID_COLUMNS
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn column(self) -> usize {
|
||||||
|
self.index() % GRID_COLUMNS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for PaneId {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(self.stable_name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum SurfaceKind {
|
pub enum SurfaceKind {
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -6,21 +73,45 @@ pub enum SurfaceKind {
|
|||||||
Review,
|
Review,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PaneStatus {
|
||||||
|
Working,
|
||||||
|
NeedsInput,
|
||||||
|
Streaming,
|
||||||
|
Ready,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PaneStatus {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Working => "WORKING",
|
||||||
|
Self::NeedsInput => "NEEDS INPUT",
|
||||||
|
Self::Streaming => "STREAMING",
|
||||||
|
Self::Ready => "READY",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct PaneFixture {
|
pub struct PaneFixture {
|
||||||
|
pub id: PaneId,
|
||||||
pub title: &'static str,
|
pub title: &'static str,
|
||||||
pub badge: &'static str,
|
pub badge: &'static str,
|
||||||
pub target: &'static str,
|
pub target: &'static str,
|
||||||
pub kind: SurfaceKind,
|
pub kind: SurfaceKind,
|
||||||
|
pub status: PaneStatus,
|
||||||
pub lines: [&'static str; 4],
|
pub lines: [&'static str; 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const PANES: [PaneFixture; 6] = [
|
pub const PANES: [PaneFixture; 6] = [
|
||||||
PaneFixture {
|
PaneFixture {
|
||||||
|
id: PaneId::CodexRuntime,
|
||||||
title: "Codex · runtime",
|
title: "Codex · runtime",
|
||||||
badge: "WORKING",
|
badge: "WORKING",
|
||||||
target: "metal · Tailscale SSH",
|
target: "metal · Tailscale SSH",
|
||||||
kind: SurfaceKind::Terminal,
|
kind: SurfaceKind::Terminal,
|
||||||
|
status: PaneStatus::Working,
|
||||||
lines: [
|
lines: [
|
||||||
"$ cargo nextest run -p lumbridge-runtime",
|
"$ cargo nextest run -p lumbridge-runtime",
|
||||||
"PASS remote::reconnect_replays_output",
|
"PASS remote::reconnect_replays_output",
|
||||||
@@ -29,10 +120,12 @@ pub const PANES: [PaneFixture; 6] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
PaneFixture {
|
PaneFixture {
|
||||||
|
id: PaneId::ClaudeUi,
|
||||||
title: "Claude Code · UI",
|
title: "Claude Code · UI",
|
||||||
badge: "NEEDS INPUT",
|
badge: "NEEDS INPUT",
|
||||||
target: "MacBook Air · local",
|
target: "MacBook Air · local",
|
||||||
kind: SurfaceKind::Terminal,
|
kind: SurfaceKind::Terminal,
|
||||||
|
status: PaneStatus::NeedsInput,
|
||||||
lines: [
|
lines: [
|
||||||
"$ bacon clippy",
|
"$ bacon clippy",
|
||||||
"finished in 0.42s",
|
"finished in 0.42s",
|
||||||
@@ -41,10 +134,12 @@ pub const PANES: [PaneFixture; 6] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
PaneFixture {
|
PaneFixture {
|
||||||
|
id: PaneId::PiDocs,
|
||||||
title: "Pi · docs",
|
title: "Pi · docs",
|
||||||
badge: "STREAMING",
|
badge: "STREAMING",
|
||||||
target: "amd-server · OpenSSH",
|
target: "amd-server · OpenSSH",
|
||||||
kind: SurfaceKind::Terminal,
|
kind: SurfaceKind::Terminal,
|
||||||
|
status: PaneStatus::Streaming,
|
||||||
lines: [
|
lines: [
|
||||||
"$ cargo watch --why",
|
"$ cargo watch --why",
|
||||||
"ACP session resumed at event 1842",
|
"ACP session resumed at event 1842",
|
||||||
@@ -53,10 +148,12 @@ pub const PANES: [PaneFixture; 6] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
PaneFixture {
|
PaneFixture {
|
||||||
|
id: PaneId::Architecture,
|
||||||
title: "Architecture.md",
|
title: "Architecture.md",
|
||||||
badge: "MARKDOWN",
|
badge: "MARKDOWN",
|
||||||
target: "lumbridge-code · worktree",
|
target: "lumbridge-code · worktree",
|
||||||
kind: SurfaceKind::Markdown,
|
kind: SurfaceKind::Markdown,
|
||||||
|
status: PaneStatus::Ready,
|
||||||
lines: [
|
lines: [
|
||||||
"## Remote session path",
|
"## Remote session path",
|
||||||
"The destination runtime owns the PTY.",
|
"The destination runtime owns the PTY.",
|
||||||
@@ -65,10 +162,12 @@ pub const PANES: [PaneFixture; 6] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
PaneFixture {
|
PaneFixture {
|
||||||
|
id: PaneId::AcpPreview,
|
||||||
title: "Preview · ACP docs",
|
title: "Preview · ACP docs",
|
||||||
badge: "BROWSER",
|
badge: "BROWSER",
|
||||||
target: "isolated system web engine",
|
target: "isolated system web engine",
|
||||||
kind: SurfaceKind::Browser,
|
kind: SurfaceKind::Browser,
|
||||||
|
status: PaneStatus::Ready,
|
||||||
lines: [
|
lines: [
|
||||||
"https://agentclientprotocol.com",
|
"https://agentclientprotocol.com",
|
||||||
"Content process: sandboxed",
|
"Content process: sandboxed",
|
||||||
@@ -77,10 +176,12 @@ pub const PANES: [PaneFixture; 6] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
PaneFixture {
|
PaneFixture {
|
||||||
|
id: PaneId::RuntimeReview,
|
||||||
title: "Changes · lumbridge-runtime",
|
title: "Changes · lumbridge-runtime",
|
||||||
badge: "REVIEW",
|
badge: "REVIEW",
|
||||||
target: "metal · worktree remote-runtime",
|
target: "metal · worktree remote-runtime",
|
||||||
kind: SurfaceKind::Review,
|
kind: SurfaceKind::Review,
|
||||||
|
status: PaneStatus::Ready,
|
||||||
lines: [
|
lines: [
|
||||||
"+ 184 remote transport",
|
"+ 184 remote transport",
|
||||||
"+ 96 SQLite migrations",
|
"+ 96 SQLite migrations",
|
||||||
@@ -97,26 +198,538 @@ pub const WORKSPACES: [&str; 5] = [
|
|||||||
"ACP adapters",
|
"ACP adapters",
|
||||||
"Usage telemetry",
|
"Usage telemetry",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const FOOTER_LEFT: &str = "6 panes · 3 remote · 1 needs input";
|
pub const FOOTER_LEFT: &str = "6 panes · 3 remote · 1 needs input";
|
||||||
pub const FOOTER_CENTER: &str = "Codex · ChatGPT subscription · 62% window remaining";
|
pub const FOOTER_CENTER: &str = "Codex · ChatGPT subscription · 62% window remaining";
|
||||||
pub const FOOTER_RIGHT: &str = "burn 8.4%/hr · resets in 2h 14m";
|
pub const FOOTER_RIGHT: &str = "burn 8.4%/hr · resets in 2h 14m";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PaneState {
|
||||||
|
fixture: PaneFixture,
|
||||||
|
status: PaneStatus,
|
||||||
|
status_before_input: Option<PaneStatus>,
|
||||||
|
lines: Vec<String>,
|
||||||
|
synthetic_line_sequence: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PaneState {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn id(&self) -> PaneId {
|
||||||
|
self.fixture.id
|
||||||
|
}
|
||||||
|
#[must_use]
|
||||||
|
pub const fn fixture(&self) -> &PaneFixture {
|
||||||
|
&self.fixture
|
||||||
|
}
|
||||||
|
#[must_use]
|
||||||
|
pub const fn kind(&self) -> SurfaceKind {
|
||||||
|
self.fixture.kind
|
||||||
|
}
|
||||||
|
#[must_use]
|
||||||
|
pub const fn status(&self) -> PaneStatus {
|
||||||
|
self.status
|
||||||
|
}
|
||||||
|
#[must_use]
|
||||||
|
pub const fn needs_input(&self) -> bool {
|
||||||
|
matches!(self.status, PaneStatus::NeedsInput)
|
||||||
|
}
|
||||||
|
#[must_use]
|
||||||
|
pub fn lines(&self) -> &[String] {
|
||||||
|
&self.lines
|
||||||
|
}
|
||||||
|
#[must_use]
|
||||||
|
pub const fn synthetic_line_sequence(&self) -> u64 {
|
||||||
|
self.synthetic_line_sequence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct CommandPaletteState {
|
||||||
|
open: bool,
|
||||||
|
query: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandPaletteState {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_open(&self) -> bool {
|
||||||
|
self.open
|
||||||
|
}
|
||||||
|
#[must_use]
|
||||||
|
pub fn query(&self) -> &str {
|
||||||
|
&self.query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum FocusDirection {
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ShellAction {
|
||||||
|
MoveFocus(FocusDirection),
|
||||||
|
SelectPane(PaneId),
|
||||||
|
SetNeedsInput { pane: PaneId, needs_input: bool },
|
||||||
|
OpenCommandPalette,
|
||||||
|
CloseCommandPalette,
|
||||||
|
SetCommandPaletteQuery(String),
|
||||||
|
SyntheticStreamTick,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counters prove both candidates replayed the same workload. Framework
|
||||||
|
/// adapters may use the event sequence and revision for explicitly labelled
|
||||||
|
/// timing stages; these counters do not imply pixels reached the display.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct MeasurementCounters {
|
||||||
|
pub events_dispatched: u64,
|
||||||
|
pub state_changes: u64,
|
||||||
|
pub focus_moves: u64,
|
||||||
|
pub blocked_focus_moves: u64,
|
||||||
|
pub direct_selections: u64,
|
||||||
|
pub needs_input_changes: u64,
|
||||||
|
pub palette_changes: u64,
|
||||||
|
pub synthetic_ticks: u64,
|
||||||
|
/// Total surfaces invalidated by the deterministic six-surface workload.
|
||||||
|
pub surface_updates: u64,
|
||||||
|
pub terminal_lines_appended: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ActionOutcome {
|
||||||
|
pub event_sequence: u64,
|
||||||
|
pub revision: u64,
|
||||||
|
pub changed: bool,
|
||||||
|
pub selected_pane: PaneId,
|
||||||
|
pub terminal_lines_appended: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct InvalidTerminalLineLimit;
|
||||||
|
|
||||||
|
impl fmt::Display for InvalidTerminalLineLimit {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str("terminal line limit must be greater than zero")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl std::error::Error for InvalidTerminalLineLimit {}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ShellModel {
|
||||||
|
panes: [PaneState; GRID_ROWS * GRID_COLUMNS],
|
||||||
|
selected_pane: PaneId,
|
||||||
|
command_palette: CommandPaletteState,
|
||||||
|
terminal_line_limit: usize,
|
||||||
|
synthetic_tick: u64,
|
||||||
|
revision: u64,
|
||||||
|
counters: MeasurementCounters,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ShellModel {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::with_terminal_line_limit(DEFAULT_TERMINAL_LINE_LIMIT)
|
||||||
|
.expect("the default terminal line limit is non-zero")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShellModel {
|
||||||
|
pub fn with_terminal_line_limit(limit: usize) -> Result<Self, InvalidTerminalLineLimit> {
|
||||||
|
if limit == 0 {
|
||||||
|
return Err(InvalidTerminalLineLimit);
|
||||||
|
}
|
||||||
|
let panes = PANES.map(|fixture| {
|
||||||
|
let mut lines = fixture.lines.map(String::from).to_vec();
|
||||||
|
if fixture.kind == SurfaceKind::Terminal && lines.len() > limit {
|
||||||
|
lines.drain(..lines.len() - limit);
|
||||||
|
}
|
||||||
|
PaneState {
|
||||||
|
fixture,
|
||||||
|
status: fixture.status,
|
||||||
|
status_before_input: (fixture.status == PaneStatus::NeedsInput)
|
||||||
|
.then_some(PaneStatus::Working),
|
||||||
|
lines,
|
||||||
|
synthetic_line_sequence: 0,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(Self {
|
||||||
|
panes,
|
||||||
|
selected_pane: PaneId::CodexRuntime,
|
||||||
|
command_palette: CommandPaletteState::default(),
|
||||||
|
terminal_line_limit: limit,
|
||||||
|
synthetic_tick: 0,
|
||||||
|
revision: 0,
|
||||||
|
counters: MeasurementCounters::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn panes(&self) -> &[PaneState; GRID_ROWS * GRID_COLUMNS] {
|
||||||
|
&self.panes
|
||||||
|
}
|
||||||
|
#[must_use]
|
||||||
|
pub fn pane(&self, id: PaneId) -> &PaneState {
|
||||||
|
&self.panes[id.index()]
|
||||||
|
}
|
||||||
|
#[must_use]
|
||||||
|
pub const fn selected_pane(&self) -> PaneId {
|
||||||
|
self.selected_pane
|
||||||
|
}
|
||||||
|
#[must_use]
|
||||||
|
pub const fn command_palette(&self) -> &CommandPaletteState {
|
||||||
|
&self.command_palette
|
||||||
|
}
|
||||||
|
#[must_use]
|
||||||
|
pub const fn terminal_line_limit(&self) -> usize {
|
||||||
|
self.terminal_line_limit
|
||||||
|
}
|
||||||
|
#[must_use]
|
||||||
|
pub const fn synthetic_tick(&self) -> u64 {
|
||||||
|
self.synthetic_tick
|
||||||
|
}
|
||||||
|
#[must_use]
|
||||||
|
pub const fn revision(&self) -> u64 {
|
||||||
|
self.revision
|
||||||
|
}
|
||||||
|
#[must_use]
|
||||||
|
pub const fn counters(&self) -> MeasurementCounters {
|
||||||
|
self.counters
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatch(&mut self, action: ShellAction) -> ActionOutcome {
|
||||||
|
self.counters.events_dispatched += 1;
|
||||||
|
let mut changed = false;
|
||||||
|
let mut appended = 0;
|
||||||
|
match action {
|
||||||
|
ShellAction::MoveFocus(direction) => {
|
||||||
|
if let Some(next) = focus_neighbor(self.selected_pane, direction) {
|
||||||
|
self.selected_pane = next;
|
||||||
|
self.counters.focus_moves += 1;
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
self.counters.blocked_focus_moves += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShellAction::SelectPane(pane) => {
|
||||||
|
if self.selected_pane != pane {
|
||||||
|
self.selected_pane = pane;
|
||||||
|
self.counters.direct_selections += 1;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShellAction::SetNeedsInput { pane, needs_input } => {
|
||||||
|
let pane = &mut self.panes[pane.index()];
|
||||||
|
if needs_input && !pane.needs_input() {
|
||||||
|
pane.status_before_input = Some(pane.status);
|
||||||
|
pane.status = PaneStatus::NeedsInput;
|
||||||
|
self.counters.needs_input_changes += 1;
|
||||||
|
changed = true;
|
||||||
|
} else if !needs_input && pane.needs_input() {
|
||||||
|
pane.status = pane.status_before_input.take().unwrap_or(PaneStatus::Ready);
|
||||||
|
self.counters.needs_input_changes += 1;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShellAction::OpenCommandPalette => {
|
||||||
|
if !self.command_palette.open {
|
||||||
|
self.command_palette.open = true;
|
||||||
|
self.command_palette.query.clear();
|
||||||
|
self.counters.palette_changes += 1;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShellAction::CloseCommandPalette => {
|
||||||
|
if self.command_palette.open {
|
||||||
|
self.command_palette.open = false;
|
||||||
|
self.command_palette.query.clear();
|
||||||
|
self.counters.palette_changes += 1;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShellAction::SetCommandPaletteQuery(query) => {
|
||||||
|
if self.command_palette.open && self.command_palette.query != query {
|
||||||
|
self.command_palette.query = query;
|
||||||
|
self.counters.palette_changes += 1;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShellAction::SyntheticStreamTick => {
|
||||||
|
self.synthetic_tick += 1;
|
||||||
|
self.counters.synthetic_ticks += 1;
|
||||||
|
for pane in &mut self.panes {
|
||||||
|
pane.synthetic_line_sequence += 1;
|
||||||
|
if pane.kind() != SurfaceKind::Terminal {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pane.lines.push(format!(
|
||||||
|
"[tick {:06}] {} · synthetic line {:06}",
|
||||||
|
self.synthetic_tick,
|
||||||
|
pane.id(),
|
||||||
|
pane.synthetic_line_sequence
|
||||||
|
));
|
||||||
|
if pane.lines.len() > self.terminal_line_limit {
|
||||||
|
pane.lines.remove(0);
|
||||||
|
}
|
||||||
|
appended += 1;
|
||||||
|
}
|
||||||
|
self.counters.surface_updates += self.panes.len() as u64;
|
||||||
|
self.counters.terminal_lines_appended += appended as u64;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
self.revision += 1;
|
||||||
|
self.counters.state_changes += 1;
|
||||||
|
}
|
||||||
|
ActionOutcome {
|
||||||
|
event_sequence: self.counters.events_dispatched,
|
||||||
|
revision: self.revision,
|
||||||
|
changed,
|
||||||
|
selected_pane: self.selected_pane,
|
||||||
|
terminal_lines_appended: appended,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn focus_neighbor(pane: PaneId, direction: FocusDirection) -> Option<PaneId> {
|
||||||
|
let row = pane.row();
|
||||||
|
let column = pane.column();
|
||||||
|
let next = match direction {
|
||||||
|
FocusDirection::Left if column > 0 => Some(pane.index() - 1),
|
||||||
|
FocusDirection::Right if column + 1 < GRID_COLUMNS => Some(pane.index() + 1),
|
||||||
|
FocusDirection::Up if row > 0 => Some(pane.index() - GRID_COLUMNS),
|
||||||
|
FocusDirection::Down if row + 1 < GRID_ROWS => Some(pane.index() + GRID_COLUMNS),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
match next {
|
||||||
|
Some(index) => Some(PaneId::ALL[index]),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{PANES, SurfaceKind};
|
use super::{
|
||||||
|
FocusDirection, GRID_COLUMNS, GRID_ROWS, PANES, PaneId, PaneStatus, ShellAction,
|
||||||
|
ShellModel, SurfaceKind, focus_neighbor,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn comparison_fixture_has_six_mixed_surfaces() {
|
fn fixture_has_stable_ids_and_mixed_surfaces() {
|
||||||
assert_eq!(PANES.len(), 6);
|
assert_eq!(PANES.len(), GRID_ROWS * GRID_COLUMNS);
|
||||||
|
assert_eq!(PANES.map(|pane| pane.id), PaneId::ALL);
|
||||||
|
assert_eq!(PaneId::CodexRuntime.stable_name(), "codex-runtime");
|
||||||
|
assert_eq!(PaneId::RuntimeReview.to_string(), "runtime-review");
|
||||||
assert!(PANES.iter().any(|pane| pane.kind == SurfaceKind::Markdown));
|
assert!(PANES.iter().any(|pane| pane.kind == SurfaceKind::Markdown));
|
||||||
assert!(PANES.iter().any(|pane| pane.kind == SurfaceKind::Browser));
|
assert!(PANES.iter().any(|pane| pane.kind == SurfaceKind::Browser));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
PANES
|
PANES
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|pane| pane.kind == SurfaceKind::Terminal)
|
.filter(|p| p.kind == SurfaceKind::Terminal)
|
||||||
.count(),
|
.count(),
|
||||||
3
|
3
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_state_has_one_explicit_needs_input_pane() {
|
||||||
|
let model = ShellModel::default();
|
||||||
|
assert_eq!(model.selected_pane(), PaneId::CodexRuntime);
|
||||||
|
assert_eq!(model.revision(), 0);
|
||||||
|
assert!(!model.command_palette().is_open());
|
||||||
|
assert_eq!(
|
||||||
|
model
|
||||||
|
.panes()
|
||||||
|
.iter()
|
||||||
|
.filter(|pane| pane.needs_input())
|
||||||
|
.map(|pane| pane.id())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![PaneId::ClaudeUi]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn neighbors_match_two_by_three_grid_without_wrapping() {
|
||||||
|
assert_eq!(
|
||||||
|
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Right),
|
||||||
|
Some(PaneId::ClaudeUi)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
focus_neighbor(PaneId::ClaudeUi, FocusDirection::Down),
|
||||||
|
Some(PaneId::AcpPreview)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
focus_neighbor(PaneId::RuntimeReview, FocusDirection::Up),
|
||||||
|
Some(PaneId::PiDocs)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
focus_neighbor(PaneId::Architecture, FocusDirection::Left),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(focus_neighbor(PaneId::PiDocs, FocusDirection::Right), None);
|
||||||
|
assert_eq!(
|
||||||
|
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Up),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focus_updates_selection_revision_and_blocked_counter() {
|
||||||
|
let mut model = ShellModel::default();
|
||||||
|
let blocked = model.dispatch(ShellAction::MoveFocus(FocusDirection::Left));
|
||||||
|
assert!(!blocked.changed);
|
||||||
|
assert_eq!((blocked.event_sequence, blocked.revision), (1, 0));
|
||||||
|
let moved = model.dispatch(ShellAction::MoveFocus(FocusDirection::Right));
|
||||||
|
assert!(moved.changed);
|
||||||
|
assert_eq!(moved.selected_pane, PaneId::ClaudeUi);
|
||||||
|
assert_eq!(moved.revision, 1);
|
||||||
|
model.dispatch(ShellAction::MoveFocus(FocusDirection::Down));
|
||||||
|
assert_eq!(model.selected_pane(), PaneId::AcpPreview);
|
||||||
|
assert_eq!(model.counters().focus_moves, 2);
|
||||||
|
assert_eq!(model.counters().blocked_focus_moves, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_selection_is_idempotent_and_counted_separately() {
|
||||||
|
let mut model = ShellModel::default();
|
||||||
|
assert!(
|
||||||
|
!model
|
||||||
|
.dispatch(ShellAction::SelectPane(PaneId::CodexRuntime))
|
||||||
|
.changed
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
model
|
||||||
|
.dispatch(ShellAction::SelectPane(PaneId::RuntimeReview))
|
||||||
|
.changed
|
||||||
|
);
|
||||||
|
assert_eq!(model.selected_pane(), PaneId::RuntimeReview);
|
||||||
|
assert_eq!(model.counters().direct_selections, 1);
|
||||||
|
assert_eq!(model.counters().events_dispatched, 2);
|
||||||
|
assert_eq!(model.counters().state_changes, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn needs_input_restores_previous_status() {
|
||||||
|
let mut model = ShellModel::default();
|
||||||
|
model.dispatch(ShellAction::SetNeedsInput {
|
||||||
|
pane: PaneId::PiDocs,
|
||||||
|
needs_input: true,
|
||||||
|
});
|
||||||
|
assert_eq!(model.pane(PaneId::PiDocs).status(), PaneStatus::NeedsInput);
|
||||||
|
assert!(
|
||||||
|
!model
|
||||||
|
.dispatch(ShellAction::SetNeedsInput {
|
||||||
|
pane: PaneId::PiDocs,
|
||||||
|
needs_input: true,
|
||||||
|
})
|
||||||
|
.changed
|
||||||
|
);
|
||||||
|
model.dispatch(ShellAction::SetNeedsInput {
|
||||||
|
pane: PaneId::PiDocs,
|
||||||
|
needs_input: false,
|
||||||
|
});
|
||||||
|
assert_eq!(model.pane(PaneId::PiDocs).status(), PaneStatus::Streaming);
|
||||||
|
model.dispatch(ShellAction::SetNeedsInput {
|
||||||
|
pane: PaneId::ClaudeUi,
|
||||||
|
needs_input: false,
|
||||||
|
});
|
||||||
|
assert_eq!(model.pane(PaneId::ClaudeUi).status(), PaneStatus::Working);
|
||||||
|
assert_eq!(model.counters().needs_input_changes, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_palette_has_explicit_lifecycle() {
|
||||||
|
let mut model = ShellModel::default();
|
||||||
|
assert!(
|
||||||
|
!model
|
||||||
|
.dispatch(ShellAction::SetCommandPaletteQuery("pane".into()))
|
||||||
|
.changed
|
||||||
|
);
|
||||||
|
model.dispatch(ShellAction::OpenCommandPalette);
|
||||||
|
model.dispatch(ShellAction::SetCommandPaletteQuery("pane: next".into()));
|
||||||
|
assert!(model.command_palette().is_open());
|
||||||
|
assert_eq!(model.command_palette().query(), "pane: next");
|
||||||
|
model.dispatch(ShellAction::CloseCommandPalette);
|
||||||
|
assert!(!model.command_palette().is_open());
|
||||||
|
assert_eq!(model.command_palette().query(), "");
|
||||||
|
assert_eq!(model.counters().palette_changes, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticks_append_predictable_lines_to_terminals_only() {
|
||||||
|
let mut model = ShellModel::default();
|
||||||
|
let markdown = model.pane(PaneId::Architecture).lines().to_vec();
|
||||||
|
let outcome = model.dispatch(ShellAction::SyntheticStreamTick);
|
||||||
|
assert_eq!(outcome.terminal_lines_appended, 3);
|
||||||
|
assert_eq!(model.synthetic_tick(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
model.pane(PaneId::CodexRuntime).lines().last().unwrap(),
|
||||||
|
"[tick 000001] codex-runtime · synthetic line 000001"
|
||||||
|
);
|
||||||
|
assert_eq!(model.pane(PaneId::Architecture).lines(), markdown);
|
||||||
|
assert!(
|
||||||
|
model
|
||||||
|
.panes()
|
||||||
|
.iter()
|
||||||
|
.all(|pane| pane.synthetic_line_sequence() == 1)
|
||||||
|
);
|
||||||
|
assert_eq!(model.counters().synthetic_ticks, 1);
|
||||||
|
assert_eq!(model.counters().surface_updates, 6);
|
||||||
|
assert_eq!(model.counters().terminal_lines_appended, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_scrollback_is_bounded_below_fixture_size() {
|
||||||
|
let mut model = ShellModel::with_terminal_line_limit(2).unwrap();
|
||||||
|
assert_eq!(model.pane(PaneId::CodexRuntime).lines().len(), 2);
|
||||||
|
for _ in 0..10 {
|
||||||
|
model.dispatch(ShellAction::SyntheticStreamTick);
|
||||||
|
}
|
||||||
|
for pane in model
|
||||||
|
.panes()
|
||||||
|
.iter()
|
||||||
|
.filter(|pane| pane.kind() == SurfaceKind::Terminal)
|
||||||
|
{
|
||||||
|
assert_eq!(pane.lines().len(), 2);
|
||||||
|
assert_eq!(pane.synthetic_line_sequence(), 10);
|
||||||
|
assert!(pane.lines()[0].starts_with("[tick 000009]"));
|
||||||
|
assert!(pane.lines()[1].starts_with("[tick 000010]"));
|
||||||
|
}
|
||||||
|
assert!(ShellModel::with_terminal_line_limit(0).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_action_replays_produce_identical_models_and_counters() {
|
||||||
|
let actions = [
|
||||||
|
ShellAction::MoveFocus(FocusDirection::Right),
|
||||||
|
ShellAction::MoveFocus(FocusDirection::Down),
|
||||||
|
ShellAction::OpenCommandPalette,
|
||||||
|
ShellAction::SetCommandPaletteQuery("workspace".into()),
|
||||||
|
ShellAction::CloseCommandPalette,
|
||||||
|
ShellAction::SetNeedsInput {
|
||||||
|
pane: PaneId::CodexRuntime,
|
||||||
|
needs_input: true,
|
||||||
|
},
|
||||||
|
ShellAction::SyntheticStreamTick,
|
||||||
|
ShellAction::SyntheticStreamTick,
|
||||||
|
];
|
||||||
|
let mut gpui = ShellModel::default();
|
||||||
|
let mut floem = ShellModel::default();
|
||||||
|
let gpui_outcomes = actions
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|action| gpui.dispatch(action))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let floem_outcomes = actions
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|action| floem.dispatch(action))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(gpui_outcomes, floem_outcomes);
|
||||||
|
assert_eq!(gpui, floem);
|
||||||
|
assert_eq!(gpui.counters().events_dispatched, actions.len() as u64);
|
||||||
|
assert_eq!(gpui.counters().surface_updates, 12);
|
||||||
|
assert_eq!(gpui.counters().terminal_lines_appended, 6);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user