@@ -12,6 +12,39 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aho-corasick"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alacritty_terminal"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"home",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"miow",
|
||||||
|
"parking_lot",
|
||||||
|
"piper",
|
||||||
|
"polling",
|
||||||
|
"regex-automata",
|
||||||
|
"rustix",
|
||||||
|
"rustix-openpty",
|
||||||
|
"signal-hook",
|
||||||
|
"unicode-width",
|
||||||
|
"vte",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -33,6 +66,12 @@ version = "0.7.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atomic-waker"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -282,6 +321,15 @@ version = "0.5.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "concurrent-queue"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -312,6 +360,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-utils"
|
||||||
|
version = "0.8.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -341,6 +395,12 @@ dependencies = [
|
|||||||
"cmov",
|
"cmov",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cursor-icon"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -381,6 +441,16 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fallible-iterator"
|
name = "fallible-iterator"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -393,6 +463,12 @@ 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 = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filedescriptor"
|
name = "filedescriptor"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -425,6 +501,12 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-io"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -478,6 +560,12 @@ dependencies = [
|
|||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hermit-abi"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hex"
|
name = "hex"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -520,6 +608,15 @@ dependencies = [
|
|||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "home"
|
||||||
|
version = "0.5.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hybrid-array"
|
name = "hybrid-array"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -719,12 +816,27 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lock_api"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||||
|
dependencies = [
|
||||||
|
"scopeguard",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.34"
|
version = "0.4.34"
|
||||||
@@ -751,6 +863,9 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "lumbridge-core"
|
name = "lumbridge-core"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lumbridge-pty"
|
name = "lumbridge-pty"
|
||||||
@@ -766,6 +881,7 @@ name = "lumbridge-runtime"
|
|||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lumbridge-pty",
|
"lumbridge-pty",
|
||||||
|
"lumbridge-terminal",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -777,12 +893,29 @@ dependencies = [
|
|||||||
"rusqlite",
|
"rusqlite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lumbridge-terminal"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"alacritty_terminal",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.3"
|
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 = "miow"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
@@ -840,6 +973,29 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||||
|
dependencies = [
|
||||||
|
"lock_api",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot_core"
|
||||||
|
version = "0.9.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"redox_syscall",
|
||||||
|
"smallvec",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "password-hash"
|
name = "password-hash"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -873,12 +1029,37 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "piper"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"fastrand",
|
||||||
|
"futures-io",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polling"
|
||||||
|
version = "3.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"concurrent-queue",
|
||||||
|
"hermit-abi",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "poly1305"
|
name = "poly1305"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -1000,6 +1181,32 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_syscall"
|
||||||
|
version = "0.5.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.4.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.8.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.40.2"
|
version = "0.40.2"
|
||||||
@@ -1013,6 +1220,30 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix-openpty"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -1028,6 +1259,12 @@ dependencies = [
|
|||||||
"cipher",
|
"cipher",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopeguard"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scrypt"
|
name = "scrypt"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -1111,7 +1348,7 @@ checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1158,6 +1395,26 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"signal-hook-registry",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -1301,6 +1558,12 @@ dependencies = [
|
|||||||
"tinyvec",
|
"tinyvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "universal-hash"
|
name = "universal-hash"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -1354,6 +1617,19 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vte"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"cursor-icon",
|
||||||
|
"log",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@@ -1496,6 +1772,15 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -1505,6 +1790,70 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ members = [
|
|||||||
"crates/lumbridge-pty",
|
"crates/lumbridge-pty",
|
||||||
"crates/lumbridge-runtime",
|
"crates/lumbridge-runtime",
|
||||||
"crates/lumbridge-storage",
|
"crates/lumbridge-storage",
|
||||||
|
"crates/lumbridge-terminal",
|
||||||
]
|
]
|
||||||
exclude = ["spikes"]
|
exclude = ["spikes"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ as installable binaries; building from source will remain supported.
|
|||||||
This repository is in architecture and vertical-slice phase. The installable
|
This repository is in architecture and vertical-slice phase. The installable
|
||||||
binary is still a scaffold, while the isolated native UI spikes now exercise an
|
binary is still a scaffold, while the isolated native UI spikes now exercise an
|
||||||
interactive six-surface workspace and the root workspace contains the first
|
interactive six-surface workspace and the root workspace contains the first
|
||||||
bounded local PTY and runtime-actor boundaries. The GPUI slice renders one real
|
bounded local PTY, runtime-actor, VT engine, and capability-gated workspace
|
||||||
actor-owned PTY beside five deterministic surfaces. We are still validating
|
command boundaries. The GPUI slice renders one real actor-owned VT session beside
|
||||||
terminal emulation, standalone runtime IPC/durability, ACP integration,
|
five deterministic surfaces. We are still validating the dedicated styled
|
||||||
|
terminal renderer, standalone runtime IPC/durability, ACP integration,
|
||||||
packaging, and usage-data contracts before a large implementation.
|
packaging, and usage-data contracts before a large implementation.
|
||||||
|
|
||||||
## Product shape
|
## Product shape
|
||||||
@@ -39,6 +40,9 @@ packaging, and usage-data contracts before a large implementation.
|
|||||||
provenance instead of invented precision.
|
provenance instead of invented precision.
|
||||||
- Optional first-class Buzz channels, messages, agents, and confirmed redacted
|
- Optional first-class Buzz channels, messages, agents, and confirmed redacted
|
||||||
pane sharing without making Buzz a requirement.
|
pane sharing without making Buzz a requirement.
|
||||||
|
- An optional Lumbridge Harness that can suggest, coordinate, and—with explicit
|
||||||
|
execution capability—manage agents through the same audited command plane as
|
||||||
|
the human UI. Lumbridge remains useful without it.
|
||||||
|
|
||||||
Start with [the product spec](docs/PRODUCT_SPEC.md),
|
Start with [the product spec](docs/PRODUCT_SPEC.md),
|
||||||
[architecture](docs/ARCHITECTURE.md), [research map](docs/RESEARCH.md), and
|
[architecture](docs/ARCHITECTURE.md), [research map](docs/RESEARCH.md), and
|
||||||
@@ -63,3 +67,5 @@ native shell candidates live in [`spikes/`](spikes/), with results tracked in
|
|||||||
[the UI scorecard](docs/UI_SPIKE_SCORECARD.md).
|
[the UI scorecard](docs/UI_SPIKE_SCORECARD.md).
|
||||||
The signed-protocol and pane-sharing boundary is in
|
The signed-protocol and pane-sharing boundary is in
|
||||||
[the Buzz integration design](docs/BUZZ_INTEGRATION.md).
|
[the Buzz integration design](docs/BUZZ_INTEGRATION.md).
|
||||||
|
Brand masters, platform-ready raster exports, and usage rules are in
|
||||||
|
[`assets/brand/`](assets/brand/) and [the brand guide](docs/BRAND.md).
|
||||||
|
|||||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 627 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 627 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 627 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -7,6 +7,8 @@ rust-version.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror = "2.0"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
|
mod workspace;
|
||||||
|
|
||||||
|
pub use workspace::{
|
||||||
|
CommandOrigin, CommandRequestId, LayoutNode, PaneCloseDisposition, PaneDefinition, PaneId,
|
||||||
|
PaneLaunchIntent, PanePlacement, PaneSurface, SplitAxis, SplitRatio, WorkspaceApplyOutcome,
|
||||||
|
WorkspaceCapability, WorkspaceCommand, WorkspaceError, WorkspaceEvent, WorkspaceId,
|
||||||
|
WorkspaceRequest, WorkspaceState,
|
||||||
|
};
|
||||||
|
|
||||||
/// The transport Lumbridge uses to reach a user-owned remote machine.
|
/// The transport Lumbridge uses to reach a user-owned remote machine.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum RemoteTransport {
|
pub enum RemoteTransport {
|
||||||
|
|||||||
@@ -0,0 +1,861 @@
|
|||||||
|
//! Deterministic workspace command plane shared by UI, CLI, and agents.
|
||||||
|
|
||||||
|
use crate::ExecutionTarget;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
const MIN_SPLIT_PERMILLE: u16 = 100;
|
||||||
|
const MAX_SPLIT_PERMILLE: u16 = 900;
|
||||||
|
const MAX_IDENTITY_BYTES: usize = 256;
|
||||||
|
|
||||||
|
macro_rules! string_id {
|
||||||
|
($name:ident, $label:literal) => {
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct $name(String);
|
||||||
|
|
||||||
|
impl $name {
|
||||||
|
/// Creates a stable, non-empty identifier.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
#[doc = concat!("Returns [`WorkspaceError::InvalidIdentifier`] for an empty ", $label, ".")]
|
||||||
|
pub fn new(value: impl Into<String>) -> Result<Self, WorkspaceError> {
|
||||||
|
let value = value.into();
|
||||||
|
if value.trim().is_empty() || value.chars().any(char::is_control) {
|
||||||
|
return Err(WorkspaceError::InvalidIdentifier($label));
|
||||||
|
}
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
string_id!(WorkspaceId, "workspace ID");
|
||||||
|
string_id!(PaneId, "pane ID");
|
||||||
|
string_id!(CommandRequestId, "command request ID");
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub enum WorkspaceCapability {
|
||||||
|
Observe,
|
||||||
|
Configure,
|
||||||
|
Execute,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum CommandOrigin {
|
||||||
|
Human,
|
||||||
|
Agent { identity: String },
|
||||||
|
Automation { client: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandOrigin {
|
||||||
|
fn validate(&self) -> Result<(), WorkspaceError> {
|
||||||
|
let value = match self {
|
||||||
|
Self::Human => return Ok(()),
|
||||||
|
Self::Agent { identity } => identity,
|
||||||
|
Self::Automation { client } => client,
|
||||||
|
};
|
||||||
|
if value.trim().is_empty()
|
||||||
|
|| value.len() > MAX_IDENTITY_BYTES
|
||||||
|
|| value.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err(WorkspaceError::InvalidOrigin);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PaneSurface {
|
||||||
|
Terminal,
|
||||||
|
Markdown,
|
||||||
|
Browser,
|
||||||
|
Tools,
|
||||||
|
Context,
|
||||||
|
Goal,
|
||||||
|
Review,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A credential-free launch reference. Executable arguments and secret values
|
||||||
|
/// are deliberately absent from the workspace plan.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub enum PaneLaunchIntent {
|
||||||
|
UserShell {
|
||||||
|
working_directory: Option<String>,
|
||||||
|
},
|
||||||
|
HarnessProfile {
|
||||||
|
profile_id: String,
|
||||||
|
working_directory: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pane metadata suitable for persistence and automation. It intentionally has
|
||||||
|
/// no `Debug` implementation because titles and paths may be private.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct PaneDefinition {
|
||||||
|
pub id: PaneId,
|
||||||
|
pub title: String,
|
||||||
|
pub surface: PaneSurface,
|
||||||
|
pub execution_target: ExecutionTarget,
|
||||||
|
pub launch: Option<PaneLaunchIntent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PaneDefinition {
|
||||||
|
/// Validates a pane definition before it reaches the layout state.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`WorkspaceError::InvalidPane`] for an empty title or for a
|
||||||
|
/// launch intent on a non-terminal pane.
|
||||||
|
pub fn validate(&self) -> Result<(), WorkspaceError> {
|
||||||
|
if self.title.trim().is_empty() || self.title.chars().any(char::is_control) {
|
||||||
|
return Err(WorkspaceError::InvalidPane("pane title"));
|
||||||
|
}
|
||||||
|
if self.launch.is_some() && self.surface != PaneSurface::Terminal {
|
||||||
|
return Err(WorkspaceError::InvalidPane(
|
||||||
|
"only terminal panes may carry a launch intent",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match &self.launch {
|
||||||
|
Some(PaneLaunchIntent::HarnessProfile { profile_id, .. })
|
||||||
|
if profile_id.trim().is_empty() || profile_id.chars().any(char::is_control) =>
|
||||||
|
{
|
||||||
|
Err(WorkspaceError::InvalidPane("harness profile ID"))
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum SplitAxis {
|
||||||
|
Horizontal,
|
||||||
|
Vertical,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PanePlacement {
|
||||||
|
Before,
|
||||||
|
After,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PaneCloseDisposition {
|
||||||
|
Detach,
|
||||||
|
Terminate,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SplitRatio(u16);
|
||||||
|
|
||||||
|
impl SplitRatio {
|
||||||
|
/// Creates the first child's share in permille.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`WorkspaceError::InvalidSplitRatio`] outside 100..=900.
|
||||||
|
pub fn from_permille(value: u16) -> Result<Self, WorkspaceError> {
|
||||||
|
if !(MIN_SPLIT_PERMILLE..=MAX_SPLIT_PERMILLE).contains(&value) {
|
||||||
|
return Err(WorkspaceError::InvalidSplitRatio(value));
|
||||||
|
}
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn permille(self) -> u16 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SplitRatio {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum LayoutNode {
|
||||||
|
Pane(PaneId),
|
||||||
|
Split {
|
||||||
|
axis: SplitAxis,
|
||||||
|
ratio: SplitRatio,
|
||||||
|
first: Box<Self>,
|
||||||
|
second: Box<Self>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LayoutNode {
|
||||||
|
fn contains(&self, pane: &PaneId) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Pane(candidate) => candidate == pane,
|
||||||
|
Self::Split { first, second, .. } => first.contains(pane) || second.contains(pane),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split(
|
||||||
|
&mut self,
|
||||||
|
target: &PaneId,
|
||||||
|
new_pane: PaneId,
|
||||||
|
axis: SplitAxis,
|
||||||
|
ratio: SplitRatio,
|
||||||
|
placement: PanePlacement,
|
||||||
|
) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Pane(candidate) if candidate == target => {
|
||||||
|
let existing = Self::Pane(candidate.clone());
|
||||||
|
let inserted = Self::Pane(new_pane);
|
||||||
|
let (first, second) = match placement {
|
||||||
|
PanePlacement::Before => (inserted, existing),
|
||||||
|
PanePlacement::After => (existing, inserted),
|
||||||
|
};
|
||||||
|
*self = Self::Split {
|
||||||
|
axis,
|
||||||
|
ratio,
|
||||||
|
first: Box::new(first),
|
||||||
|
second: Box::new(second),
|
||||||
|
};
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Self::Pane(_) => false,
|
||||||
|
Self::Split { first, second, .. } => {
|
||||||
|
first.split(target, new_pane.clone(), axis, ratio, placement)
|
||||||
|
|| second.split(target, new_pane, axis, ratio, placement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove(self, target: &PaneId) -> Option<Self> {
|
||||||
|
match self {
|
||||||
|
Self::Pane(pane) => (pane != *target).then_some(Self::Pane(pane)),
|
||||||
|
Self::Split {
|
||||||
|
axis,
|
||||||
|
ratio,
|
||||||
|
first,
|
||||||
|
second,
|
||||||
|
} => match (first.remove(target), second.remove(target)) {
|
||||||
|
(Some(first), Some(second)) => Some(Self::Split {
|
||||||
|
axis,
|
||||||
|
ratio,
|
||||||
|
first: Box::new(first),
|
||||||
|
second: Box::new(second),
|
||||||
|
}),
|
||||||
|
(Some(remaining), None) | (None, Some(remaining)) => Some(remaining),
|
||||||
|
(None, None) => None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_pane(&self) -> &PaneId {
|
||||||
|
match self {
|
||||||
|
Self::Pane(pane) => pane,
|
||||||
|
Self::Split { first, .. } => first.first_pane(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub enum WorkspaceCommand {
|
||||||
|
SplitPane {
|
||||||
|
target: PaneId,
|
||||||
|
pane: PaneDefinition,
|
||||||
|
axis: SplitAxis,
|
||||||
|
ratio: SplitRatio,
|
||||||
|
placement: PanePlacement,
|
||||||
|
},
|
||||||
|
SelectPane {
|
||||||
|
pane: PaneId,
|
||||||
|
},
|
||||||
|
RenamePane {
|
||||||
|
pane: PaneId,
|
||||||
|
title: String,
|
||||||
|
},
|
||||||
|
ClosePane {
|
||||||
|
pane: PaneId,
|
||||||
|
disposition: PaneCloseDisposition,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceCommand {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn required_capability(&self) -> WorkspaceCapability {
|
||||||
|
match self {
|
||||||
|
Self::ClosePane {
|
||||||
|
disposition: PaneCloseDisposition::Terminate,
|
||||||
|
..
|
||||||
|
} => WorkspaceCapability::Execute,
|
||||||
|
Self::SplitPane { pane, .. } if pane.launch.is_some() => WorkspaceCapability::Execute,
|
||||||
|
Self::SplitPane { .. }
|
||||||
|
| Self::SelectPane { .. }
|
||||||
|
| Self::RenamePane { .. }
|
||||||
|
| Self::ClosePane {
|
||||||
|
disposition: PaneCloseDisposition::Detach,
|
||||||
|
..
|
||||||
|
} => WorkspaceCapability::Configure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct WorkspaceRequest {
|
||||||
|
pub request_id: CommandRequestId,
|
||||||
|
pub origin: CommandOrigin,
|
||||||
|
pub command: WorkspaceCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State transition emitted after an accepted command. Transcript or input
|
||||||
|
/// content never appears in these events.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub enum WorkspaceEvent {
|
||||||
|
PaneSplit {
|
||||||
|
target: PaneId,
|
||||||
|
pane: PaneId,
|
||||||
|
},
|
||||||
|
PaneSelected {
|
||||||
|
pane: PaneId,
|
||||||
|
},
|
||||||
|
PaneRenamed {
|
||||||
|
pane: PaneId,
|
||||||
|
},
|
||||||
|
PaneClosed {
|
||||||
|
pane: PaneId,
|
||||||
|
disposition: PaneCloseDisposition,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WorkspaceApplyOutcome {
|
||||||
|
pub revision: u64,
|
||||||
|
pub duplicate: bool,
|
||||||
|
pub event: Option<WorkspaceEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Eq, PartialEq)]
|
||||||
|
pub enum WorkspaceError {
|
||||||
|
#[error("invalid {0}")]
|
||||||
|
InvalidIdentifier(&'static str),
|
||||||
|
#[error("invalid {0}")]
|
||||||
|
InvalidPane(&'static str),
|
||||||
|
#[error("split ratio {0} must be between 100 and 900 permille")]
|
||||||
|
InvalidSplitRatio(u16),
|
||||||
|
#[error("pane does not exist: {}", .0.as_str())]
|
||||||
|
PaneNotFound(PaneId),
|
||||||
|
#[error("pane already exists: {}", .0.as_str())]
|
||||||
|
PaneAlreadyExists(PaneId),
|
||||||
|
#[error("command origin identity is invalid")]
|
||||||
|
InvalidOrigin,
|
||||||
|
#[error("request ID was already used for a different request: {}", .0.as_str())]
|
||||||
|
RequestConflict(CommandRequestId),
|
||||||
|
#[error("the last pane cannot be closed")]
|
||||||
|
CannotCloseLastPane,
|
||||||
|
#[error("workspace command requires {required:?} capability but only {granted:?} was granted")]
|
||||||
|
CapabilityDenied {
|
||||||
|
required: WorkspaceCapability,
|
||||||
|
granted: WorkspaceCapability,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic layout state shared by all command-plane frontends. It has no
|
||||||
|
/// `Debug` implementation because pane metadata may contain local paths.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct WorkspaceState {
|
||||||
|
id: WorkspaceId,
|
||||||
|
name: String,
|
||||||
|
panes: BTreeMap<PaneId, PaneDefinition>,
|
||||||
|
layout: LayoutNode,
|
||||||
|
selected: PaneId,
|
||||||
|
applied_requests: BTreeMap<CommandRequestId, WorkspaceRequest>,
|
||||||
|
revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceState {
|
||||||
|
/// Creates a workspace with one root pane.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`WorkspaceError::InvalidPane`] for invalid names or pane
|
||||||
|
/// definitions.
|
||||||
|
pub fn new(
|
||||||
|
id: WorkspaceId,
|
||||||
|
name: impl Into<String>,
|
||||||
|
root: PaneDefinition,
|
||||||
|
) -> Result<Self, WorkspaceError> {
|
||||||
|
let name = name.into();
|
||||||
|
if name.trim().is_empty() || name.chars().any(char::is_control) {
|
||||||
|
return Err(WorkspaceError::InvalidPane("workspace name"));
|
||||||
|
}
|
||||||
|
root.validate()?;
|
||||||
|
let selected = root.id.clone();
|
||||||
|
let layout = LayoutNode::Pane(root.id.clone());
|
||||||
|
let panes = BTreeMap::from([(root.id.clone(), root)]);
|
||||||
|
Ok(Self {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
panes,
|
||||||
|
layout,
|
||||||
|
selected,
|
||||||
|
applied_requests: BTreeMap::new(),
|
||||||
|
revision: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn id(&self) -> &WorkspaceId {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn layout(&self) -> &LayoutNode {
|
||||||
|
&self.layout
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn selected(&self) -> &PaneId {
|
||||||
|
&self.selected
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn revision(&self) -> u64 {
|
||||||
|
self.revision
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn pane(&self, id: &PaneId) -> Option<&PaneDefinition> {
|
||||||
|
self.panes.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn pane_count(&self) -> usize {
|
||||||
|
self.panes.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies one idempotent command after checking its required capability.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Rejects insufficient authority and invalid state transitions without
|
||||||
|
/// changing the workspace.
|
||||||
|
pub fn apply(
|
||||||
|
&mut self,
|
||||||
|
request: WorkspaceRequest,
|
||||||
|
granted: WorkspaceCapability,
|
||||||
|
) -> Result<WorkspaceApplyOutcome, WorkspaceError> {
|
||||||
|
if let Some(applied) = self.applied_requests.get(&request.request_id) {
|
||||||
|
if applied == &request {
|
||||||
|
return Ok(WorkspaceApplyOutcome {
|
||||||
|
revision: self.revision,
|
||||||
|
duplicate: true,
|
||||||
|
event: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Err(WorkspaceError::RequestConflict(request.request_id));
|
||||||
|
}
|
||||||
|
request.origin.validate()?;
|
||||||
|
let required = request.command.required_capability();
|
||||||
|
if granted < required {
|
||||||
|
return Err(WorkspaceError::CapabilityDenied { required, granted });
|
||||||
|
}
|
||||||
|
|
||||||
|
let event = self.apply_command(request.command.clone())?;
|
||||||
|
self.applied_requests
|
||||||
|
.insert(request.request_id.clone(), request);
|
||||||
|
self.revision = self.revision.saturating_add(1);
|
||||||
|
Ok(WorkspaceApplyOutcome {
|
||||||
|
revision: self.revision,
|
||||||
|
duplicate: false,
|
||||||
|
event: Some(event),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a setup plan atomically. Any rejected command leaves the
|
||||||
|
/// original workspace unchanged.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns the first command error without committing partial state.
|
||||||
|
pub fn apply_plan(
|
||||||
|
&mut self,
|
||||||
|
requests: impl IntoIterator<Item = WorkspaceRequest>,
|
||||||
|
granted: WorkspaceCapability,
|
||||||
|
) -> Result<Vec<WorkspaceApplyOutcome>, WorkspaceError> {
|
||||||
|
let mut candidate = self.clone();
|
||||||
|
let mut outcomes = Vec::new();
|
||||||
|
for request in requests {
|
||||||
|
outcomes.push(candidate.apply(request, granted)?);
|
||||||
|
}
|
||||||
|
*self = candidate;
|
||||||
|
Ok(outcomes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_command(
|
||||||
|
&mut self,
|
||||||
|
command: WorkspaceCommand,
|
||||||
|
) -> Result<WorkspaceEvent, WorkspaceError> {
|
||||||
|
match command {
|
||||||
|
WorkspaceCommand::SplitPane {
|
||||||
|
target,
|
||||||
|
pane,
|
||||||
|
axis,
|
||||||
|
ratio,
|
||||||
|
placement,
|
||||||
|
} => {
|
||||||
|
if !self.layout.contains(&target) {
|
||||||
|
return Err(WorkspaceError::PaneNotFound(target));
|
||||||
|
}
|
||||||
|
if self.panes.contains_key(&pane.id) {
|
||||||
|
return Err(WorkspaceError::PaneAlreadyExists(pane.id));
|
||||||
|
}
|
||||||
|
pane.validate()?;
|
||||||
|
let pane_id = pane.id.clone();
|
||||||
|
let did_split = self
|
||||||
|
.layout
|
||||||
|
.split(&target, pane_id.clone(), axis, ratio, placement);
|
||||||
|
debug_assert!(did_split, "target presence was checked");
|
||||||
|
self.panes.insert(pane_id.clone(), pane);
|
||||||
|
self.selected = pane_id.clone();
|
||||||
|
Ok(WorkspaceEvent::PaneSplit {
|
||||||
|
target,
|
||||||
|
pane: pane_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
WorkspaceCommand::SelectPane { pane } => {
|
||||||
|
if !self.panes.contains_key(&pane) {
|
||||||
|
return Err(WorkspaceError::PaneNotFound(pane));
|
||||||
|
}
|
||||||
|
self.selected = pane.clone();
|
||||||
|
Ok(WorkspaceEvent::PaneSelected { pane })
|
||||||
|
}
|
||||||
|
WorkspaceCommand::RenamePane { pane, title } => {
|
||||||
|
if title.trim().is_empty() || title.chars().any(char::is_control) {
|
||||||
|
return Err(WorkspaceError::InvalidPane("pane title"));
|
||||||
|
}
|
||||||
|
let definition = self
|
||||||
|
.panes
|
||||||
|
.get_mut(&pane)
|
||||||
|
.ok_or_else(|| WorkspaceError::PaneNotFound(pane.clone()))?;
|
||||||
|
definition.title = title;
|
||||||
|
Ok(WorkspaceEvent::PaneRenamed { pane })
|
||||||
|
}
|
||||||
|
WorkspaceCommand::ClosePane { pane, disposition } => {
|
||||||
|
if self.panes.len() == 1 && self.panes.contains_key(&pane) {
|
||||||
|
return Err(WorkspaceError::CannotCloseLastPane);
|
||||||
|
}
|
||||||
|
if !self.panes.contains_key(&pane) {
|
||||||
|
return Err(WorkspaceError::PaneNotFound(pane));
|
||||||
|
}
|
||||||
|
let layout = self
|
||||||
|
.layout
|
||||||
|
.clone()
|
||||||
|
.remove(&pane)
|
||||||
|
.ok_or(WorkspaceError::CannotCloseLastPane)?;
|
||||||
|
self.panes.remove(&pane);
|
||||||
|
self.layout = layout;
|
||||||
|
if self.selected == pane {
|
||||||
|
self.selected = self.layout.first_pane().clone();
|
||||||
|
}
|
||||||
|
Ok(WorkspaceEvent::PaneClosed { pane, disposition })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for WorkspaceEvent {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::PaneSplit { target, pane } => formatter
|
||||||
|
.debug_struct("PaneSplit")
|
||||||
|
.field("target", target)
|
||||||
|
.field("pane", pane)
|
||||||
|
.finish(),
|
||||||
|
Self::PaneSelected { pane } => formatter
|
||||||
|
.debug_struct("PaneSelected")
|
||||||
|
.field("pane", pane)
|
||||||
|
.finish(),
|
||||||
|
Self::PaneRenamed { pane } => formatter
|
||||||
|
.debug_struct("PaneRenamed")
|
||||||
|
.field("pane", pane)
|
||||||
|
.finish(),
|
||||||
|
Self::PaneClosed { pane, disposition } => formatter
|
||||||
|
.debug_struct("PaneClosed")
|
||||||
|
.field("pane", pane)
|
||||||
|
.field("disposition", disposition)
|
||||||
|
.finish(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
CommandOrigin, CommandRequestId, LayoutNode, PaneCloseDisposition, PaneDefinition, PaneId,
|
||||||
|
PaneLaunchIntent, PanePlacement, PaneSurface, SplitAxis, SplitRatio, WorkspaceCapability,
|
||||||
|
WorkspaceCommand, WorkspaceError, WorkspaceId, WorkspaceRequest, WorkspaceState,
|
||||||
|
};
|
||||||
|
use crate::ExecutionTarget;
|
||||||
|
|
||||||
|
fn id<T>(constructor: impl FnOnce(String) -> Result<T, WorkspaceError>, value: &str) -> T {
|
||||||
|
constructor(value.to_owned()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pane(value: &str, launch: bool) -> PaneDefinition {
|
||||||
|
PaneDefinition {
|
||||||
|
id: id(PaneId::new, value),
|
||||||
|
title: value.to_owned(),
|
||||||
|
surface: PaneSurface::Terminal,
|
||||||
|
execution_target: ExecutionTarget::Local,
|
||||||
|
launch: launch.then_some(PaneLaunchIntent::UserShell {
|
||||||
|
working_directory: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(value: &str, command: WorkspaceCommand) -> WorkspaceRequest {
|
||||||
|
WorkspaceRequest {
|
||||||
|
request_id: id(CommandRequestId::new, value),
|
||||||
|
origin: CommandOrigin::Agent {
|
||||||
|
identity: "test-agent".into(),
|
||||||
|
},
|
||||||
|
command,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace() -> WorkspaceState {
|
||||||
|
WorkspaceState::new(id(WorkspaceId::new, "ws"), "Workspace", pane("root", false)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_plan_builds_a_deterministic_split_tree() {
|
||||||
|
let mut state = workspace();
|
||||||
|
let requests = [
|
||||||
|
request(
|
||||||
|
"split-1",
|
||||||
|
WorkspaceCommand::SplitPane {
|
||||||
|
target: id(PaneId::new, "root"),
|
||||||
|
pane: pane("agent", false),
|
||||||
|
axis: SplitAxis::Horizontal,
|
||||||
|
ratio: SplitRatio::default(),
|
||||||
|
placement: PanePlacement::After,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
request(
|
||||||
|
"split-2",
|
||||||
|
WorkspaceCommand::SplitPane {
|
||||||
|
target: id(PaneId::new, "agent"),
|
||||||
|
pane: pane("tests", false),
|
||||||
|
axis: SplitAxis::Vertical,
|
||||||
|
ratio: SplitRatio::from_permille(600).unwrap(),
|
||||||
|
placement: PanePlacement::Before,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let outcomes = state
|
||||||
|
.apply_plan(requests, WorkspaceCapability::Configure)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outcomes.len(), 2);
|
||||||
|
assert_eq!(state.pane_count(), 3);
|
||||||
|
assert_eq!(state.selected().as_str(), "tests");
|
||||||
|
assert_eq!(state.revision(), 2);
|
||||||
|
assert!(matches!(state.layout(), LayoutNode::Split { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_request_is_idempotent() {
|
||||||
|
let mut state = workspace();
|
||||||
|
let command = request(
|
||||||
|
"select-root",
|
||||||
|
WorkspaceCommand::SelectPane {
|
||||||
|
pane: id(PaneId::new, "root"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let first = state
|
||||||
|
.apply(command.clone(), WorkspaceCapability::Configure)
|
||||||
|
.unwrap();
|
||||||
|
let second = state
|
||||||
|
.apply(command, WorkspaceCapability::Configure)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!first.duplicate);
|
||||||
|
assert!(second.duplicate);
|
||||||
|
assert_eq!(state.revision(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reused_request_id_must_match_origin_and_command() {
|
||||||
|
let mut state = workspace();
|
||||||
|
state
|
||||||
|
.apply(
|
||||||
|
request(
|
||||||
|
"shared-id",
|
||||||
|
WorkspaceCommand::SelectPane {
|
||||||
|
pane: id(PaneId::new, "root"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
WorkspaceCapability::Configure,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let conflicting = request(
|
||||||
|
"shared-id",
|
||||||
|
WorkspaceCommand::RenamePane {
|
||||||
|
pane: id(PaneId::new, "root"),
|
||||||
|
title: "Different".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
state.apply(conflicting, WorkspaceCapability::Configure),
|
||||||
|
Err(WorkspaceError::RequestConflict(_))
|
||||||
|
));
|
||||||
|
assert_eq!(state.pane(&id(PaneId::new, "root")).unwrap().title, "root");
|
||||||
|
assert_eq!(state.revision(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unattributed_agent_requests() {
|
||||||
|
let mut state = workspace();
|
||||||
|
let request = WorkspaceRequest {
|
||||||
|
request_id: id(CommandRequestId::new, "origin"),
|
||||||
|
origin: CommandOrigin::Agent {
|
||||||
|
identity: "\n".into(),
|
||||||
|
},
|
||||||
|
command: WorkspaceCommand::SelectPane {
|
||||||
|
pane: id(PaneId::new, "root"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
state.apply(request, WorkspaceCapability::Configure),
|
||||||
|
Err(WorkspaceError::InvalidOrigin)
|
||||||
|
));
|
||||||
|
assert_eq!(state.revision(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launch_and_termination_require_execute_capability() {
|
||||||
|
let mut state = workspace();
|
||||||
|
let launch = request(
|
||||||
|
"launch",
|
||||||
|
WorkspaceCommand::SplitPane {
|
||||||
|
target: id(PaneId::new, "root"),
|
||||||
|
pane: pane("shell", true),
|
||||||
|
axis: SplitAxis::Horizontal,
|
||||||
|
ratio: SplitRatio::default(),
|
||||||
|
placement: PanePlacement::After,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
state.apply(launch.clone(), WorkspaceCapability::Configure),
|
||||||
|
Err(WorkspaceError::CapabilityDenied { .. })
|
||||||
|
));
|
||||||
|
state.apply(launch, WorkspaceCapability::Execute).unwrap();
|
||||||
|
let terminate = request(
|
||||||
|
"terminate",
|
||||||
|
WorkspaceCommand::ClosePane {
|
||||||
|
pane: id(PaneId::new, "shell"),
|
||||||
|
disposition: PaneCloseDisposition::Terminate,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
state.apply(terminate, WorkspaceCapability::Configure),
|
||||||
|
Err(WorkspaceError::CapabilityDenied { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_plan_is_atomic() {
|
||||||
|
let mut state = workspace();
|
||||||
|
let before = state.clone();
|
||||||
|
let requests = [
|
||||||
|
request(
|
||||||
|
"good",
|
||||||
|
WorkspaceCommand::SplitPane {
|
||||||
|
target: id(PaneId::new, "root"),
|
||||||
|
pane: pane("new", false),
|
||||||
|
axis: SplitAxis::Horizontal,
|
||||||
|
ratio: SplitRatio::default(),
|
||||||
|
placement: PanePlacement::After,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
request(
|
||||||
|
"bad",
|
||||||
|
WorkspaceCommand::SelectPane {
|
||||||
|
pane: id(PaneId::new, "missing"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
assert!(
|
||||||
|
state
|
||||||
|
.apply_plan(requests, WorkspaceCapability::Configure)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(state == before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_promotes_sibling_and_never_closes_last_pane() {
|
||||||
|
let mut state = workspace();
|
||||||
|
state
|
||||||
|
.apply(
|
||||||
|
request(
|
||||||
|
"split",
|
||||||
|
WorkspaceCommand::SplitPane {
|
||||||
|
target: id(PaneId::new, "root"),
|
||||||
|
pane: pane("second", false),
|
||||||
|
axis: SplitAxis::Horizontal,
|
||||||
|
ratio: SplitRatio::default(),
|
||||||
|
placement: PanePlacement::After,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
WorkspaceCapability::Configure,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
state
|
||||||
|
.apply(
|
||||||
|
request(
|
||||||
|
"close",
|
||||||
|
WorkspaceCommand::ClosePane {
|
||||||
|
pane: id(PaneId::new, "second"),
|
||||||
|
disposition: PaneCloseDisposition::Detach,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
WorkspaceCapability::Configure,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(state.selected().as_str(), "root");
|
||||||
|
assert_eq!(state.pane_count(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
state.apply(
|
||||||
|
request(
|
||||||
|
"last",
|
||||||
|
WorkspaceCommand::ClosePane {
|
||||||
|
pane: id(PaneId::new, "root"),
|
||||||
|
disposition: PaneCloseDisposition::Detach,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
WorkspaceCapability::Configure
|
||||||
|
),
|
||||||
|
Err(WorkspaceError::CannotCloseLastPane)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_identifiers_ratios_and_launch_surfaces() {
|
||||||
|
assert!(PaneId::new(" ").is_err());
|
||||||
|
assert!(SplitRatio::from_permille(99).is_err());
|
||||||
|
let invalid = PaneDefinition {
|
||||||
|
id: id(PaneId::new, "browser"),
|
||||||
|
title: "Browser".into(),
|
||||||
|
surface: PaneSurface::Browser,
|
||||||
|
execution_target: ExecutionTarget::Local,
|
||||||
|
launch: Some(PaneLaunchIntent::UserShell {
|
||||||
|
working_directory: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
assert!(invalid.validate().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,5 +11,8 @@ repository.workspace = true
|
|||||||
lumbridge-pty = { path = "../lumbridge-pty" }
|
lumbridge-pty = { path = "../lumbridge-pty" }
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
lumbridge-terminal = { path = "../lumbridge-terminal" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
use lumbridge_runtime::{
|
||||||
|
CommandConfig, PtyOptions, RuntimeActor, RuntimeActorOptions, RuntimeCommand, RuntimeEvent,
|
||||||
|
TerminalSize,
|
||||||
|
};
|
||||||
|
use lumbridge_terminal::{
|
||||||
|
KeyModifiers, TerminalDimensions, TerminalEngine, TerminalEngineOptions, TerminalKey,
|
||||||
|
TerminalKeyEvent,
|
||||||
|
};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const TEST_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn actor_terminal_pipeline_accepts_input_resize_and_vt_output() {
|
||||||
|
let command = CommandConfig::new("/bin/sh")
|
||||||
|
.unwrap()
|
||||||
|
.args([
|
||||||
|
"-c",
|
||||||
|
"printf '\x1b[31mready\x1b[0m\\n'; IFS= read -r line; stty size; printf 'got:%s\\n' \"$line\"",
|
||||||
|
]);
|
||||||
|
let actor = RuntimeActor::spawn(
|
||||||
|
command,
|
||||||
|
PtyOptions::default(),
|
||||||
|
RuntimeActorOptions::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let dimensions = TerminalDimensions::new(41, 101).unwrap();
|
||||||
|
let mut terminal = TerminalEngine::new(TerminalEngineOptions {
|
||||||
|
dimensions: TerminalDimensions::default(),
|
||||||
|
scrollback_lines: 64,
|
||||||
|
});
|
||||||
|
assert!(terminal.resize(dimensions));
|
||||||
|
actor
|
||||||
|
.try_send(RuntimeCommand::Resize(TerminalSize::new(41, 101).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let text = terminal.encode_key(&TerminalKeyEvent {
|
||||||
|
key: TerminalKey::Text("hello-pipeline".into()),
|
||||||
|
modifiers: KeyModifiers::default(),
|
||||||
|
});
|
||||||
|
actor.try_send(RuntimeCommand::Input(text)).unwrap();
|
||||||
|
let enter = terminal.encode_key(&TerminalKeyEvent {
|
||||||
|
key: TerminalKey::Enter,
|
||||||
|
modifiers: KeyModifiers::default(),
|
||||||
|
});
|
||||||
|
actor.try_send(RuntimeCommand::Input(enter)).unwrap();
|
||||||
|
|
||||||
|
let deadline = Instant::now() + TEST_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||||
|
assert!(!remaining.is_zero(), "timed out waiting for pipeline exit");
|
||||||
|
match actor.recv_timeout(remaining).unwrap() {
|
||||||
|
RuntimeEvent::Output { bytes, .. } => {
|
||||||
|
let update = terminal.process(&bytes);
|
||||||
|
for response in update.outbound {
|
||||||
|
actor.try_send(RuntimeCommand::Input(response)).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RuntimeEvent::Exited { status, .. } => {
|
||||||
|
assert!(status.success());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
RuntimeEvent::Fault { message, .. } => panic!("runtime fault: {message}"),
|
||||||
|
RuntimeEvent::Started { .. } | RuntimeEvent::InputClosed { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = terminal.snapshot().plain_rows().join("\n");
|
||||||
|
assert!(contents.contains("ready"), "missing ready marker");
|
||||||
|
assert!(
|
||||||
|
contents.contains("41 101"),
|
||||||
|
"missing resized PTY dimensions"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
contents.contains("got:hello-pipeline"),
|
||||||
|
"missing echoed input"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "lumbridge-terminal"
|
||||||
|
description = "Framework-neutral terminal state, snapshots, and input encoding for Lumbridge"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
alacritty_terminal = { version = "0.26.0", default-features = false }
|
||||||
|
thiserror = "2.0"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,994 @@
|
|||||||
|
//! Framework-neutral terminal state for Lumbridge.
|
||||||
|
//!
|
||||||
|
//! This crate consumes raw PTY bytes, maintains VT state, creates immutable
|
||||||
|
//! render snapshots, and encodes human input. It deliberately owns neither a
|
||||||
|
//! PTY nor a UI framework.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use alacritty_terminal::event::{Event, EventListener, WindowSize};
|
||||||
|
use alacritty_terminal::grid::Dimensions;
|
||||||
|
use alacritty_terminal::term::cell::Flags;
|
||||||
|
use alacritty_terminal::term::{Config, Osc52, Term, TermMode, point_to_viewport};
|
||||||
|
use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Processor};
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::{Arc, Mutex, MutexGuard};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
const DEFAULT_SCROLLBACK_LINES: usize = 10_000;
|
||||||
|
const MAX_TITLE_BYTES: usize = 512;
|
||||||
|
|
||||||
|
/// Validated terminal grid and cell dimensions.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct TerminalDimensions {
|
||||||
|
rows: u16,
|
||||||
|
columns: u16,
|
||||||
|
cell_width: u16,
|
||||||
|
cell_height: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalDimensions {
|
||||||
|
/// Creates dimensions without pixel-cell measurements.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`TerminalEngineError::InvalidDimensions`] for a zero row or
|
||||||
|
/// column count.
|
||||||
|
pub fn new(rows: u16, columns: u16) -> Result<Self, TerminalEngineError> {
|
||||||
|
Self::with_cell_size(rows, columns, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates dimensions with the cell size used for terminal size queries.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`TerminalEngineError::InvalidDimensions`] for a zero row or
|
||||||
|
/// column count.
|
||||||
|
pub fn with_cell_size(
|
||||||
|
rows: u16,
|
||||||
|
columns: u16,
|
||||||
|
cell_width: u16,
|
||||||
|
cell_height: u16,
|
||||||
|
) -> Result<Self, TerminalEngineError> {
|
||||||
|
if rows == 0 || columns == 0 {
|
||||||
|
return Err(TerminalEngineError::InvalidDimensions);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
rows,
|
||||||
|
columns,
|
||||||
|
cell_width,
|
||||||
|
cell_height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn rows(self) -> u16 {
|
||||||
|
self.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn columns(self) -> u16 {
|
||||||
|
self.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn window_size(self) -> WindowSize {
|
||||||
|
WindowSize {
|
||||||
|
num_lines: self.rows,
|
||||||
|
num_cols: self.columns,
|
||||||
|
cell_width: self.cell_width,
|
||||||
|
cell_height: self.cell_height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TerminalDimensions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
rows: 24,
|
||||||
|
columns: 80,
|
||||||
|
cell_width: 0,
|
||||||
|
cell_height: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dimensions for TerminalDimensions {
|
||||||
|
fn total_lines(&self) -> usize {
|
||||||
|
usize::from(self.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn screen_lines(&self) -> usize {
|
||||||
|
usize::from(self.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn columns(&self) -> usize {
|
||||||
|
usize::from(self.columns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for a terminal state engine.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct TerminalEngineOptions {
|
||||||
|
pub dimensions: TerminalDimensions,
|
||||||
|
pub scrollback_lines: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TerminalEngineOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
dimensions: TerminalDimensions::default(),
|
||||||
|
scrollback_lines: DEFAULT_SCROLLBACK_LINES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Eq, PartialEq)]
|
||||||
|
pub enum TerminalEngineError {
|
||||||
|
#[error("terminal rows and columns must both be greater than zero")]
|
||||||
|
InvalidDimensions,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Color identity retained by an immutable terminal snapshot.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum TerminalColor {
|
||||||
|
Named(TerminalNamedColor),
|
||||||
|
Indexed(u8),
|
||||||
|
Rgb { red: u8, green: u8, blue: u8 },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum TerminalNamedColor {
|
||||||
|
Black,
|
||||||
|
Red,
|
||||||
|
Green,
|
||||||
|
Yellow,
|
||||||
|
Blue,
|
||||||
|
Magenta,
|
||||||
|
Cyan,
|
||||||
|
White,
|
||||||
|
BrightBlack,
|
||||||
|
BrightRed,
|
||||||
|
BrightGreen,
|
||||||
|
BrightYellow,
|
||||||
|
BrightBlue,
|
||||||
|
BrightMagenta,
|
||||||
|
BrightCyan,
|
||||||
|
BrightWhite,
|
||||||
|
Foreground,
|
||||||
|
Background,
|
||||||
|
Cursor,
|
||||||
|
DimBlack,
|
||||||
|
DimRed,
|
||||||
|
DimGreen,
|
||||||
|
DimYellow,
|
||||||
|
DimBlue,
|
||||||
|
DimMagenta,
|
||||||
|
DimCyan,
|
||||||
|
DimWhite,
|
||||||
|
BrightForeground,
|
||||||
|
DimForeground,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render attributes for one terminal cell.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct TerminalCellStyle(u16);
|
||||||
|
|
||||||
|
impl TerminalCellStyle {
|
||||||
|
pub const BOLD: Self = Self(1 << 0);
|
||||||
|
pub const DIM: Self = Self(1 << 1);
|
||||||
|
pub const ITALIC: Self = Self(1 << 2);
|
||||||
|
pub const UNDERLINE: Self = Self(1 << 3);
|
||||||
|
pub const DOUBLE_UNDERLINE: Self = Self(1 << 4);
|
||||||
|
pub const UNDERCURL: Self = Self(1 << 5);
|
||||||
|
pub const DOTTED_UNDERLINE: Self = Self(1 << 6);
|
||||||
|
pub const DASHED_UNDERLINE: Self = Self(1 << 7);
|
||||||
|
pub const INVERSE: Self = Self(1 << 8);
|
||||||
|
pub const HIDDEN: Self = Self(1 << 9);
|
||||||
|
pub const STRIKEOUT: Self = Self(1 << 10);
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn contains(self, other: Self) -> bool {
|
||||||
|
self.0 & other.0 == other.0
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn with(mut self, flag: Self, enabled: bool) -> Self {
|
||||||
|
if enabled {
|
||||||
|
self.0 |= flag.0;
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One immutable cell. It intentionally has no `Debug` implementation so a
|
||||||
|
/// transcript cannot be accidentally dumped through snapshot diagnostics.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct TerminalCell {
|
||||||
|
pub text: String,
|
||||||
|
pub foreground: TerminalColor,
|
||||||
|
pub background: TerminalColor,
|
||||||
|
pub style: TerminalCellStyle,
|
||||||
|
pub wide: bool,
|
||||||
|
pub wide_spacer: bool,
|
||||||
|
pub hyperlink: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum TerminalCursorShape {
|
||||||
|
Block,
|
||||||
|
Underline,
|
||||||
|
Beam,
|
||||||
|
HollowBlock,
|
||||||
|
Hidden,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct TerminalCursor {
|
||||||
|
pub row: u16,
|
||||||
|
pub column: u16,
|
||||||
|
pub shape: TerminalCursorShape,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct TerminalModes(u16);
|
||||||
|
|
||||||
|
impl TerminalModes {
|
||||||
|
pub const ALTERNATE_SCREEN: Self = Self(1 << 0);
|
||||||
|
pub const APPLICATION_CURSOR: Self = Self(1 << 1);
|
||||||
|
pub const APPLICATION_KEYPAD: Self = Self(1 << 2);
|
||||||
|
pub const BRACKETED_PASTE: Self = Self(1 << 3);
|
||||||
|
pub const MOUSE_REPORTING: Self = Self(1 << 4);
|
||||||
|
pub const FOCUS_REPORTING: Self = Self(1 << 5);
|
||||||
|
pub const KITTY_KEYBOARD: Self = Self(1 << 6);
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn contains(self, other: Self) -> bool {
|
||||||
|
self.0 & other.0 == other.0
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn with(mut self, flag: Self, enabled: bool) -> Self {
|
||||||
|
if enabled {
|
||||||
|
self.0 |= flag.0;
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable, framework-neutral render state. It intentionally omits `Debug`
|
||||||
|
/// because cells and titles may contain private terminal content.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct TerminalSnapshot {
|
||||||
|
pub revision: u64,
|
||||||
|
pub dimensions: TerminalDimensions,
|
||||||
|
pub cells: Vec<TerminalCell>,
|
||||||
|
pub cursor: TerminalCursor,
|
||||||
|
pub modes: TerminalModes,
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalSnapshot {
|
||||||
|
#[must_use]
|
||||||
|
pub fn cell(&self, row: u16, column: u16) -> Option<&TerminalCell> {
|
||||||
|
if row >= self.dimensions.rows() || column >= self.dimensions.columns() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let index = usize::from(row) * usize::from(self.dimensions.columns()) + usize::from(column);
|
||||||
|
self.cells.get(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns visible plain-text rows for simple UI adapters and tests.
|
||||||
|
#[must_use]
|
||||||
|
pub fn plain_rows(&self) -> Vec<String> {
|
||||||
|
(0..self.dimensions.rows())
|
||||||
|
.map(|row| {
|
||||||
|
let mut text = String::new();
|
||||||
|
for column in 0..self.dimensions.columns() {
|
||||||
|
let Some(cell) = self.cell(row, column) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if cell.wide_spacer {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if cell.text.is_empty() || cell.style.contains(TerminalCellStyle::HIDDEN) {
|
||||||
|
text.push(' ');
|
||||||
|
} else {
|
||||||
|
text.push_str(&cell.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text.trim_end().to_owned()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum TerminalQueryKind {
|
||||||
|
Color,
|
||||||
|
Clipboard,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Semantic terminal event safe to pass to the application boundary.
|
||||||
|
/// Transcript-bearing title values are redacted from `Debug`.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub enum TerminalEvent {
|
||||||
|
Bell,
|
||||||
|
TitleChanged(String),
|
||||||
|
TitleReset,
|
||||||
|
ClipboardOperationBlocked,
|
||||||
|
UnsupportedQuery(TerminalQueryKind),
|
||||||
|
ExitRequested,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for TerminalEvent {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Bell => formatter.write_str("Bell"),
|
||||||
|
Self::TitleChanged(title) => formatter
|
||||||
|
.debug_struct("TitleChanged")
|
||||||
|
.field("bytes", &title.len())
|
||||||
|
.finish_non_exhaustive(),
|
||||||
|
Self::TitleReset => formatter.write_str("TitleReset"),
|
||||||
|
Self::ClipboardOperationBlocked => formatter.write_str("ClipboardOperationBlocked"),
|
||||||
|
Self::UnsupportedQuery(kind) => formatter
|
||||||
|
.debug_tuple("UnsupportedQuery")
|
||||||
|
.field(kind)
|
||||||
|
.finish(),
|
||||||
|
Self::ExitRequested => formatter.write_str("ExitRequested"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of processing PTY output. `outbound` contains terminal-protocol
|
||||||
|
/// responses that must be serialized through the same actor input queue as
|
||||||
|
/// human input.
|
||||||
|
pub struct TerminalUpdate {
|
||||||
|
pub revision: u64,
|
||||||
|
pub outbound: Vec<Vec<u8>>,
|
||||||
|
pub events: Vec<TerminalEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct KeyModifiers(u8);
|
||||||
|
|
||||||
|
impl KeyModifiers {
|
||||||
|
pub const SHIFT: Self = Self(1 << 0);
|
||||||
|
pub const ALT: Self = Self(1 << 1);
|
||||||
|
pub const CONTROL: Self = Self(1 << 2);
|
||||||
|
pub const PLATFORM: Self = Self(1 << 3);
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn contains(self, other: Self) -> bool {
|
||||||
|
self.0 & other.0 == other.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn union(self, other: Self) -> Self {
|
||||||
|
Self(self.0 | other.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum TerminalKey {
|
||||||
|
Text(String),
|
||||||
|
Enter,
|
||||||
|
Backspace,
|
||||||
|
Tab,
|
||||||
|
Escape,
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Home,
|
||||||
|
End,
|
||||||
|
Insert,
|
||||||
|
Delete,
|
||||||
|
PageUp,
|
||||||
|
PageDown,
|
||||||
|
Function(u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct TerminalKeyEvent {
|
||||||
|
pub key: TerminalKey,
|
||||||
|
pub modifiers: KeyModifiers,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ProxyState {
|
||||||
|
dimensions: TerminalDimensions,
|
||||||
|
outbound: Vec<Vec<u8>>,
|
||||||
|
events: Vec<TerminalEvent>,
|
||||||
|
title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct EventProxy {
|
||||||
|
state: Arc<Mutex<ProxyState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventProxy {
|
||||||
|
fn lock(&self) -> MutexGuard<'_, ProxyState> {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_dimensions(&self, dimensions: TerminalDimensions) {
|
||||||
|
self.lock().dimensions = dimensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain(&self, revision: u64) -> TerminalUpdate {
|
||||||
|
let mut state = self.lock();
|
||||||
|
TerminalUpdate {
|
||||||
|
revision,
|
||||||
|
outbound: std::mem::take(&mut state.outbound),
|
||||||
|
events: std::mem::take(&mut state.events),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self) -> Option<String> {
|
||||||
|
self.lock().title.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventListener for EventProxy {
|
||||||
|
fn send_event(&self, event: Event) {
|
||||||
|
let mut state = self.lock();
|
||||||
|
match event {
|
||||||
|
Event::PtyWrite(text) => state.outbound.push(text.into_bytes()),
|
||||||
|
Event::TextAreaSizeRequest(formatter) => {
|
||||||
|
let window_size = state.dimensions.window_size();
|
||||||
|
state.outbound.push(formatter(window_size).into_bytes());
|
||||||
|
}
|
||||||
|
Event::Title(title) => {
|
||||||
|
let title = sanitize_title(&title);
|
||||||
|
state.title = Some(title.clone());
|
||||||
|
state.events.push(TerminalEvent::TitleChanged(title));
|
||||||
|
}
|
||||||
|
Event::ResetTitle => {
|
||||||
|
state.title = None;
|
||||||
|
state.events.push(TerminalEvent::TitleReset);
|
||||||
|
}
|
||||||
|
Event::Bell => state.events.push(TerminalEvent::Bell),
|
||||||
|
Event::ClipboardStore(_, _) | Event::ClipboardLoad(_, _) => {
|
||||||
|
state.events.push(TerminalEvent::ClipboardOperationBlocked);
|
||||||
|
}
|
||||||
|
Event::ColorRequest(_, _) => state
|
||||||
|
.events
|
||||||
|
.push(TerminalEvent::UnsupportedQuery(TerminalQueryKind::Color)),
|
||||||
|
Event::Exit => state.events.push(TerminalEvent::ExitRequested),
|
||||||
|
Event::MouseCursorDirty
|
||||||
|
| Event::CursorBlinkingChange
|
||||||
|
| Event::Wakeup
|
||||||
|
| Event::ChildExit(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-owner terminal state engine.
|
||||||
|
pub struct TerminalEngine {
|
||||||
|
parser: Processor,
|
||||||
|
terminal: Term<EventProxy>,
|
||||||
|
proxy: EventProxy,
|
||||||
|
dimensions: TerminalDimensions,
|
||||||
|
revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalEngine {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(options: TerminalEngineOptions) -> Self {
|
||||||
|
let proxy = EventProxy::default();
|
||||||
|
proxy.set_dimensions(options.dimensions);
|
||||||
|
let config = Config {
|
||||||
|
scrolling_history: options.scrollback_lines,
|
||||||
|
osc52: Osc52::Disabled,
|
||||||
|
..Config::default()
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
parser: Processor::new(),
|
||||||
|
terminal: Term::new(config, &options.dimensions, proxy.clone()),
|
||||||
|
proxy,
|
||||||
|
dimensions: options.dimensions,
|
||||||
|
revision: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies ordered raw PTY bytes and returns protocol responses/events.
|
||||||
|
pub fn process(&mut self, bytes: &[u8]) -> TerminalUpdate {
|
||||||
|
if !bytes.is_empty() {
|
||||||
|
self.parser.advance(&mut self.terminal, bytes);
|
||||||
|
self.revision = self.revision.saturating_add(1);
|
||||||
|
}
|
||||||
|
self.proxy.drain(self.revision)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resizes terminal state. The caller must send the equivalent PTY resize
|
||||||
|
/// through the runtime actor to keep both sides synchronized.
|
||||||
|
pub fn resize(&mut self, dimensions: TerminalDimensions) -> bool {
|
||||||
|
if self.dimensions == dimensions {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.terminal.resize(dimensions);
|
||||||
|
self.proxy.set_dimensions(dimensions);
|
||||||
|
self.dimensions = dimensions;
|
||||||
|
self.revision = self.revision.saturating_add(1);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn dimensions(&self) -> TerminalDimensions {
|
||||||
|
self.dimensions
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn revision(&self) -> u64 {
|
||||||
|
self.revision
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a key using the terminal's current input modes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn encode_key(&self, event: &TerminalKeyEvent) -> Vec<u8> {
|
||||||
|
encode_key(event, *self.terminal.mode())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes paste delimiters only when the child enabled bracketed paste.
|
||||||
|
#[must_use]
|
||||||
|
pub fn encode_paste(&self, text: &str) -> Vec<u8> {
|
||||||
|
if self.terminal.mode().contains(TermMode::BRACKETED_PASTE) {
|
||||||
|
let mut bytes = Vec::with_capacity(text.len() + 12);
|
||||||
|
bytes.extend_from_slice(b"\x1b[200~");
|
||||||
|
bytes.extend_from_slice(text.as_bytes());
|
||||||
|
bytes.extend_from_slice(b"\x1b[201~");
|
||||||
|
bytes
|
||||||
|
} else {
|
||||||
|
text.as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn snapshot(&self) -> TerminalSnapshot {
|
||||||
|
let renderable = self.terminal.renderable_content();
|
||||||
|
let cursor_point = point_to_viewport(renderable.display_offset, renderable.cursor.point)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut cells = vec![
|
||||||
|
blank_cell();
|
||||||
|
usize::from(self.dimensions.rows)
|
||||||
|
* usize::from(self.dimensions.columns)
|
||||||
|
];
|
||||||
|
|
||||||
|
for indexed in renderable.display_iter {
|
||||||
|
let Some(point) = point_to_viewport(renderable.display_offset, indexed.point) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if point.line >= usize::from(self.dimensions.rows)
|
||||||
|
|| point.column.0 >= usize::from(self.dimensions.columns)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let index = point.line * usize::from(self.dimensions.columns) + point.column.0;
|
||||||
|
cells[index] = snapshot_cell(indexed.cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
TerminalSnapshot {
|
||||||
|
revision: self.revision,
|
||||||
|
dimensions: self.dimensions,
|
||||||
|
cells,
|
||||||
|
cursor: TerminalCursor {
|
||||||
|
row: u16::try_from(cursor_point.line).unwrap_or(u16::MAX),
|
||||||
|
column: u16::try_from(cursor_point.column.0).unwrap_or(u16::MAX),
|
||||||
|
shape: map_cursor_shape(renderable.cursor.shape),
|
||||||
|
},
|
||||||
|
modes: snapshot_modes(renderable.mode),
|
||||||
|
title: self.proxy.title(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TerminalEngine {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(TerminalEngineOptions::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_title(title: &str) -> String {
|
||||||
|
title
|
||||||
|
.chars()
|
||||||
|
.filter(|character| !character.is_control())
|
||||||
|
.scan(0_usize, |bytes, character| {
|
||||||
|
let next = *bytes + character.len_utf8();
|
||||||
|
if next > MAX_TITLE_BYTES {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
*bytes = next;
|
||||||
|
Some(character)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_cell(cell: &alacritty_terminal::term::cell::Cell) -> TerminalCell {
|
||||||
|
let mut text = String::new();
|
||||||
|
if !cell
|
||||||
|
.flags
|
||||||
|
.contains(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
|
||||||
|
{
|
||||||
|
text.push(cell.c);
|
||||||
|
if let Some(zero_width) = cell.zerowidth() {
|
||||||
|
text.extend(zero_width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TerminalCell {
|
||||||
|
text,
|
||||||
|
foreground: map_color(cell.fg),
|
||||||
|
background: map_color(cell.bg),
|
||||||
|
style: TerminalCellStyle::default()
|
||||||
|
.with(TerminalCellStyle::BOLD, cell.flags.contains(Flags::BOLD))
|
||||||
|
.with(TerminalCellStyle::DIM, cell.flags.contains(Flags::DIM))
|
||||||
|
.with(
|
||||||
|
TerminalCellStyle::ITALIC,
|
||||||
|
cell.flags.contains(Flags::ITALIC),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
TerminalCellStyle::UNDERLINE,
|
||||||
|
cell.flags.contains(Flags::UNDERLINE),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
TerminalCellStyle::DOUBLE_UNDERLINE,
|
||||||
|
cell.flags.contains(Flags::DOUBLE_UNDERLINE),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
TerminalCellStyle::UNDERCURL,
|
||||||
|
cell.flags.contains(Flags::UNDERCURL),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
TerminalCellStyle::DOTTED_UNDERLINE,
|
||||||
|
cell.flags.contains(Flags::DOTTED_UNDERLINE),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
TerminalCellStyle::DASHED_UNDERLINE,
|
||||||
|
cell.flags.contains(Flags::DASHED_UNDERLINE),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
TerminalCellStyle::INVERSE,
|
||||||
|
cell.flags.contains(Flags::INVERSE),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
TerminalCellStyle::HIDDEN,
|
||||||
|
cell.flags.contains(Flags::HIDDEN),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
TerminalCellStyle::STRIKEOUT,
|
||||||
|
cell.flags.contains(Flags::STRIKEOUT),
|
||||||
|
),
|
||||||
|
wide: cell.flags.contains(Flags::WIDE_CHAR),
|
||||||
|
wide_spacer: cell.flags.contains(Flags::WIDE_CHAR_SPACER),
|
||||||
|
hyperlink: cell.hyperlink().map(|link| link.uri().to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blank_cell() -> TerminalCell {
|
||||||
|
snapshot_cell(&alacritty_terminal::term::cell::Cell::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_modes(mode: TermMode) -> TerminalModes {
|
||||||
|
TerminalModes::default()
|
||||||
|
.with(
|
||||||
|
TerminalModes::ALTERNATE_SCREEN,
|
||||||
|
mode.contains(TermMode::ALT_SCREEN),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
TerminalModes::APPLICATION_CURSOR,
|
||||||
|
mode.contains(TermMode::APP_CURSOR),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
TerminalModes::APPLICATION_KEYPAD,
|
||||||
|
mode.contains(TermMode::APP_KEYPAD),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
TerminalModes::BRACKETED_PASTE,
|
||||||
|
mode.contains(TermMode::BRACKETED_PASTE),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
TerminalModes::MOUSE_REPORTING,
|
||||||
|
mode.intersects(TermMode::MOUSE_MODE),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
TerminalModes::FOCUS_REPORTING,
|
||||||
|
mode.contains(TermMode::FOCUS_IN_OUT),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
TerminalModes::KITTY_KEYBOARD,
|
||||||
|
mode.intersects(TermMode::KITTY_KEYBOARD_PROTOCOL),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn map_cursor_shape(shape: CursorShape) -> TerminalCursorShape {
|
||||||
|
match shape {
|
||||||
|
CursorShape::Block => TerminalCursorShape::Block,
|
||||||
|
CursorShape::Underline => TerminalCursorShape::Underline,
|
||||||
|
CursorShape::Beam => TerminalCursorShape::Beam,
|
||||||
|
CursorShape::HollowBlock => TerminalCursorShape::HollowBlock,
|
||||||
|
CursorShape::Hidden => TerminalCursorShape::Hidden,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn map_color(color: Color) -> TerminalColor {
|
||||||
|
match color {
|
||||||
|
Color::Named(named) => TerminalColor::Named(map_named_color(named)),
|
||||||
|
Color::Indexed(index) => TerminalColor::Indexed(index),
|
||||||
|
Color::Spec(rgb) => TerminalColor::Rgb {
|
||||||
|
red: rgb.r,
|
||||||
|
green: rgb.g,
|
||||||
|
blue: rgb.b,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn map_named_color(color: NamedColor) -> TerminalNamedColor {
|
||||||
|
match color {
|
||||||
|
NamedColor::Black => TerminalNamedColor::Black,
|
||||||
|
NamedColor::Red => TerminalNamedColor::Red,
|
||||||
|
NamedColor::Green => TerminalNamedColor::Green,
|
||||||
|
NamedColor::Yellow => TerminalNamedColor::Yellow,
|
||||||
|
NamedColor::Blue => TerminalNamedColor::Blue,
|
||||||
|
NamedColor::Magenta => TerminalNamedColor::Magenta,
|
||||||
|
NamedColor::Cyan => TerminalNamedColor::Cyan,
|
||||||
|
NamedColor::White => TerminalNamedColor::White,
|
||||||
|
NamedColor::BrightBlack => TerminalNamedColor::BrightBlack,
|
||||||
|
NamedColor::BrightRed => TerminalNamedColor::BrightRed,
|
||||||
|
NamedColor::BrightGreen => TerminalNamedColor::BrightGreen,
|
||||||
|
NamedColor::BrightYellow => TerminalNamedColor::BrightYellow,
|
||||||
|
NamedColor::BrightBlue => TerminalNamedColor::BrightBlue,
|
||||||
|
NamedColor::BrightMagenta => TerminalNamedColor::BrightMagenta,
|
||||||
|
NamedColor::BrightCyan => TerminalNamedColor::BrightCyan,
|
||||||
|
NamedColor::BrightWhite => TerminalNamedColor::BrightWhite,
|
||||||
|
NamedColor::Foreground => TerminalNamedColor::Foreground,
|
||||||
|
NamedColor::Background => TerminalNamedColor::Background,
|
||||||
|
NamedColor::Cursor => TerminalNamedColor::Cursor,
|
||||||
|
NamedColor::DimBlack => TerminalNamedColor::DimBlack,
|
||||||
|
NamedColor::DimRed => TerminalNamedColor::DimRed,
|
||||||
|
NamedColor::DimGreen => TerminalNamedColor::DimGreen,
|
||||||
|
NamedColor::DimYellow => TerminalNamedColor::DimYellow,
|
||||||
|
NamedColor::DimBlue => TerminalNamedColor::DimBlue,
|
||||||
|
NamedColor::DimMagenta => TerminalNamedColor::DimMagenta,
|
||||||
|
NamedColor::DimCyan => TerminalNamedColor::DimCyan,
|
||||||
|
NamedColor::DimWhite => TerminalNamedColor::DimWhite,
|
||||||
|
NamedColor::BrightForeground => TerminalNamedColor::BrightForeground,
|
||||||
|
NamedColor::DimForeground => TerminalNamedColor::DimForeground,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_key(event: &TerminalKeyEvent, mode: TermMode) -> Vec<u8> {
|
||||||
|
let modifiers = event.modifiers;
|
||||||
|
match &event.key {
|
||||||
|
TerminalKey::Text(text) => encode_text(text, modifiers),
|
||||||
|
TerminalKey::Enter => with_alt(b"\r".to_vec(), modifiers.contains(KeyModifiers::ALT)),
|
||||||
|
TerminalKey::Backspace => with_alt(vec![0x7f], modifiers.contains(KeyModifiers::ALT)),
|
||||||
|
TerminalKey::Tab if modifiers.contains(KeyModifiers::SHIFT) => b"\x1b[Z".to_vec(),
|
||||||
|
TerminalKey::Tab => with_alt(b"\t".to_vec(), modifiers.contains(KeyModifiers::ALT)),
|
||||||
|
TerminalKey::Escape => b"\x1b".to_vec(),
|
||||||
|
TerminalKey::Up => encode_cursor_key('A', modifiers, mode),
|
||||||
|
TerminalKey::Down => encode_cursor_key('B', modifiers, mode),
|
||||||
|
TerminalKey::Right => encode_cursor_key('C', modifiers, mode),
|
||||||
|
TerminalKey::Left => encode_cursor_key('D', modifiers, mode),
|
||||||
|
TerminalKey::Home => encode_cursor_key('H', modifiers, mode),
|
||||||
|
TerminalKey::End => encode_cursor_key('F', modifiers, mode),
|
||||||
|
TerminalKey::Insert => encode_csi_tilde(2, modifiers),
|
||||||
|
TerminalKey::Delete => encode_csi_tilde(3, modifiers),
|
||||||
|
TerminalKey::PageUp => encode_csi_tilde(5, modifiers),
|
||||||
|
TerminalKey::PageDown => encode_csi_tilde(6, modifiers),
|
||||||
|
TerminalKey::Function(number) => encode_function(*number, modifiers),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_text(text: &str, modifiers: KeyModifiers) -> Vec<u8> {
|
||||||
|
let mut bytes = if modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
control_text(text).unwrap_or_else(|| text.as_bytes().to_vec())
|
||||||
|
} else {
|
||||||
|
text.as_bytes().to_vec()
|
||||||
|
};
|
||||||
|
if modifiers.contains(KeyModifiers::ALT) {
|
||||||
|
bytes.insert(0, 0x1b);
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn control_text(text: &str) -> Option<Vec<u8>> {
|
||||||
|
let mut characters = text.chars();
|
||||||
|
let character = characters.next()?;
|
||||||
|
if characters.next().is_some() || !character.is_ascii() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let byte = character as u8;
|
||||||
|
let control = match byte {
|
||||||
|
b'@'..=b'_' => byte - b'@',
|
||||||
|
b'a'..=b'z' => byte - b'a' + 1,
|
||||||
|
b'?' => 0x7f,
|
||||||
|
b' ' => 0,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(vec![control])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_alt(mut bytes: Vec<u8>, alt: bool) -> Vec<u8> {
|
||||||
|
if alt {
|
||||||
|
bytes.insert(0, 0x1b);
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn modifier_parameter(modifiers: KeyModifiers) -> u8 {
|
||||||
|
1 + u8::from(modifiers.contains(KeyModifiers::SHIFT))
|
||||||
|
+ 2 * u8::from(modifiers.contains(KeyModifiers::ALT))
|
||||||
|
+ 4 * u8::from(modifiers.contains(KeyModifiers::CONTROL))
|
||||||
|
+ 8 * u8::from(modifiers.contains(KeyModifiers::PLATFORM))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_cursor_key(final_byte: char, modifiers: KeyModifiers, mode: TermMode) -> Vec<u8> {
|
||||||
|
let parameter = modifier_parameter(modifiers);
|
||||||
|
if parameter == 1 && mode.contains(TermMode::APP_CURSOR) {
|
||||||
|
format!("\x1bO{final_byte}").into_bytes()
|
||||||
|
} else if parameter == 1 {
|
||||||
|
format!("\x1b[{final_byte}").into_bytes()
|
||||||
|
} else {
|
||||||
|
format!("\x1b[1;{parameter}{final_byte}").into_bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_csi_tilde(number: u8, modifiers: KeyModifiers) -> Vec<u8> {
|
||||||
|
let parameter = modifier_parameter(modifiers);
|
||||||
|
if parameter == 1 {
|
||||||
|
format!("\x1b[{number}~").into_bytes()
|
||||||
|
} else {
|
||||||
|
format!("\x1b[{number};{parameter}~").into_bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_function(number: u8, modifiers: KeyModifiers) -> Vec<u8> {
|
||||||
|
let parameter = modifier_parameter(modifiers);
|
||||||
|
if let Some(final_byte) = [None, Some('P'), Some('Q'), Some('R'), Some('S')]
|
||||||
|
.get(usize::from(number))
|
||||||
|
.copied()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
if parameter == 1 {
|
||||||
|
return format!("\x1bO{final_byte}").into_bytes();
|
||||||
|
}
|
||||||
|
return format!("\x1b[1;{parameter}{final_byte}").into_bytes();
|
||||||
|
}
|
||||||
|
let code = match number {
|
||||||
|
5 => 15,
|
||||||
|
6 => 17,
|
||||||
|
7 => 18,
|
||||||
|
8 => 19,
|
||||||
|
9 => 20,
|
||||||
|
10 => 21,
|
||||||
|
11 => 23,
|
||||||
|
12 => 24,
|
||||||
|
_ => return Vec::new(),
|
||||||
|
};
|
||||||
|
encode_csi_tilde(code, modifiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
KeyModifiers, TerminalCellStyle, TerminalDimensions, TerminalEngine, TerminalEngineOptions,
|
||||||
|
TerminalEvent, TerminalKey, TerminalKeyEvent, TerminalModes, TerminalNamedColor,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn engine(rows: u16, columns: u16) -> TerminalEngine {
|
||||||
|
TerminalEngine::new(TerminalEngineOptions {
|
||||||
|
dimensions: TerminalDimensions::new(rows, columns).unwrap(),
|
||||||
|
scrollback_lines: 32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_cells_styles_unicode_cursor_and_title() {
|
||||||
|
let mut engine = engine(3, 8);
|
||||||
|
let update = engine.process(b"\x1b]2;safe\x07\x1b[31;1mhi\x1b[0m \xf0\x9f\x91\xa9\r\nnext");
|
||||||
|
assert_eq!(update.events, [TerminalEvent::TitleChanged("safe".into())]);
|
||||||
|
let snapshot = engine.snapshot();
|
||||||
|
assert_eq!(snapshot.title.as_deref(), Some("safe"));
|
||||||
|
let first = snapshot.cell(0, 0).unwrap();
|
||||||
|
assert_eq!(first.text, "h");
|
||||||
|
assert!(first.style.contains(TerminalCellStyle::BOLD));
|
||||||
|
assert_eq!(
|
||||||
|
first.foreground,
|
||||||
|
super::TerminalColor::Named(TerminalNamedColor::Red)
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.cell(0, 3).unwrap().text, "👩");
|
||||||
|
assert!(snapshot.cell(0, 3).unwrap().wide);
|
||||||
|
assert!(snapshot.cell(0, 4).unwrap().wide_spacer);
|
||||||
|
assert_eq!(snapshot.plain_rows()[1], "next");
|
||||||
|
assert_eq!((snapshot.cursor.row, snapshot.cursor.column), (1, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tracks_alternate_screen_and_bracketed_paste() {
|
||||||
|
let mut engine = engine(2, 8);
|
||||||
|
engine.process(b"primary\x1b[?1049h\x1b[H\x1b[?2004halt");
|
||||||
|
let snapshot = engine.snapshot();
|
||||||
|
assert!(snapshot.modes.contains(TerminalModes::ALTERNATE_SCREEN));
|
||||||
|
assert!(snapshot.modes.contains(TerminalModes::BRACKETED_PASTE));
|
||||||
|
assert_eq!(snapshot.plain_rows()[0], "alt");
|
||||||
|
assert_eq!(engine.encode_paste("hello"), b"\x1b[200~hello\x1b[201~");
|
||||||
|
engine.process(b"\x1b[?1049l");
|
||||||
|
assert_eq!(engine.snapshot().plain_rows()[0], "primary");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emits_protocol_replies_without_logging_input_content() {
|
||||||
|
let mut engine = engine(4, 10);
|
||||||
|
let update = engine.process(b"\x1b[6n\x1b[18t");
|
||||||
|
assert!(update.outbound.iter().any(|reply| reply == b"\x1b[1;1R"));
|
||||||
|
assert!(update.outbound.iter().any(|reply| reply == b"\x1b[8;4;10t"));
|
||||||
|
assert!(update.events.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitizes_titles_and_blocks_clipboard_protocol() {
|
||||||
|
let mut engine = engine(2, 8);
|
||||||
|
let update = engine.process(b"\x1b]2;hello\x01world\x07\x1b]52;c;aGVsbG8=\x07");
|
||||||
|
assert_eq!(engine.snapshot().title.as_deref(), Some("helloworld"));
|
||||||
|
assert!(
|
||||||
|
update
|
||||||
|
.events
|
||||||
|
.contains(&TerminalEvent::TitleChanged("helloworld".into()))
|
||||||
|
);
|
||||||
|
assert!(update.outbound.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encodes_text_control_cursor_modifiers_and_function_keys() {
|
||||||
|
let mut engine = engine(2, 8);
|
||||||
|
let text = TerminalKeyEvent {
|
||||||
|
key: TerminalKey::Text("c".into()),
|
||||||
|
modifiers: KeyModifiers::CONTROL,
|
||||||
|
};
|
||||||
|
assert_eq!(engine.encode_key(&text), [3]);
|
||||||
|
|
||||||
|
let up = TerminalKeyEvent {
|
||||||
|
key: TerminalKey::Up,
|
||||||
|
modifiers: KeyModifiers::default(),
|
||||||
|
};
|
||||||
|
assert_eq!(engine.encode_key(&up), b"\x1b[A");
|
||||||
|
engine.process(b"\x1b[?1h");
|
||||||
|
assert_eq!(engine.encode_key(&up), b"\x1bOA");
|
||||||
|
|
||||||
|
let modified = TerminalKeyEvent {
|
||||||
|
key: TerminalKey::Left,
|
||||||
|
modifiers: KeyModifiers::SHIFT.union(KeyModifiers::CONTROL),
|
||||||
|
};
|
||||||
|
assert_eq!(engine.encode_key(&modified), b"\x1b[1;6D");
|
||||||
|
|
||||||
|
let function = TerminalKeyEvent {
|
||||||
|
key: TerminalKey::Function(12),
|
||||||
|
modifiers: KeyModifiers::default(),
|
||||||
|
};
|
||||||
|
assert_eq!(engine.encode_key(&function), b"\x1b[24~");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_updates_snapshots_and_terminal_size_queries() {
|
||||||
|
let mut engine = engine(2, 8);
|
||||||
|
let dimensions = TerminalDimensions::with_cell_size(5, 12, 9, 18).unwrap();
|
||||||
|
assert!(engine.resize(dimensions));
|
||||||
|
assert!(!engine.resize(dimensions));
|
||||||
|
assert_eq!(engine.snapshot().dimensions, dimensions);
|
||||||
|
let update = engine.process(b"\x1b[14t\x1b[18t");
|
||||||
|
assert!(
|
||||||
|
update
|
||||||
|
.outbound
|
||||||
|
.iter()
|
||||||
|
.any(|reply| reply == b"\x1b[4;90;108t")
|
||||||
|
);
|
||||||
|
assert!(update.outbound.iter().any(|reply| reply == b"\x1b[8;5;12t"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_zero_dimensions() {
|
||||||
|
assert!(TerminalDimensions::new(0, 80).is_err());
|
||||||
|
assert!(TerminalDimensions::new(24, 0).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,8 +41,10 @@ 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`, `lumbridge-pty`, `lumbridge-runtime`, and the entry point.
|
`lumbridge-buzz`, `lumbridge-pty`, `lumbridge-runtime`, `lumbridge-terminal`, and
|
||||||
Larger runtime crates are added after their architecture spikes pass.
|
the entry point. `lumbridge-core` also contains the first typed workspace
|
||||||
|
command reducer. Larger runtime crates are added after their architecture spikes
|
||||||
|
pass.
|
||||||
|
|
||||||
## Terminal path
|
## Terminal path
|
||||||
|
|
||||||
@@ -55,19 +57,41 @@ 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.
|
||||||
|
|
||||||
|
The first `lumbridge-terminal` implementation wraps `alacritty_terminal` behind
|
||||||
|
a Lumbridge-owned input and immutable snapshot contract. It parses styled cells,
|
||||||
|
cursor state, alternate screen, terminal modes, title events, keyboard input,
|
||||||
|
bracketed paste, resize, and terminal-generated protocol replies. OSC 52
|
||||||
|
clipboard writes are disabled by default. The renderer never imports the
|
||||||
|
upstream terminal type, and protocol replies return through the same actor input
|
||||||
|
queue as human input. See decision 0005.
|
||||||
|
|
||||||
The first `lumbridge-runtime` actor now owns one `PtySession` on a dedicated
|
The first `lumbridge-runtime` actor now owns one `PtySession` on a dedicated
|
||||||
thread. Bounded command and event queues serialize input, resize, close, and
|
thread. Bounded command and event queues serialize input, resize, close, and
|
||||||
shutdown against ordered raw-byte output. The GPUI slice consumes one actor
|
shutdown against ordered raw-byte output. The GPUI slice feeds one actor session
|
||||||
session while five surfaces retain deterministic comparison output. This actor
|
through the terminal engine while five surfaces retain deterministic comparison
|
||||||
still runs in-process; moving the same framework-neutral contract behind local
|
output. Its current text adapter intentionally renders plain rows and does not
|
||||||
authenticated IPC is the next durability step. See decision 0004.
|
yet paint cell styles, selection, or the cursor; the next rendering slice owns
|
||||||
|
that work. This actor still runs in-process; moving the same framework-neutral
|
||||||
|
contract behind local authenticated IPC is the next durability step. See
|
||||||
|
decisions 0004 and 0005.
|
||||||
|
|
||||||
We should evaluate, not blindly copy, WezTerm, Zellij, RMUX, tmux, and cmux. The
|
We should evaluate, not blindly copy, WezTerm, Zellij, RMUX, tmux, and cmux. The
|
||||||
first spike must compare a reusable terminal crate with a small first-party layer.
|
first spike must compare a reusable terminal crate with a small first-party layer.
|
||||||
Correctness cases include alternate screen, bracketed paste, OSC 8 links, Kitty
|
Remaining correctness cases include OSC 8 links, Kitty
|
||||||
keyboard/graphics negotiation, Unicode width, IME, mouse modes, shell integration,
|
keyboard/graphics negotiation, Unicode width, IME, mouse modes, shell integration,
|
||||||
and simultaneous automation plus human input.
|
and simultaneous automation plus human input.
|
||||||
|
|
||||||
|
## Workspace command plane
|
||||||
|
|
||||||
|
Human UI, local automation, remote clients, and agents reduce the same typed
|
||||||
|
workspace commands. Stable request IDs make retries idempotent; validated pane
|
||||||
|
IDs, split ratios, launch intents, and close dispositions prevent untyped JSON
|
||||||
|
from becoming the product API. A multi-command setup plan is reduced atomically.
|
||||||
|
Observe, Configure, and Execute capabilities are checked before mutation, and a
|
||||||
|
layout-only agent cannot smuggle an executable or arbitrary arguments into a
|
||||||
|
split request. Current code is an in-process reducer; serialization, durable
|
||||||
|
events, and authenticated IPC are subsequent layers. See decision 0006.
|
||||||
|
|
||||||
## UI decision gate
|
## UI decision gate
|
||||||
|
|
||||||
Do not lock the project to a webview or to Zed's application implementation
|
Do not lock the project to a webview or to Zed's application implementation
|
||||||
@@ -129,6 +153,19 @@ available because terminal fidelity and arbitrary CLI compatibility are product
|
|||||||
requirements. Harness-specific adapters translate both paths into the same event
|
requirements. Harness-specific adapters translate both paths into the same event
|
||||||
model without pretending a PTY has capabilities it cannot prove.
|
model without pretending a PTY has capabilities it cannot prove.
|
||||||
|
|
||||||
|
Lumbridge Harness is a separate optional orchestrator, not a UI framework. The
|
||||||
|
DeepSeek Harness snapshot is a useful MIT-licensed reference for plugin seams,
|
||||||
|
durable session events, approvals, ACP, and agent lifecycle, but its developer-
|
||||||
|
preview Node/TypeScript/web composition is not embedded into the Rust desktop
|
||||||
|
process. If we maintain a fork, it lives in its own repository and communicates
|
||||||
|
through a versioned, capability-scoped protocol. A first-party Rust service may
|
||||||
|
later replace that implementation without changing the desktop contract.
|
||||||
|
|
||||||
|
The Harness can consume redacted workspace projections and emit suggestions or
|
||||||
|
typed command plans. Suggestions have no authority. Plan execution is routed
|
||||||
|
through the same command plane as human actions, and trace export to a hosted
|
||||||
|
model requires explicit scope and destination consent. See decision 0007.
|
||||||
|
|
||||||
## Usage model
|
## Usage model
|
||||||
|
|
||||||
Usage is an append-only observation stream, not a mutable percentage field.
|
Usage is an append-only observation stream, not a mutable percentage field.
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Lumbridge brand mark
|
||||||
|
|
||||||
|
## Concept
|
||||||
|
|
||||||
|
Lumbridge's symbol is an original ember-flame wrapped around a bridge arch. The
|
||||||
|
diagonal negative space also reads as a terminal chevron or an open gateway.
|
||||||
|
Together, those shapes express a fast native workspace where engineers cross
|
||||||
|
between terminals, tools, hosts, and agents.
|
||||||
|
|
||||||
|
The mark is symbol-only. Do not ship a Unicode fire emoji as the application
|
||||||
|
icon, add pseudo-medieval ornament, or attach generated lettering to the mark.
|
||||||
|
|
||||||
|
## Color
|
||||||
|
|
||||||
|
| Role | Hex | Intended surface |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Ember | `#FF6B35` | Canonical application icon and neutral contexts |
|
||||||
|
| Ember, light UI | `#D94824` | Light surfaces around `#F6F3EE` |
|
||||||
|
| Ember, dark UI | `#FF8A5B` | Dark surfaces around `#0B0F14` |
|
||||||
|
|
||||||
|
All files have transparent backgrounds. The surface colors above are usage
|
||||||
|
guidance and are not baked into the assets. Do not add gradients, drop shadows,
|
||||||
|
outlines, or extra flame colors.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
The 1024 px master is
|
||||||
|
`assets/brand/master/lumbridge-mark-master-1024.png`. Use it as the source for
|
||||||
|
new platform exports.
|
||||||
|
|
||||||
|
- `assets/brand/app-icons/lumbridge-app-{size}.png` contains the canonical Ember
|
||||||
|
icon at 16, 32, 48, 64, 128, 256, 512, and 1024 px.
|
||||||
|
- `assets/brand/light/lumbridge-mark-light-{size}.png` contains the light-UI
|
||||||
|
colorway at the same sizes.
|
||||||
|
- `assets/brand/dark/lumbridge-mark-dark-{size}.png` contains the dark-UI
|
||||||
|
colorway at the same sizes.
|
||||||
|
|
||||||
|
The existing PNGs are RGBA and square. Packaging may derive platform-native
|
||||||
|
containers such as macOS `.icns` later, but those derived files should preserve
|
||||||
|
the supplied canvas and must not stretch or crop the symbol.
|
||||||
|
|
||||||
|
## Placement
|
||||||
|
|
||||||
|
- Preserve the mark's aspect ratio and transparent canvas.
|
||||||
|
- Outside an application-icon tile, keep clear space equal to at least one
|
||||||
|
eighth of the rendered mark height on every side.
|
||||||
|
- Prefer the prepared export nearest the requested display size. Do not scale a
|
||||||
|
16 px export upward.
|
||||||
|
- At 16–24 px, use the canonical app-icon export without a wordmark, badge, or
|
||||||
|
status dot. The flame and gateway are the intended small-size reading; the
|
||||||
|
terminal chevron becomes secondary.
|
||||||
|
- Do not place detailed artwork or low-contrast orange immediately behind the
|
||||||
|
transparent mark.
|
||||||
|
|
||||||
|
## Generation and production record
|
||||||
|
|
||||||
|
The concept was generated once with the built-in `image_gen` mode using this
|
||||||
|
prompt:
|
||||||
|
|
||||||
|
> Use case: logo-brand. Asset type: native desktop application icon master for
|
||||||
|
> Lumbridge, a very high-performance Rust terminal multiplexer and AI-agent
|
||||||
|
> workspace. Create one original symbol-only brand mark that expresses
|
||||||
|
> firemaking energy, a bridge or gateway, and a terminal prompt through a single
|
||||||
|
> strong silhouette. Use a compact upright ember-flame whose interior negative
|
||||||
|
> space forms a clean bridge arch or open gateway; incorporate one extremely
|
||||||
|
> subtle angular terminal chevron idea into the geometry without adding a
|
||||||
|
> separate floating glyph. Use a vector-friendly, flat, minimal geometric mark
|
||||||
|
> with precise edges, centered on a square transparent canvas. Use warm ember
|
||||||
|
> orange. Exactly one symbol; no text, letters, wordmark, emoji glyph, gradient,
|
||||||
|
> shadow, 3D, mockup, border tile, or watermark. Avoid RuneScape imagery or
|
||||||
|
> style, game trademarks, medieval crests, literal campfires, logs, swords,
|
||||||
|
> shields, runes, detailed sparks, characters, mascots, and decorative
|
||||||
|
> flourishes.
|
||||||
|
|
||||||
|
The selected output's geometry was retained. Production exports were then made
|
||||||
|
deterministically: low-alpha detached pixels were removed, the mark was inset
|
||||||
|
onto a 1024 px transparent canvas, its generated shading was replaced with the
|
||||||
|
three exact flat colors above, and Lanczos downscales were produced. No
|
||||||
|
third-party artwork, typeface, or game asset is included.
|
||||||
|
|
||||||
|
## Originality and release constraints
|
||||||
|
|
||||||
|
This identity deliberately uses only generic fire, bridge, gateway, and terminal
|
||||||
|
ideas. Do not adapt or add artwork, interfaces, names, symbols, screenshots, or
|
||||||
|
other assets from RuneScape or another game.
|
||||||
|
|
||||||
|
Generation and internal review are not a trademark search or legal clearance.
|
||||||
|
Before a public release, perform a similarity search for the symbol and obtain
|
||||||
|
product-name/trademark review; the original mark does not by itself clear the
|
||||||
|
name `Lumbridge`. Keep the prompt and this production record with the assets for
|
||||||
|
provenance, and review the image-generation provider's then-current output terms
|
||||||
|
before redistribution.
|
||||||
@@ -11,10 +11,16 @@ Initial release artifacts:
|
|||||||
- Omarchy/Arch x86_64: signed package archive and an AUR recipe that downloads
|
- Omarchy/Arch x86_64: signed package archive and an AUR recipe that downloads
|
||||||
the matching release artifact.
|
the matching release artifact.
|
||||||
|
|
||||||
|
Every package installs the canonical Lumbridge application icon into the native
|
||||||
|
platform location: the macOS asset catalog/`.icns`, Linux hicolor PNG sizes and
|
||||||
|
`.desktop` entry, and any package-manager metadata. Derived files come from the
|
||||||
|
transparent masters in `assets/brand/`; light and dark variants are for product
|
||||||
|
surfaces, while launchers use the canonical app icon unless a platform has a
|
||||||
|
documented adaptive-icon contract.
|
||||||
|
|
||||||
Each release includes SHA-256 checksums, a signed update manifest, SBOM, license
|
Each release includes SHA-256 checksums, a signed update manifest, SBOM, license
|
||||||
notices, and the exact source commit. Reproducibility is a goal, but a release is
|
notices, and the exact source commit. Reproducibility is a goal, but a release is
|
||||||
not called reproducible until two clean builders produce matching artifacts.
|
not called reproducible until two clean builders produce matching artifacts.
|
||||||
|
|
||||||
The updater must verify signatures before replacing binaries, retain the prior
|
The updater must verify signatures before replacing binaries, retain the prior
|
||||||
version for rollback, and never update a running session runtime in place.
|
version for rollback, and never update a running session runtime in place.
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ The product should feel faster and calmer as concurrency rises.
|
|||||||
user-owned machine reached through SSH or Tailscale.
|
user-owned machine reached through SSH or Tailscale.
|
||||||
9. Follow a project's Buzz channel and intentionally share a redacted pane image
|
9. Follow a project's Buzz channel and intentionally share a redacted pane image
|
||||||
without leaving the workspace.
|
without leaving the workspace.
|
||||||
|
10. Inspect terminal, browser, tools, context, goal, and review views without
|
||||||
|
losing the pane's process identity or execution target.
|
||||||
|
11. Ask a local or selected-provider analyst for a small set of next actions,
|
||||||
|
then choose, edit, dismiss, or explicitly authorize one.
|
||||||
|
|
||||||
## Local and remote workspaces
|
## Local and remote workspaces
|
||||||
|
|
||||||
@@ -54,6 +58,39 @@ devices.
|
|||||||
|
|
||||||
An arbitrary command remains a supported harness through the generic PTY adapter.
|
An arbitrary command remains a supported harness through the generic PTY adapter.
|
||||||
|
|
||||||
|
## Pane surfaces and decision shelf
|
||||||
|
|
||||||
|
A pane is the durable unit of work; its surface is a view over that work. The
|
||||||
|
first surface set is Terminal, Browser, Tools, Context, Goal, and Review. A
|
||||||
|
surface change never silently launches a process, moves the pane to another
|
||||||
|
host, or changes which agent owns the session. Unsupported surfaces are shown as
|
||||||
|
unavailable rather than simulated.
|
||||||
|
|
||||||
|
An optional decision shelf sits below the active work surface. It may show an
|
||||||
|
answer draft, two or three concrete choices, why each was suggested, and the
|
||||||
|
capability each choice would need. Suggestions are inert data. Choosing one may
|
||||||
|
prepare a typed workspace plan, but any command, file mutation, credential use,
|
||||||
|
or external message still passes through the normal approval and command plane.
|
||||||
|
|
||||||
|
## Lumbridge Harness
|
||||||
|
|
||||||
|
Lumbridge Harness is an optional orchestrator distributed as a separately
|
||||||
|
versioned component. It can analyze explicitly shared workspace state, refine
|
||||||
|
prompts, recommend next steps, and coordinate supported agents. It does not own
|
||||||
|
the desktop shell and is not required for terminal multiplexing.
|
||||||
|
|
||||||
|
Its model boundary is provider-neutral. Initial development targets an
|
||||||
|
OpenAI-compatible API so the same contract can use a local Spark/Qwen endpoint,
|
||||||
|
Cerebras, or another user-selected provider. Credentials stay in the OS
|
||||||
|
credential store and are resolved by opaque handle. Endpoint profiles must not
|
||||||
|
embed secrets in workspace layouts, command arguments, or traces.
|
||||||
|
|
||||||
|
Trace analysis is off until the user enables it for a workspace and selects the
|
||||||
|
fields or sessions to share. Local analysis remains local. Sending trace data to
|
||||||
|
a hosted provider requires a destination preview and a separate consent. The
|
||||||
|
Harness receives only the Observe, Configure, or Execute capability granted for
|
||||||
|
that run; autopilot cannot manufacture a broader grant.
|
||||||
|
|
||||||
## Provider modes
|
## Provider modes
|
||||||
|
|
||||||
Subscription mode delegates authentication to the installed upstream harness.
|
Subscription mode delegates authentication to the installed upstream harness.
|
||||||
@@ -95,6 +132,7 @@ in the operating-system credential store.
|
|||||||
- A new foundation-model training or inference service.
|
- A new foundation-model training or inference service.
|
||||||
- A hosted account or mandatory Lumbridge cloud.
|
- A hosted account or mandatory Lumbridge cloud.
|
||||||
- Replacing every coding harness with one Lumbridge-owned agent loop.
|
- Replacing every coding harness with one Lumbridge-owned agent loop.
|
||||||
|
- Making Lumbridge Harness, trace analysis, or autopilot mandatory.
|
||||||
- Windows support.
|
- Windows support.
|
||||||
- Mobile control or collaborative hosted sessions.
|
- Mobile control or collaborative hosted sessions.
|
||||||
- Arbitrary third-party remote compute provisioning; the first release connects
|
- Arbitrary third-party remote compute provisioning; the first release connects
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ They are shallow snapshots for study, not dependencies or vendored source.
|
|||||||
- `openclaw/openclaw`
|
- `openclaw/openclaw`
|
||||||
- `anomalyco/opencode`
|
- `anomalyco/opencode`
|
||||||
|
|
||||||
|
DeepSeek Harness at the captured revision is MIT-licensed experimental software,
|
||||||
|
but it is a large Node/TypeScript workspace with a React web client, Cordis
|
||||||
|
plugin tree, native/platform payloads, and a broad third-party closure. Its own
|
||||||
|
safety notice says it is unaudited and can access processes, files, credentials,
|
||||||
|
and the network exposed to it. We will study its service seams, durable event
|
||||||
|
log, approval flow, ACP profile, and agent lifecycle. We will not embed its web
|
||||||
|
shell into Lumbridge or treat its sandbox as a security boundary. Any branded
|
||||||
|
fork is a separate repository/process with preserved notices and an explicit
|
||||||
|
dependency and payload review.
|
||||||
|
|
||||||
## Research questions
|
## Research questions
|
||||||
|
|
||||||
1. Which terminal core gives us correctness without inheriting a product-shaped
|
1. Which terminal core gives us correctness without inheriting a product-shaped
|
||||||
@@ -60,4 +70,3 @@ They are shallow snapshots for study, not dependencies or vendored source.
|
|||||||
must remain measured or unavailable?
|
must remain measured or unavailable?
|
||||||
6. Can one event model cover PTY, ACP, Git, usage, permissions, and recovery
|
6. Can one event model cover PTY, ACP, Git, usage, permissions, and recovery
|
||||||
without turning into untyped JSON everywhere?
|
without turning into untyped JSON everywhere?
|
||||||
|
|
||||||
|
|||||||
@@ -39,5 +39,9 @@ only when a question genuinely requires upstream history.
|
|||||||
crate before adapting anything.
|
crate before adapting anything.
|
||||||
- The captured BBARIT Terminal root did not contain a top-level license file.
|
- The captured BBARIT Terminal root did not contain a top-level license file.
|
||||||
Treat its implementation as unlicensed unless upstream clarifies otherwise.
|
Treat its implementation as unlicensed unless upstream clarifies otherwise.
|
||||||
|
- DeepSeek Harness is MIT at the captured root, but it is an unaudited
|
||||||
|
developer preview with Node/TypeScript, React, native payloads, and packages
|
||||||
|
carrying their own terms. Preserve its license/notices and review the complete
|
||||||
|
shipped dependency closure before maintaining or distributing a fork.
|
||||||
- Every other source still requires a file-level and dependency-level review
|
- Every other source still requires a file-level and dependency-level review
|
||||||
before reuse; a top-level license filename is not sufficient clearance.
|
before reuse; a top-level license filename is not sufficient clearance.
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
- Prove Buzz SDK message, attachment, cursor, reconnect, and duplicate-delivery
|
- Prove Buzz SDK message, attachment, cursor, reconnect, and duplicate-delivery
|
||||||
behavior against a disposable local relay.
|
behavior against a disposable local relay.
|
||||||
- Decide terminal core, UI stack, IPC transport, and schema evolution policy.
|
- Decide terminal core, UI stack, IPC transport, and schema evolution policy.
|
||||||
|
- Define the Lumbridge Harness process boundary, trace-consent model, provider-
|
||||||
|
neutral endpoint profile, and upstream-fork maintenance policy.
|
||||||
|
|
||||||
Exit: recorded decisions with working spikes and measured results.
|
Exit: recorded decisions with working spikes and measured results.
|
||||||
|
|
||||||
@@ -21,6 +23,8 @@ Exit: recorded decisions with working spikes and measured results.
|
|||||||
- Saved SSH/Tailscale hosts and durable remote panes through a per-user remote
|
- Saved SSH/Tailscale hosts and durable remote panes through a per-user remote
|
||||||
runtime; reconnect from a second Lumbridge installation.
|
runtime; reconnect from a second Lumbridge installation.
|
||||||
- Session restore, crash recovery, command palette, keybindings, notifications.
|
- Session restore, crash recovery, command palette, keybindings, notifications.
|
||||||
|
- Per-pane Terminal, Browser, Tools, Context, Goal, and Review surface tabs, plus
|
||||||
|
an inert decision shelf that can render typed suggestions without executing.
|
||||||
- Repository/worktree creation and basic Git status/diff.
|
- Repository/worktree creation and basic Git status/diff.
|
||||||
- Optional Buzz collaboration pane, workspace channel binding, and confirmed
|
- Optional Buzz collaboration pane, workspace channel binding, and confirmed
|
||||||
redacted pane-image sharing.
|
redacted pane-image sharing.
|
||||||
@@ -34,6 +38,9 @@ Exit: Lumbridge is worth using as a terminal multiplexer without AI features.
|
|||||||
- ACP client with capability negotiation and permission rendering.
|
- ACP client with capability negotiation and permission rendering.
|
||||||
- Codex, Claude Code, DeepSeek Harness, Pi, and Hermes adapters.
|
- Codex, Claude Code, DeepSeek Harness, Pi, and Hermes adapters.
|
||||||
- Agent status, unread/needs-input state, resume, and transcript navigation.
|
- Agent status, unread/needs-input state, resume, and transcript navigation.
|
||||||
|
- Separately versioned Lumbridge Harness with local/self-hosted OpenAI-compatible
|
||||||
|
model support, prompt refinement, trace projections, and capability-gated
|
||||||
|
workspace plans.
|
||||||
|
|
||||||
Exit: daily multi-agent work is reliable and observable.
|
Exit: daily multi-agent work is reliable and observable.
|
||||||
|
|
||||||
|
|||||||
@@ -111,9 +111,16 @@ who already have the final cargo-watch release installed.
|
|||||||
dimensions, and exclusion of provider credentials from the child environment.
|
dimensions, and exclusion of provider credentials from the child environment.
|
||||||
- `lumbridge-runtime` tests ordered actor output, serialized input and resize,
|
- `lumbridge-runtime` tests ordered actor output, serialized input and resize,
|
||||||
event polling, invalid configuration, and bounded-time cleanup of a hung PTY.
|
event polling, invalid configuration, and bounded-time cleanup of a hung PTY.
|
||||||
- The GPUI slice tests byte line-framing across CRLF, chunk boundaries, split
|
A cross-crate integration test drives a real synthetic shell through actor
|
||||||
UTF-8, and invalid bytes. It renders one real actor-owned PTY and keeps five
|
output, VT parsing, terminal key encoding, and a 41×101 resize.
|
||||||
surfaces deterministic; line framing is explicitly not VT emulation.
|
- `lumbridge-terminal` fixtures cover styled Unicode, cursor/title state,
|
||||||
|
alternate screen, bracketed paste, protocol replies, sanitized title and
|
||||||
|
blocked OSC 52 behavior, key encoding, application-cursor mode, and resize.
|
||||||
|
- `lumbridge-core` tests capability-gated split/select/rename/close commands,
|
||||||
|
idempotent request IDs, close-tree promotion, and atomic agentic setup plans.
|
||||||
|
- The GPUI slice tests its key-event adapter. It renders one real actor-owned VT
|
||||||
|
session as plain snapshot rows and keeps five surfaces deterministic; styled
|
||||||
|
cell/cursor rendering remains a separate gate.
|
||||||
- The current-GPUI probe compile-checks real AccessKit element wiring and real
|
- The current-GPUI probe compile-checks real AccessKit element wiring and real
|
||||||
platform input-handler installation. Unit tests cover its semantic tree and
|
platform input-handler installation. Unit tests cover its semantic tree and
|
||||||
UTF-16/UTF-8 composed-text mutations. OS screen readers, IME candidate windows,
|
UTF-16/UTF-8 composed-text mutations. OS screen readers, IME candidate windows,
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ from macOS and Linux and the hard gates pass.
|
|||||||
| Native menu/window/clipboard/drag-and-drop | review | pending | pending |
|
| Native menu/window/clipboard/drag-and-drop | review | pending | pending |
|
||||||
| API clarity and maintenance burden | review | pending | pending |
|
| API clarity and maintenance burden | review | pending | pending |
|
||||||
|
|
||||||
The current programs establish dependency, build, launch, and static layout
|
The current programs establish dependency, build, launch, and interaction
|
||||||
baselines. The next iteration adds deterministic streaming, input timestamps,
|
baselines. GPUI now routes a real PTY through a VT engine and keyboard encoder;
|
||||||
accessibility identifiers, a native Markdown editor, and one Wry browser child.
|
its renderer still flattens the immutable screen snapshot to plain rows. The
|
||||||
|
next iteration adds styled cells/cursor/selection, measured pane-driven resize,
|
||||||
|
a native Markdown editor, and one isolated browser child.
|
||||||
|
|
||||||
## Ubuntu baseline — metal, 2026-08-31
|
## Ubuntu baseline — metal, 2026-08-31
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ fallback.
|
|||||||
|
|
||||||
Both renderers retain the same all-deterministic six-surface action stream for
|
Both renderers retain the same all-deterministic six-surface action stream for
|
||||||
comparison. The GPUI integration mode replaces one terminal fixture with a real
|
comparison. The GPUI integration mode replaces one terminal fixture with a real
|
||||||
actor-owned PTY and leaves five deterministic surfaces running. Counters
|
actor-owned VT session and leaves five deterministic surfaces running. Counters
|
||||||
separate external PTY batches/lines from total model updates. The GPUI footer
|
separate external PTY batches/lines from total model updates. The GPUI footer
|
||||||
reports dispatch-to-element-build p50/p95 over a bounded 256-sample window. It
|
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
|
is deliberately not called key-to-present or frame-present latency: neither
|
||||||
|
|||||||
@@ -67,11 +67,18 @@ surface rather than a hosted Lumbridge control plane.
|
|||||||
in a stable order.
|
in a stable order.
|
||||||
6. The footer groups connection state, selected-harness identity, usage-window
|
6. The footer groups connection state, selected-harness identity, usage-window
|
||||||
provenance, and burn forecast into readable regions.
|
provenance, and burn forecast into readable regions.
|
||||||
|
7. The selected pane has a quiet surface strip for Terminal, Browser, Tools,
|
||||||
|
Context, Goal, and Review. Switching surfaces keeps one pane identity.
|
||||||
|
8. A compact decision shelf may show local-analyst choices below the work
|
||||||
|
surface. It labels its model/provider, trace scope, and whether suggestions
|
||||||
|
are inert; no choice is visually conflated with an approved action.
|
||||||
|
|
||||||
## Keyboard task flow
|
## Keyboard task flow
|
||||||
|
|
||||||
- Arrow keys or `h/j/k/l`: move focus through the 2×3 pane grid.
|
- `Alt+Arrow` or `Alt+h/j/k/l`: move focus through the 2×3 pane grid while
|
||||||
- `1` through `6`: focus a pane directly.
|
leaving ordinary terminal arrows and text available to the PTY.
|
||||||
|
- `Alt+1` through `Alt+6`: focus a pane directly while leaving terminal digits
|
||||||
|
available to the PTY.
|
||||||
- `Cmd+K` on macOS or `Ctrl+K` on Linux: open the command palette.
|
- `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;
|
- Typing while the palette is open changes its query; arrows change the result;
|
||||||
`Enter` runs it; `Escape` closes it and restores pane focus.
|
`Enter` runs it; `Escape` closes it and restores pane focus.
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ arrives. Process-local session IDs are correlation IDs only; durable IDs are
|
|||||||
assigned by the persisted runtime protocol later.
|
assigned by the persisted runtime protocol later.
|
||||||
|
|
||||||
Runtime events contain ordered raw PTY bytes. The UI spike may line-frame plain
|
Runtime events contain ordered raw PTY bytes. The UI spike may line-frame plain
|
||||||
fixture output for display, but ANSI/VT parsing, screen state, cursor behavior,
|
fixture output for deterministic surfaces, but the live pane sends bytes through
|
||||||
scrollback, and terminal input modes belong to `lumbridge-terminal`. The UI must
|
`lumbridge-terminal`. ANSI/VT parsing, screen state, cursor behavior, scrollback,
|
||||||
not infer terminal semantics from raw strings.
|
and terminal input modes never belong to the runtime actor or UI. The UI must not
|
||||||
|
infer terminal semantics from raw strings.
|
||||||
|
|
||||||
Dropping the UI-side actor disconnects its bounded channels, releases an actor
|
Dropping the UI-side actor disconnects its bounded channels, releases an actor
|
||||||
blocked by event backpressure, terminates the process group through
|
blocked by event backpressure, terminates the process group through
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 0005: Terminal semantics stay behind a Lumbridge-owned contract
|
||||||
|
|
||||||
|
Status: accepted for the first terminal slice.
|
||||||
|
|
||||||
|
Lumbridge uses `alacritty_terminal` with default features disabled as its first
|
||||||
|
VT state engine. The dependency is mature enough to cover substantially more
|
||||||
|
terminal behavior than a new parser, but its types do not cross into runtime,
|
||||||
|
storage, IPC, or UI APIs.
|
||||||
|
|
||||||
|
`lumbridge-terminal` accepts ordered raw PTY bytes and exposes immutable
|
||||||
|
Lumbridge snapshots containing cells, styles, colors, cursor, terminal modes,
|
||||||
|
and sanitized title state. It owns terminal key and paste encoding. Protocol
|
||||||
|
replies such as device-status responses return as bytes and enter the same
|
||||||
|
bounded actor queue as all other PTY input, preserving ordering.
|
||||||
|
|
||||||
|
Clipboard writes such as OSC 52 are disabled by default. Transcript-bearing
|
||||||
|
snapshots deliberately omit `Debug`, and terminal titles are bounded and stripped
|
||||||
|
of controls. A renderer may derive plain rows for a spike, but that is not the
|
||||||
|
terminal model and must not become the persistence or automation contract.
|
||||||
|
|
||||||
|
Adopting this engine is reversible: conformance fixtures target the Lumbridge
|
||||||
|
contract, and no upstream application code is copied into this repository.
|
||||||
|
Kitty keyboard/graphics behavior, OSC 8 links, mouse modes, IME, selection, and
|
||||||
|
platform rendering remain explicit qualification work.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 0006: Every workspace controller uses one typed command plane
|
||||||
|
|
||||||
|
Status: accepted for implementation.
|
||||||
|
|
||||||
|
Human UI, CLI automation, remote clients, and agents submit the same typed
|
||||||
|
workspace commands. Stable request IDs make retries idempotent. Pane IDs, split
|
||||||
|
ratios, surfaces, launch intents, and close dispositions are validated before
|
||||||
|
state changes, and a multi-command setup plan is applied atomically. Reusing an
|
||||||
|
ID with a different origin or command is a conflict, not a silent duplicate.
|
||||||
|
|
||||||
|
Each request has an origin and an explicit Observe, Configure, or Execute grant.
|
||||||
|
The command declares the minimum capability it needs. Creating or terminating a
|
||||||
|
process requires Execute; layout-only changes require Configure. Launch intents
|
||||||
|
refer to a user shell or an approved harness profile and working directory, not
|
||||||
|
arbitrary executable arguments or secret values.
|
||||||
|
|
||||||
|
This reducer is not an authorization bypass. The future IPC layer authenticates
|
||||||
|
the caller and supplies its granted capability; the reducer rechecks it before
|
||||||
|
mutation. Durable audit events record request and object identities, never
|
||||||
|
terminal input, transcript content, provider keys, or pane titles by default.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 0007: Lumbridge Harness is optional, separate, and capability-scoped
|
||||||
|
|
||||||
|
Status: accepted for architecture; implementation and fork policy pending.
|
||||||
|
|
||||||
|
Lumbridge may distribute a separately versioned Lumbridge Harness for prompt
|
||||||
|
refinement, trace analysis, recommendations, and agent coordination. The native
|
||||||
|
Rust terminal workspace remains complete without it, and the Harness does not
|
||||||
|
run inside the UI process.
|
||||||
|
|
||||||
|
The captured DeepSeek Harness is an MIT-licensed and architecturally useful
|
||||||
|
reference, but it is also an unaudited Node/TypeScript developer preview with a
|
||||||
|
web client and broad native dependency closure. A maintained fork, if selected,
|
||||||
|
lives in its own repository/process, preserves required notices, and exposes a
|
||||||
|
small versioned protocol. Its React UI is not embedded in Lumbridge. We can later
|
||||||
|
replace the implementation with a first-party Rust service without changing the
|
||||||
|
desktop contract.
|
||||||
|
|
||||||
|
Model access is provider-neutral and begins with OpenAI-compatible endpoints so
|
||||||
|
development can use a self-hosted Spark/Qwen model or a user-selected service
|
||||||
|
such as Cerebras. Endpoint credentials are opaque secret-store handles.
|
||||||
|
|
||||||
|
Trace analysis is opt-in per workspace. The user chooses the trace projection
|
||||||
|
and destination; local processing stays local, while hosted processing requires
|
||||||
|
an explicit preview and consent. Suggestions and decision-shelf choices carry no
|
||||||
|
authority. Any setup, launch, mutation, or autopilot action becomes a typed
|
||||||
|
workspace plan and can use only the Observe, Configure, or Execute capability
|
||||||
|
granted to that run.
|
||||||
@@ -21,7 +21,10 @@ Both spikes must preserve the same information architecture:
|
|||||||
GPUI also has an integration mode with one real local PTY owned by
|
GPUI also has an integration mode with one real local PTY owned by
|
||||||
`lumbridge-runtime`; the other five surfaces remain deterministic. Floem and the
|
`lumbridge-runtime`; the other five surfaces remain deterministic. Floem and the
|
||||||
shared model retain the all-deterministic mode for like-for-like framework
|
shared model retain the all-deterministic mode for like-for-like framework
|
||||||
comparison. The PTY display is plain byte line-framing, not terminal emulation.
|
comparison. GPUI feeds raw output through `lumbridge-terminal` and sends encoded
|
||||||
|
keyboard input and terminal protocol replies through the bounded runtime actor.
|
||||||
|
Its visual adapter currently flattens the VT snapshot to plain rows, so styled
|
||||||
|
cells, selection, and cursor painting remain intentionally unfinished.
|
||||||
|
|
||||||
Build independently:
|
Build independently:
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,30 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alacritty_terminal"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"home",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"miow",
|
||||||
|
"parking_lot",
|
||||||
|
"piper",
|
||||||
|
"polling",
|
||||||
|
"regex-automata",
|
||||||
|
"rustix 1.1.4",
|
||||||
|
"rustix-openpty",
|
||||||
|
"signal-hook",
|
||||||
|
"unicode-width",
|
||||||
|
"vte",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aligned"
|
name = "aligned"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -1252,6 +1276,12 @@ version = "0.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
|
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cursor-icon"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-url"
|
name = "data-url"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -3007,12 +3037,21 @@ dependencies = [
|
|||||||
"gpui",
|
"gpui",
|
||||||
"lumbridge-runtime",
|
"lumbridge-runtime",
|
||||||
"lumbridge-spike-model",
|
"lumbridge-spike-model",
|
||||||
|
"lumbridge-terminal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lumbridge-spike-model"
|
name = "lumbridge-spike-model"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lumbridge-terminal"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"alacritty_terminal",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lyon"
|
name = "lyon"
|
||||||
version = "1.0.19"
|
version = "1.0.19"
|
||||||
@@ -3198,6 +3237,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miow"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moxcms"
|
name = "moxcms"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -4499,6 +4547,17 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix-openpty"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"rustix 1.1.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.43"
|
version = "0.23.43"
|
||||||
@@ -4907,6 +4966,16 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"signal-hook-registry",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook-registry"
|
name = "signal-hook-registry"
|
||||||
version = "1.4.8"
|
version = "1.4.8"
|
||||||
@@ -5985,6 +6054,19 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vte"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"cursor-icon",
|
||||||
|
"log",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "waker-fn"
|
name = "waker-fn"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ publish = false
|
|||||||
gpui = "0.2.2"
|
gpui = "0.2.2"
|
||||||
lumbridge-runtime = { path = "../../crates/lumbridge-runtime" }
|
lumbridge-runtime = { path = "../../crates/lumbridge-runtime" }
|
||||||
lumbridge-spike-model = { path = "../ui-shell-model" }
|
lumbridge-spike-model = { path = "../ui-shell-model" }
|
||||||
|
lumbridge-terminal = { path = "../../crates/lumbridge-terminal" }
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ use gpui::{
|
|||||||
WindowOptions, actions, div, prelude::*, px, rgb, size,
|
WindowOptions, actions, div, prelude::*, px, rgb, size,
|
||||||
};
|
};
|
||||||
use lumbridge_runtime::{
|
use lumbridge_runtime::{
|
||||||
CommandConfig, PtyOptions, RuntimeActor, RuntimeActorError, RuntimeActorOptions, RuntimeEvent,
|
CommandConfig, PtyOptions, RuntimeActor, RuntimeActorError, RuntimeActorOptions,
|
||||||
|
RuntimeCommand, RuntimeEvent, TerminalSize,
|
||||||
};
|
};
|
||||||
use lumbridge_spike_model::{
|
use lumbridge_spike_model::{
|
||||||
ActionOutcome, FOOTER_RIGHT, FocusDirection, OutputSource, PaneId, PaneState, ShellAction,
|
ActionOutcome, FOOTER_RIGHT, FocusDirection, OutputSource, PaneId, PaneState, ShellAction,
|
||||||
ShellModel, SurfaceKind, WORKSPACES,
|
ShellModel, SurfaceKind, WORKSPACES,
|
||||||
};
|
};
|
||||||
|
use lumbridge_terminal::{
|
||||||
|
KeyModifiers, TerminalDimensions, TerminalEngine, TerminalEngineOptions, TerminalKey,
|
||||||
|
TerminalKeyEvent,
|
||||||
|
};
|
||||||
|
|
||||||
const BG: u32 = 0x090c12;
|
const BG: u32 = 0x090c12;
|
||||||
const PANEL: u32 = 0x101620;
|
const PANEL: u32 = 0x101620;
|
||||||
@@ -28,7 +33,9 @@ const TIMING_SAMPLE_LIMIT: usize = 256;
|
|||||||
const RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(16);
|
const RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(16);
|
||||||
const RUNTIME_DRAIN_LIMIT: usize = 64;
|
const RUNTIME_DRAIN_LIMIT: usize = 64;
|
||||||
const LIVE_PANE: PaneId = PaneId::CodexRuntime;
|
const LIVE_PANE: PaneId = PaneId::CodexRuntime;
|
||||||
const LIVE_PTY_SCRIPT: &str = "i=0; printf 'Lumbridge runtime actor owns this PTY\\n'; while :; do printf '[pty %06d] /bin/sh · actor output\\n' \"$i\"; i=$((i+1)); sleep 1; done";
|
const LIVE_PTY_SCRIPT: &str = "printf 'Lumbridge interactive PTY · type here\\n'; exec /bin/sh -i";
|
||||||
|
const TERMINAL_ROW_STEP: u16 = 2;
|
||||||
|
const TERMINAL_COLUMN_STEP: u16 = 10;
|
||||||
|
|
||||||
actions!(
|
actions!(
|
||||||
lumbridge,
|
lumbridge,
|
||||||
@@ -45,6 +52,10 @@ actions!(
|
|||||||
SelectPane4,
|
SelectPane4,
|
||||||
SelectPane5,
|
SelectPane5,
|
||||||
SelectPane6,
|
SelectPane6,
|
||||||
|
TerminalTaller,
|
||||||
|
TerminalShorter,
|
||||||
|
TerminalWider,
|
||||||
|
TerminalNarrower,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -53,7 +64,7 @@ struct LumbridgeShell {
|
|||||||
timing: RenderTiming,
|
timing: RenderTiming,
|
||||||
runtime: Option<RuntimeActor>,
|
runtime: Option<RuntimeActor>,
|
||||||
runtime_status: LiveRuntimeStatus,
|
runtime_status: LiveRuntimeStatus,
|
||||||
runtime_lines: PtyLineFramer,
|
terminal: TerminalEngine,
|
||||||
last_runtime_sequence: u64,
|
last_runtime_sequence: u64,
|
||||||
root_focus: FocusHandle,
|
root_focus: FocusHandle,
|
||||||
pane_focus: [FocusHandle; 6],
|
pane_focus: [FocusHandle; 6],
|
||||||
@@ -106,38 +117,6 @@ impl LiveRuntimeStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct PtyLineFramer {
|
|
||||||
pending: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PtyLineFramer {
|
|
||||||
fn push(&mut self, bytes: &[u8]) -> Vec<String> {
|
|
||||||
self.pending.extend_from_slice(bytes);
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
while let Some(newline) = self.pending.iter().position(|byte| *byte == b'\n') {
|
|
||||||
let mut line = self.pending.drain(..=newline).collect::<Vec<_>>();
|
|
||||||
line.pop();
|
|
||||||
if line.last() == Some(&b'\r') {
|
|
||||||
line.pop();
|
|
||||||
}
|
|
||||||
lines.push(String::from_utf8_lossy(&line).into_owned());
|
|
||||||
}
|
|
||||||
lines
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish(&mut self) -> Vec<String> {
|
|
||||||
if self.pending.is_empty() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
let mut line = std::mem::take(&mut self.pending);
|
|
||||||
if line.last() == Some(&b'\r') {
|
|
||||||
line.pop();
|
|
||||||
}
|
|
||||||
vec![String::from_utf8_lossy(&line).into_owned()]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct RenderTiming {
|
struct RenderTiming {
|
||||||
pending_dispatch: Option<Instant>,
|
pending_dispatch: Option<Instant>,
|
||||||
@@ -228,7 +207,8 @@ impl LumbridgeShell {
|
|||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
let (runtime, runtime_status) = match start_live_runtime() {
|
let terminal = TerminalEngine::new(TerminalEngineOptions::default());
|
||||||
|
let (runtime, runtime_status) = match start_live_runtime(terminal.dimensions()) {
|
||||||
Ok(runtime) => (Some(runtime), LiveRuntimeStatus::Starting),
|
Ok(runtime) => (Some(runtime), LiveRuntimeStatus::Starting),
|
||||||
Err(error) => (None, LiveRuntimeStatus::Fault(error.to_string())),
|
Err(error) => (None, LiveRuntimeStatus::Fault(error.to_string())),
|
||||||
};
|
};
|
||||||
@@ -239,7 +219,7 @@ impl LumbridgeShell {
|
|||||||
timing: RenderTiming::default(),
|
timing: RenderTiming::default(),
|
||||||
runtime,
|
runtime,
|
||||||
runtime_status,
|
runtime_status,
|
||||||
runtime_lines: PtyLineFramer::default(),
|
terminal,
|
||||||
last_runtime_sequence: 0,
|
last_runtime_sequence: 0,
|
||||||
root_focus,
|
root_focus,
|
||||||
pane_focus,
|
pane_focus,
|
||||||
@@ -296,24 +276,20 @@ impl LumbridgeShell {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
self.last_runtime_sequence = sequence;
|
self.last_runtime_sequence = sequence;
|
||||||
let lines = self.runtime_lines.push(&bytes);
|
let update = self.terminal.process(&bytes);
|
||||||
if !lines.is_empty() {
|
for response in update.outbound {
|
||||||
self.dispatch(ShellAction::AppendExternalOutput {
|
if let Err(error) =
|
||||||
pane: LIVE_PANE,
|
self.send_runtime_command(RuntimeCommand::Input(response))
|
||||||
lines,
|
{
|
||||||
});
|
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string());
|
||||||
changed = true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.publish_terminal_snapshot();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
RuntimeEvent::InputClosed { .. } => {}
|
RuntimeEvent::InputClosed { .. } => {}
|
||||||
RuntimeEvent::Exited { status, .. } => {
|
RuntimeEvent::Exited { status, .. } => {
|
||||||
let lines = self.runtime_lines.finish();
|
|
||||||
if !lines.is_empty() {
|
|
||||||
self.dispatch(ShellAction::AppendExternalOutput {
|
|
||||||
pane: LIVE_PANE,
|
|
||||||
lines,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.runtime_status =
|
self.runtime_status =
|
||||||
LiveRuntimeStatus::Exited(format!("PTY exited with code {}", status.code));
|
LiveRuntimeStatus::Exited(format!("PTY exited with code {}", status.code));
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -332,6 +308,36 @@ impl LumbridgeShell {
|
|||||||
changed
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn send_runtime_command(&self, command: RuntimeCommand) -> Result<(), RuntimeActorError> {
|
||||||
|
self.runtime
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(RuntimeActorError::Disconnected)?
|
||||||
|
.try_send(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_terminal_snapshot(&mut self) {
|
||||||
|
let lines = self.terminal.snapshot().plain_rows();
|
||||||
|
self.dispatch(ShellAction::ReplaceExternalOutput {
|
||||||
|
pane: LIVE_PANE,
|
||||||
|
lines,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_terminal(&mut self, rows: u16, columns: u16, cx: &mut Context<Self>) {
|
||||||
|
let dimensions = TerminalDimensions::new(rows, columns)
|
||||||
|
.expect("resize actions always retain non-zero dimensions");
|
||||||
|
if !self.terminal.resize(dimensions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pty_size = TerminalSize::new(rows, columns)
|
||||||
|
.expect("terminal engine dimensions are valid PTY dimensions");
|
||||||
|
if let Err(error) = self.send_runtime_command(RuntimeCommand::Resize(pty_size)) {
|
||||||
|
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string());
|
||||||
|
}
|
||||||
|
self.publish_terminal_snapshot();
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
fn select_pane(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
|
fn select_pane(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.dispatch(ShellAction::SelectPane(pane));
|
self.dispatch(ShellAction::SelectPane(pane));
|
||||||
window.focus(&self.pane_focus[pane.index()]);
|
window.focus(&self.pane_focus[pane.index()]);
|
||||||
@@ -375,8 +381,66 @@ impl LumbridgeShell {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn terminal_taller(&mut self, _: &TerminalTaller, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let dimensions = self.terminal.dimensions();
|
||||||
|
self.resize_terminal(
|
||||||
|
dimensions.rows().saturating_add(TERMINAL_ROW_STEP),
|
||||||
|
dimensions.columns(),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminal_shorter(&mut self, _: &TerminalShorter, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let dimensions = self.terminal.dimensions();
|
||||||
|
self.resize_terminal(
|
||||||
|
dimensions.rows().saturating_sub(TERMINAL_ROW_STEP).max(2),
|
||||||
|
dimensions.columns(),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminal_wider(&mut self, _: &TerminalWider, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let dimensions = self.terminal.dimensions();
|
||||||
|
self.resize_terminal(
|
||||||
|
dimensions.rows(),
|
||||||
|
dimensions.columns().saturating_add(TERMINAL_COLUMN_STEP),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminal_narrower(&mut self, _: &TerminalNarrower, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let dimensions = self.terminal.dimensions();
|
||||||
|
self.resize_terminal(
|
||||||
|
dimensions.rows(),
|
||||||
|
dimensions
|
||||||
|
.columns()
|
||||||
|
.saturating_sub(TERMINAL_COLUMN_STEP)
|
||||||
|
.max(20),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
|
fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if !self.model.command_palette().is_open() {
|
if !self.model.command_palette().is_open() {
|
||||||
|
if self.model.selected_pane() != LIVE_PANE || self.runtime_status.is_terminal() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let modifiers = key_modifiers(event);
|
||||||
|
let Some(event) = terminal_key_from_parts(
|
||||||
|
event.keystroke.key.as_str(),
|
||||||
|
event.keystroke.key_char.as_deref(),
|
||||||
|
modifiers,
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let bytes = self.terminal.encode_key(&event);
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(error) = self.send_runtime_command(RuntimeCommand::Input(bytes)) {
|
||||||
|
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string());
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +454,11 @@ impl LumbridgeShell {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
"escape" => {
|
||||||
|
self.dispatch(ShellAction::CloseCommandPalette);
|
||||||
|
cx.notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if let Some(character) = event.keystroke.key_char.as_deref()
|
if let Some(character) = event.keystroke.key_char.as_deref()
|
||||||
&& !event.keystroke.modifiers.control
|
&& !event.keystroke.modifiers.control
|
||||||
@@ -409,6 +478,7 @@ impl LumbridgeShell {
|
|||||||
pane: &PaneState,
|
pane: &PaneState,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
runtime_status: LiveRuntimeStatus,
|
runtime_status: LiveRuntimeStatus,
|
||||||
|
terminal_dimensions: TerminalDimensions,
|
||||||
focus: FocusHandle,
|
focus: FocusHandle,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> gpui::AnyElement {
|
) -> gpui::AnyElement {
|
||||||
@@ -478,7 +548,7 @@ impl LumbridgeShell {
|
|||||||
.child(format!("{}", id.index() + 1)),
|
.child(format!("{}", id.index() + 1)),
|
||||||
)
|
)
|
||||||
.child(if external {
|
.child(if external {
|
||||||
"Shell · runtime actor"
|
"Shell · VT session"
|
||||||
} else {
|
} else {
|
||||||
pane.fixture().title
|
pane.fixture().title
|
||||||
}),
|
}),
|
||||||
@@ -495,16 +565,45 @@ impl LumbridgeShell {
|
|||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(rgb(MUTED))
|
.text_color(rgb(MUTED))
|
||||||
.child(if external {
|
.child(if external {
|
||||||
runtime_status.detail()
|
format!(
|
||||||
|
"{} · {}×{} · Alt+Shift+arrows resize",
|
||||||
|
runtime_status.detail(),
|
||||||
|
terminal_dimensions.columns(),
|
||||||
|
terminal_dimensions.rows()
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
pane.fixture().target.to_owned()
|
pane.fixture().target.to_owned()
|
||||||
})
|
})
|
||||||
.when(selected, |view| view.child("FOCUSED")),
|
.when(selected, |view| view.child("FOCUSED")),
|
||||||
)
|
)
|
||||||
|
.when(external && selected, |view| {
|
||||||
|
view.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_3()
|
||||||
|
.h(px(26.0))
|
||||||
|
.flex_none()
|
||||||
|
.px_3()
|
||||||
|
.border_y_1()
|
||||||
|
.border_color(rgb(BORDER_QUIET))
|
||||||
|
.bg(rgb(PANEL_ALT))
|
||||||
|
.text_xs()
|
||||||
|
.child(div().text_color(rgb(ACCENT)).child("TERMINAL"))
|
||||||
|
.child(div().text_color(rgb(MUTED)).child("BROWSER"))
|
||||||
|
.child(div().text_color(rgb(MUTED)).child("TOOLS"))
|
||||||
|
.child(div().text_color(rgb(MUTED)).child("CONTEXT"))
|
||||||
|
.child(div().text_color(rgb(MUTED)).child("GOAL"))
|
||||||
|
.child(div().text_color(rgb(MUTED)).child("REVIEW")),
|
||||||
|
)
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
|
.flex_1()
|
||||||
|
.min_h_0()
|
||||||
|
.overflow_hidden()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.px_3()
|
.px_3()
|
||||||
.pb_3()
|
.pb_3()
|
||||||
@@ -512,6 +611,36 @@ impl LumbridgeShell {
|
|||||||
.text_color(rgb(TEXT))
|
.text_color(rgb(TEXT))
|
||||||
.children(pane.lines()[line_start..].iter().cloned()),
|
.children(pane.lines()[line_start..].iter().cloned()),
|
||||||
)
|
)
|
||||||
|
.when(external && selected, |view| {
|
||||||
|
view.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_between()
|
||||||
|
.gap_2()
|
||||||
|
.h(px(38.0))
|
||||||
|
.flex_none()
|
||||||
|
.px_3()
|
||||||
|
.border_t_1()
|
||||||
|
.border_color(rgb(BORDER_QUIET))
|
||||||
|
.bg(rgb(PANEL_ALT))
|
||||||
|
.text_xs()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child("LOCAL ANALYST · OFF · suggestions only"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_color(rgb(TEXT))
|
||||||
|
.child("Continue")
|
||||||
|
.child("Review plan")
|
||||||
|
.child("Ask…"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,7 +896,7 @@ impl Render for LumbridgeShell {
|
|||||||
.px_3()
|
.px_3()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(rgb(MUTED))
|
.text_color(rgb(MUTED))
|
||||||
.child("1 live PTY · 5 deterministic · 1 waiting"),
|
.child("1 interactive VT · 5 deterministic · 1 waiting"),
|
||||||
);
|
);
|
||||||
|
|
||||||
let pane_cards = self
|
let pane_cards = self
|
||||||
@@ -779,6 +908,7 @@ impl Render for LumbridgeShell {
|
|||||||
pane,
|
pane,
|
||||||
self.model.selected_pane() == pane.id(),
|
self.model.selected_pane() == pane.id(),
|
||||||
self.runtime_status.clone(),
|
self.runtime_status.clone(),
|
||||||
|
self.terminal.dimensions(),
|
||||||
self.pane_focus[pane.id().index()].clone(),
|
self.pane_focus[pane.id().index()].clone(),
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
@@ -795,11 +925,11 @@ impl Render for LumbridgeShell {
|
|||||||
|
|
||||||
let counters = self.model.counters();
|
let counters = self.model.counters();
|
||||||
let footer_left = format!(
|
let footer_left = format!(
|
||||||
"rev {} · {} focus · {} external batches · {} PTY lines · {} total lines",
|
"rev {} · VT rev {} · {} focus · {} VT snapshots · {} synthetic lines",
|
||||||
self.model.revision(),
|
self.model.revision(),
|
||||||
|
self.terminal.revision(),
|
||||||
counters.focus_moves,
|
counters.focus_moves,
|
||||||
counters.external_output_batches,
|
counters.external_snapshot_updates,
|
||||||
counters.external_lines_appended,
|
|
||||||
counters.terminal_lines_appended
|
counters.terminal_lines_appended
|
||||||
);
|
);
|
||||||
let timing = self.timing.summary();
|
let timing = self.timing.summary();
|
||||||
@@ -815,6 +945,10 @@ impl Render for LumbridgeShell {
|
|||||||
.on_action(cx.listener(Self::focus_down))
|
.on_action(cx.listener(Self::focus_down))
|
||||||
.on_action(cx.listener(Self::open_palette))
|
.on_action(cx.listener(Self::open_palette))
|
||||||
.on_action(cx.listener(Self::close_palette))
|
.on_action(cx.listener(Self::close_palette))
|
||||||
|
.on_action(cx.listener(Self::terminal_taller))
|
||||||
|
.on_action(cx.listener(Self::terminal_shorter))
|
||||||
|
.on_action(cx.listener(Self::terminal_wider))
|
||||||
|
.on_action(cx.listener(Self::terminal_narrower))
|
||||||
.on_action(cx.listener(|shell, _: &SelectPane1, window, cx| {
|
.on_action(cx.listener(|shell, _: &SelectPane1, window, cx| {
|
||||||
shell.select_pane(PaneId::CodexRuntime, window, cx);
|
shell.select_pane(PaneId::CodexRuntime, window, cx);
|
||||||
}))
|
}))
|
||||||
@@ -908,13 +1042,62 @@ impl Render for LumbridgeShell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_live_runtime() -> Result<RuntimeActor, RuntimeActorError> {
|
fn key_modifiers(event: &KeyDownEvent) -> KeyModifiers {
|
||||||
|
let mut modifiers = KeyModifiers::default();
|
||||||
|
if event.keystroke.modifiers.shift {
|
||||||
|
modifiers = modifiers.union(KeyModifiers::SHIFT);
|
||||||
|
}
|
||||||
|
if event.keystroke.modifiers.alt {
|
||||||
|
modifiers = modifiers.union(KeyModifiers::ALT);
|
||||||
|
}
|
||||||
|
if event.keystroke.modifiers.control {
|
||||||
|
modifiers = modifiers.union(KeyModifiers::CONTROL);
|
||||||
|
}
|
||||||
|
if event.keystroke.modifiers.platform {
|
||||||
|
modifiers = modifiers.union(KeyModifiers::PLATFORM);
|
||||||
|
}
|
||||||
|
modifiers
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminal_key_from_parts(
|
||||||
|
key: &str,
|
||||||
|
key_char: Option<&str>,
|
||||||
|
modifiers: KeyModifiers,
|
||||||
|
) -> Option<TerminalKeyEvent> {
|
||||||
|
let key = match key {
|
||||||
|
"enter" => TerminalKey::Enter,
|
||||||
|
"backspace" => TerminalKey::Backspace,
|
||||||
|
"tab" => TerminalKey::Tab,
|
||||||
|
"escape" => TerminalKey::Escape,
|
||||||
|
"up" => TerminalKey::Up,
|
||||||
|
"down" => TerminalKey::Down,
|
||||||
|
"left" => TerminalKey::Left,
|
||||||
|
"right" => TerminalKey::Right,
|
||||||
|
"home" => TerminalKey::Home,
|
||||||
|
"end" => TerminalKey::End,
|
||||||
|
"insert" => TerminalKey::Insert,
|
||||||
|
"delete" => TerminalKey::Delete,
|
||||||
|
"pageup" => TerminalKey::PageUp,
|
||||||
|
"pagedown" => TerminalKey::PageDown,
|
||||||
|
function if function.starts_with('f') => {
|
||||||
|
let number = function.strip_prefix('f')?.parse::<u8>().ok()?;
|
||||||
|
TerminalKey::Function(number)
|
||||||
|
}
|
||||||
|
_ if !modifiers.contains(KeyModifiers::PLATFORM) => TerminalKey::Text(key_char?.to_owned()),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(TerminalKeyEvent { key, modifiers })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_live_runtime(dimensions: TerminalDimensions) -> Result<RuntimeActor, RuntimeActorError> {
|
||||||
let command = CommandConfig::new("/bin/sh")
|
let command = CommandConfig::new("/bin/sh")
|
||||||
.map_err(RuntimeActorError::Start)?
|
.map_err(RuntimeActorError::Start)?
|
||||||
.args(["-c", LIVE_PTY_SCRIPT]);
|
.args(["-c", LIVE_PTY_SCRIPT]);
|
||||||
|
let pty_size = TerminalSize::new(dimensions.rows(), dimensions.columns())
|
||||||
|
.map_err(RuntimeActorError::Start)?;
|
||||||
RuntimeActor::spawn(
|
RuntimeActor::spawn(
|
||||||
command,
|
command,
|
||||||
PtyOptions::default(),
|
PtyOptions::new(pty_size),
|
||||||
RuntimeActorOptions::default(),
|
RuntimeActorOptions::default(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -922,22 +1105,21 @@ fn start_live_runtime() -> Result<RuntimeActor, RuntimeActorError> {
|
|||||||
fn main() {
|
fn main() {
|
||||||
Application::new().run(|cx: &mut App| {
|
Application::new().run(|cx: &mut App| {
|
||||||
cx.bind_keys([
|
cx.bind_keys([
|
||||||
KeyBinding::new("left", FocusLeft, Some("LumbridgeShell")),
|
KeyBinding::new("alt-left", FocusLeft, Some("LumbridgeShell")),
|
||||||
KeyBinding::new("right", FocusRight, Some("LumbridgeShell")),
|
KeyBinding::new("alt-right", FocusRight, Some("LumbridgeShell")),
|
||||||
KeyBinding::new("up", FocusUp, Some("LumbridgeShell")),
|
KeyBinding::new("alt-up", FocusUp, Some("LumbridgeShell")),
|
||||||
KeyBinding::new("down", FocusDown, Some("LumbridgeShell")),
|
KeyBinding::new("alt-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("secondary-k", OpenPalette, Some("LumbridgeShell")),
|
||||||
KeyBinding::new("escape", ClosePalette, Some("LumbridgeShell")),
|
KeyBinding::new("alt-1", SelectPane1, Some("LumbridgeShell")),
|
||||||
KeyBinding::new("1", SelectPane1, Some("LumbridgeShell")),
|
KeyBinding::new("alt-2", SelectPane2, Some("LumbridgeShell")),
|
||||||
KeyBinding::new("2", SelectPane2, Some("LumbridgeShell")),
|
KeyBinding::new("alt-3", SelectPane3, Some("LumbridgeShell")),
|
||||||
KeyBinding::new("3", SelectPane3, Some("LumbridgeShell")),
|
KeyBinding::new("alt-4", SelectPane4, Some("LumbridgeShell")),
|
||||||
KeyBinding::new("4", SelectPane4, Some("LumbridgeShell")),
|
KeyBinding::new("alt-5", SelectPane5, Some("LumbridgeShell")),
|
||||||
KeyBinding::new("5", SelectPane5, Some("LumbridgeShell")),
|
KeyBinding::new("alt-6", SelectPane6, Some("LumbridgeShell")),
|
||||||
KeyBinding::new("6", SelectPane6, Some("LumbridgeShell")),
|
KeyBinding::new("alt-shift-up", TerminalTaller, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("alt-shift-down", TerminalShorter, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("alt-shift-right", TerminalWider, Some("LumbridgeShell")),
|
||||||
|
KeyBinding::new("alt-shift-left", TerminalNarrower, Some("LumbridgeShell")),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let bounds = Bounds::centered(None, size(px(1500.0), px(960.0)), cx);
|
let bounds = Bounds::centered(None, size(px(1500.0), px(960.0)), cx);
|
||||||
@@ -959,22 +1141,21 @@ fn main() {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::PtyLineFramer;
|
use super::{KeyModifiers, TerminalKey, terminal_key_from_parts};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn frames_split_crlf_and_utf8_chunks_without_losing_bytes() {
|
fn maps_named_and_composed_gpui_keys_to_terminal_input() {
|
||||||
let mut framer = PtyLineFramer::default();
|
let enter = terminal_key_from_parts("enter", None, KeyModifiers::default()).unwrap();
|
||||||
assert!(framer.push(b"first\r").is_empty());
|
assert_eq!(enter.key, TerminalKey::Enter);
|
||||||
assert_eq!(framer.push(b"\nsecond\npart"), ["first", "second"]);
|
|
||||||
assert!(framer.push(&[0xf0, 0x9f]).is_empty());
|
let composed = terminal_key_from_parts("é", Some("é"), KeyModifiers::default()).unwrap();
|
||||||
assert!(framer.push(&[0x91, 0xa9]).is_empty());
|
assert_eq!(composed.key, TerminalKey::Text("é".into()));
|
||||||
assert_eq!(framer.finish(), ["part👩"]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replaces_invalid_utf8_only_after_a_complete_line() {
|
fn reserves_platform_text_shortcuts_for_the_workspace() {
|
||||||
let mut framer = PtyLineFramer::default();
|
assert!(terminal_key_from_parts("c", Some("c"), KeyModifiers::PLATFORM).is_none());
|
||||||
assert!(framer.push(&[0xff]).is_empty());
|
let control = terminal_key_from_parts("c", Some("c"), KeyModifiers::CONTROL).unwrap();
|
||||||
assert_eq!(framer.push(b"\n"), ["�"]);
|
assert_eq!(control.key, TerminalKey::Text("c".into()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ pub enum ShellAction {
|
|||||||
SetCommandPaletteQuery(String),
|
SetCommandPaletteQuery(String),
|
||||||
SyntheticStreamTick,
|
SyntheticStreamTick,
|
||||||
AppendExternalOutput { pane: PaneId, lines: Vec<String> },
|
AppendExternalOutput { pane: PaneId, lines: Vec<String> },
|
||||||
|
ReplaceExternalOutput { pane: PaneId, lines: Vec<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Counters prove both candidates replayed the same workload. Framework
|
/// Counters prove both candidates replayed the same workload. Framework
|
||||||
@@ -308,6 +309,7 @@ pub struct MeasurementCounters {
|
|||||||
pub terminal_lines_appended: u64,
|
pub terminal_lines_appended: u64,
|
||||||
pub external_output_batches: u64,
|
pub external_output_batches: u64,
|
||||||
pub external_lines_appended: u64,
|
pub external_lines_appended: u64,
|
||||||
|
pub external_snapshot_updates: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -536,6 +538,21 @@ impl ShellModel {
|
|||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ShellAction::ReplaceExternalOutput { pane, mut lines } => {
|
||||||
|
let pane = &mut self.panes[pane.index()];
|
||||||
|
if pane.output_source == OutputSource::External {
|
||||||
|
if lines.len() > self.terminal_line_limit {
|
||||||
|
lines.drain(..lines.len() - self.terminal_line_limit);
|
||||||
|
}
|
||||||
|
if pane.lines != lines {
|
||||||
|
pane.lines = lines;
|
||||||
|
self.counters.surface_updates += 1;
|
||||||
|
self.counters.external_output_batches += 1;
|
||||||
|
self.counters.external_snapshot_updates += 1;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if changed {
|
if changed {
|
||||||
self.revision += 1;
|
self.revision += 1;
|
||||||
@@ -786,6 +803,14 @@ mod tests {
|
|||||||
assert_eq!(model.counters().surface_updates, 6);
|
assert_eq!(model.counters().surface_updates, 6);
|
||||||
assert_eq!(model.counters().external_output_batches, 1);
|
assert_eq!(model.counters().external_output_batches, 1);
|
||||||
assert_eq!(model.counters().external_lines_appended, 2);
|
assert_eq!(model.counters().external_lines_appended, 2);
|
||||||
|
|
||||||
|
let snapshot = model.dispatch(ShellAction::ReplaceExternalOutput {
|
||||||
|
pane: PaneId::CodexRuntime,
|
||||||
|
lines: vec!["prompt $".into()],
|
||||||
|
});
|
||||||
|
assert!(snapshot.changed);
|
||||||
|
assert_eq!(model.pane(PaneId::CodexRuntime).lines(), ["prompt $"]);
|
||||||
|
assert_eq!(model.counters().external_snapshot_updates, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||