feat: add interactive VT and workspace command plane
CI / rust (push) Successful in 3m48s

This commit is contained in:
2026-08-31 17:05:49 -07:00
parent 32d190c6c6
commit b838d000db
54 changed files with 3004 additions and 113 deletions
Generated
+350 -1
View File
@@ -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"
+1
View File
@@ -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"
+9 -3
View File
@@ -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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+3 -1
View File
@@ -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
+9
View File
@@ -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 {
+861
View File
@@ -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());
}
}
+3
View File
@@ -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"
);
}
+15
View File
@@ -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
+994
View File
@@ -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());
}
}
+44 -7
View File
@@ -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.
+92
View File
@@ -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 1624 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.
+7 -1
View File
@@ -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.
+38
View File
@@ -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
+10 -1
View File
@@ -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?
+4
View File
@@ -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.
+7
View File
@@ -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.
+10 -3
View File
@@ -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,
+6 -4
View File
@@ -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
+9 -2
View File
@@ -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.
+4 -1
View File
@@ -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:
+82
View File
@@ -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"
+1
View File
@@ -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]
+267 -86
View File
@@ -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()));
} }
} }
+25
View File
@@ -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]