diff --git a/Cargo.lock b/Cargo.lock index 9753810..6b504b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,39 @@ dependencies = [ "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]] name = "android_system_properties" version = "0.1.6" @@ -33,6 +66,12 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" @@ -282,6 +321,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "const-oid" version = "0.10.2" @@ -312,6 +360,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-common" version = "0.1.7" @@ -341,6 +395,12 @@ dependencies = [ "cmov", ] +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "digest" version = "0.10.7" @@ -381,6 +441,16 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "fallible-iterator" version = "0.3.0" @@ -393,6 +463,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -425,6 +501,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + [[package]] name = "futures-task" version = "0.3.34" @@ -478,6 +560,12 @@ dependencies = [ "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]] name = "hex" version = "0.4.3" @@ -520,6 +608,15 @@ dependencies = [ "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]] name = "hybrid-array" version = "0.4.14" @@ -719,12 +816,27 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "log" version = "0.4.34" @@ -751,6 +863,9 @@ dependencies = [ [[package]] name = "lumbridge-core" version = "0.0.1" +dependencies = [ + "thiserror 2.0.20", +] [[package]] name = "lumbridge-pty" @@ -766,6 +881,7 @@ name = "lumbridge-runtime" version = "0.0.1" dependencies = [ "lumbridge-pty", + "lumbridge-terminal", "thiserror 2.0.20", ] @@ -777,12 +893,29 @@ dependencies = [ "rusqlite", ] +[[package]] +name = "lumbridge-terminal" +version = "0.0.1" +dependencies = [ + "alacritty_terminal", + "thiserror 2.0.20", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "nix" version = "0.28.0" @@ -840,6 +973,29 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "password-hash" version = "0.5.0" @@ -873,12 +1029,37 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "pkg-config" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "poly1305" version = "0.8.0" @@ -1000,6 +1181,32 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "rusqlite" version = "0.40.2" @@ -1013,6 +1220,30 @@ dependencies = [ "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]] name = "rustversion" version = "1.0.23" @@ -1028,6 +1259,12 @@ dependencies = [ "cipher", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "scrypt" version = "0.11.0" @@ -1111,7 +1348,7 @@ checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" dependencies = [ "cfg-if", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1158,6 +1395,26 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "slab" version = "0.4.12" @@ -1301,6 +1558,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "universal-hash" version = "0.5.1" @@ -1354,6 +1617,19 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1496,6 +1772,15 @@ dependencies = [ "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]] name = "windows-sys" version = "0.61.2" @@ -1505,6 +1790,70 @@ dependencies = [ "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]] name = "winreg" version = "0.10.1" diff --git a/Cargo.toml b/Cargo.toml index da2d268..10f2c6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/lumbridge-pty", "crates/lumbridge-runtime", "crates/lumbridge-storage", + "crates/lumbridge-terminal", ] exclude = ["spikes"] resolver = "2" diff --git a/README.md b/README.md index eda3464..5245ac8 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ as installable binaries; building from source will remain supported. This repository is in architecture and vertical-slice phase. The installable binary is still a scaffold, while the isolated native UI spikes now exercise an interactive six-surface workspace and the root workspace contains the first -bounded local PTY and runtime-actor boundaries. The GPUI slice renders one real -actor-owned PTY beside five deterministic surfaces. We are still validating -terminal emulation, standalone runtime IPC/durability, ACP integration, +bounded local PTY, runtime-actor, VT engine, and capability-gated workspace +command boundaries. The GPUI slice renders one real actor-owned VT session beside +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. ## Product shape @@ -39,6 +40,9 @@ packaging, and usage-data contracts before a large implementation. provenance instead of invented precision. - Optional first-class Buzz channels, messages, agents, and confirmed redacted 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), [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 signed-protocol and pane-sharing boundary is in [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). diff --git a/assets/brand/app-icons/lumbridge-app-1024.png b/assets/brand/app-icons/lumbridge-app-1024.png new file mode 100644 index 0000000..5065211 Binary files /dev/null and b/assets/brand/app-icons/lumbridge-app-1024.png differ diff --git a/assets/brand/app-icons/lumbridge-app-128.png b/assets/brand/app-icons/lumbridge-app-128.png new file mode 100644 index 0000000..1006882 Binary files /dev/null and b/assets/brand/app-icons/lumbridge-app-128.png differ diff --git a/assets/brand/app-icons/lumbridge-app-16.png b/assets/brand/app-icons/lumbridge-app-16.png new file mode 100644 index 0000000..63a6f8d Binary files /dev/null and b/assets/brand/app-icons/lumbridge-app-16.png differ diff --git a/assets/brand/app-icons/lumbridge-app-256.png b/assets/brand/app-icons/lumbridge-app-256.png new file mode 100644 index 0000000..a45e8e3 Binary files /dev/null and b/assets/brand/app-icons/lumbridge-app-256.png differ diff --git a/assets/brand/app-icons/lumbridge-app-32.png b/assets/brand/app-icons/lumbridge-app-32.png new file mode 100644 index 0000000..72376e6 Binary files /dev/null and b/assets/brand/app-icons/lumbridge-app-32.png differ diff --git a/assets/brand/app-icons/lumbridge-app-48.png b/assets/brand/app-icons/lumbridge-app-48.png new file mode 100644 index 0000000..5d577fc Binary files /dev/null and b/assets/brand/app-icons/lumbridge-app-48.png differ diff --git a/assets/brand/app-icons/lumbridge-app-512.png b/assets/brand/app-icons/lumbridge-app-512.png new file mode 100644 index 0000000..c2fcff8 Binary files /dev/null and b/assets/brand/app-icons/lumbridge-app-512.png differ diff --git a/assets/brand/app-icons/lumbridge-app-64.png b/assets/brand/app-icons/lumbridge-app-64.png new file mode 100644 index 0000000..43e84af Binary files /dev/null and b/assets/brand/app-icons/lumbridge-app-64.png differ diff --git a/assets/brand/dark/lumbridge-mark-dark-1024.png b/assets/brand/dark/lumbridge-mark-dark-1024.png new file mode 100644 index 0000000..54473eb Binary files /dev/null and b/assets/brand/dark/lumbridge-mark-dark-1024.png differ diff --git a/assets/brand/dark/lumbridge-mark-dark-128.png b/assets/brand/dark/lumbridge-mark-dark-128.png new file mode 100644 index 0000000..6609d0a Binary files /dev/null and b/assets/brand/dark/lumbridge-mark-dark-128.png differ diff --git a/assets/brand/dark/lumbridge-mark-dark-16.png b/assets/brand/dark/lumbridge-mark-dark-16.png new file mode 100644 index 0000000..7382e6e Binary files /dev/null and b/assets/brand/dark/lumbridge-mark-dark-16.png differ diff --git a/assets/brand/dark/lumbridge-mark-dark-256.png b/assets/brand/dark/lumbridge-mark-dark-256.png new file mode 100644 index 0000000..0a105c7 Binary files /dev/null and b/assets/brand/dark/lumbridge-mark-dark-256.png differ diff --git a/assets/brand/dark/lumbridge-mark-dark-32.png b/assets/brand/dark/lumbridge-mark-dark-32.png new file mode 100644 index 0000000..b11afcd Binary files /dev/null and b/assets/brand/dark/lumbridge-mark-dark-32.png differ diff --git a/assets/brand/dark/lumbridge-mark-dark-48.png b/assets/brand/dark/lumbridge-mark-dark-48.png new file mode 100644 index 0000000..8b80dff Binary files /dev/null and b/assets/brand/dark/lumbridge-mark-dark-48.png differ diff --git a/assets/brand/dark/lumbridge-mark-dark-512.png b/assets/brand/dark/lumbridge-mark-dark-512.png new file mode 100644 index 0000000..fa658e5 Binary files /dev/null and b/assets/brand/dark/lumbridge-mark-dark-512.png differ diff --git a/assets/brand/dark/lumbridge-mark-dark-64.png b/assets/brand/dark/lumbridge-mark-dark-64.png new file mode 100644 index 0000000..73ea60f Binary files /dev/null and b/assets/brand/dark/lumbridge-mark-dark-64.png differ diff --git a/assets/brand/light/lumbridge-mark-light-1024.png b/assets/brand/light/lumbridge-mark-light-1024.png new file mode 100644 index 0000000..b10a4ff Binary files /dev/null and b/assets/brand/light/lumbridge-mark-light-1024.png differ diff --git a/assets/brand/light/lumbridge-mark-light-128.png b/assets/brand/light/lumbridge-mark-light-128.png new file mode 100644 index 0000000..29b956e Binary files /dev/null and b/assets/brand/light/lumbridge-mark-light-128.png differ diff --git a/assets/brand/light/lumbridge-mark-light-16.png b/assets/brand/light/lumbridge-mark-light-16.png new file mode 100644 index 0000000..7e487db Binary files /dev/null and b/assets/brand/light/lumbridge-mark-light-16.png differ diff --git a/assets/brand/light/lumbridge-mark-light-256.png b/assets/brand/light/lumbridge-mark-light-256.png new file mode 100644 index 0000000..60dd169 Binary files /dev/null and b/assets/brand/light/lumbridge-mark-light-256.png differ diff --git a/assets/brand/light/lumbridge-mark-light-32.png b/assets/brand/light/lumbridge-mark-light-32.png new file mode 100644 index 0000000..bbaa6c6 Binary files /dev/null and b/assets/brand/light/lumbridge-mark-light-32.png differ diff --git a/assets/brand/light/lumbridge-mark-light-48.png b/assets/brand/light/lumbridge-mark-light-48.png new file mode 100644 index 0000000..8071af3 Binary files /dev/null and b/assets/brand/light/lumbridge-mark-light-48.png differ diff --git a/assets/brand/light/lumbridge-mark-light-512.png b/assets/brand/light/lumbridge-mark-light-512.png new file mode 100644 index 0000000..24163a4 Binary files /dev/null and b/assets/brand/light/lumbridge-mark-light-512.png differ diff --git a/assets/brand/light/lumbridge-mark-light-64.png b/assets/brand/light/lumbridge-mark-light-64.png new file mode 100644 index 0000000..0a9c149 Binary files /dev/null and b/assets/brand/light/lumbridge-mark-light-64.png differ diff --git a/assets/brand/master/lumbridge-mark-master-1024.png b/assets/brand/master/lumbridge-mark-master-1024.png new file mode 100644 index 0000000..5065211 Binary files /dev/null and b/assets/brand/master/lumbridge-mark-master-1024.png differ diff --git a/crates/lumbridge-core/Cargo.toml b/crates/lumbridge-core/Cargo.toml index 0d0ae9c..f759208 100644 --- a/crates/lumbridge-core/Cargo.toml +++ b/crates/lumbridge-core/Cargo.toml @@ -7,6 +7,8 @@ rust-version.workspace = true license.workspace = true repository.workspace = true +[dependencies] +thiserror = "2.0" + [lints] workspace = true - diff --git a/crates/lumbridge-core/src/lib.rs b/crates/lumbridge-core/src/lib.rs index 0547dc2..a9c0c66 100644 --- a/crates/lumbridge-core/src/lib.rs +++ b/crates/lumbridge-core/src/lib.rs @@ -2,6 +2,15 @@ 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. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum RemoteTransport { diff --git a/crates/lumbridge-core/src/workspace.rs b/crates/lumbridge-core/src/workspace.rs new file mode 100644 index 0000000..0a5ad2d --- /dev/null +++ b/crates/lumbridge-core/src/workspace.rs @@ -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) -> Result { + 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, + }, + HarnessProfile { + profile_id: String, + working_directory: Option, + }, +} + +/// 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, +} + +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 { + 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, + second: Box, + }, +} + +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 { + 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, +} + +#[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, + layout: LayoutNode, + selected: PaneId, + applied_requests: BTreeMap, + 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, + root: PaneDefinition, + ) -> Result { + 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 { + 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, + granted: WorkspaceCapability, + ) -> Result, 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 { + 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(constructor: impl FnOnce(String) -> Result, 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()); + } +} diff --git a/crates/lumbridge-runtime/Cargo.toml b/crates/lumbridge-runtime/Cargo.toml index f77a8e5..c570b55 100644 --- a/crates/lumbridge-runtime/Cargo.toml +++ b/crates/lumbridge-runtime/Cargo.toml @@ -11,5 +11,8 @@ repository.workspace = true lumbridge-pty = { path = "../lumbridge-pty" } thiserror = "2.0" +[dev-dependencies] +lumbridge-terminal = { path = "../lumbridge-terminal" } + [lints] workspace = true diff --git a/crates/lumbridge-runtime/tests/terminal_pipeline.rs b/crates/lumbridge-runtime/tests/terminal_pipeline.rs new file mode 100644 index 0000000..5588923 --- /dev/null +++ b/crates/lumbridge-runtime/tests/terminal_pipeline.rs @@ -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" + ); +} diff --git a/crates/lumbridge-terminal/Cargo.toml b/crates/lumbridge-terminal/Cargo.toml new file mode 100644 index 0000000..86c826d --- /dev/null +++ b/crates/lumbridge-terminal/Cargo.toml @@ -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 diff --git a/crates/lumbridge-terminal/src/lib.rs b/crates/lumbridge-terminal/src/lib.rs new file mode 100644 index 0000000..f52fca1 --- /dev/null +++ b/crates/lumbridge-terminal/src/lib.rs @@ -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::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 { + 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, +} + +#[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, + pub cursor: TerminalCursor, + pub modes: TerminalModes, + pub title: Option, +} + +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 { + (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>, + pub events: Vec, +} + +#[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>, + events: Vec, + title: Option, +} + +#[derive(Clone, Default)] +struct EventProxy { + state: Arc>, +} + +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 { + 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, + 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 { + 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 { + 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 { + 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 { + 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> { + 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, alt: bool) -> Vec { + 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 { + 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 { + 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 { + 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()); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b0b7b44..1251a1d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,8 +41,10 @@ messages should be real from the beginning. - `lumbridge`: installable application entry point. The scaffold currently contains `lumbridge-core`, `lumbridge-storage`, -`lumbridge-buzz`, `lumbridge-pty`, `lumbridge-runtime`, and the entry point. -Larger runtime crates are added after their architecture spikes pass. +`lumbridge-buzz`, `lumbridge-pty`, `lumbridge-runtime`, `lumbridge-terminal`, and +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 @@ -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 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 thread. Bounded command and event queues serialize input, resize, close, and -shutdown against ordered raw-byte output. The GPUI slice consumes one actor -session while five surfaces retain deterministic comparison output. This actor -still runs in-process; moving the same framework-neutral contract behind local -authenticated IPC is the next durability step. See decision 0004. +shutdown against ordered raw-byte output. The GPUI slice feeds one actor session +through the terminal engine while five surfaces retain deterministic comparison +output. Its current text adapter intentionally renders plain rows and does not +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 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, 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 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 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 is an append-only observation stream, not a mutable percentage field. diff --git a/docs/BRAND.md b/docs/BRAND.md new file mode 100644 index 0000000..fd9f92c --- /dev/null +++ b/docs/BRAND.md @@ -0,0 +1,92 @@ +# Lumbridge brand mark + +## Concept + +Lumbridge's symbol is an original ember-flame wrapped around a bridge arch. The +diagonal negative space also reads as a terminal chevron or an open gateway. +Together, those shapes express a fast native workspace where engineers cross +between terminals, tools, hosts, and agents. + +The mark is symbol-only. Do not ship a Unicode fire emoji as the application +icon, add pseudo-medieval ornament, or attach generated lettering to the mark. + +## Color + +| Role | Hex | Intended surface | +| --- | --- | --- | +| Ember | `#FF6B35` | Canonical application icon and neutral contexts | +| Ember, light UI | `#D94824` | Light surfaces around `#F6F3EE` | +| Ember, dark UI | `#FF8A5B` | Dark surfaces around `#0B0F14` | + +All files have transparent backgrounds. The surface colors above are usage +guidance and are not baked into the assets. Do not add gradients, drop shadows, +outlines, or extra flame colors. + +## Assets + +The 1024 px master is +`assets/brand/master/lumbridge-mark-master-1024.png`. Use it as the source for +new platform exports. + +- `assets/brand/app-icons/lumbridge-app-{size}.png` contains the canonical Ember + icon at 16, 32, 48, 64, 128, 256, 512, and 1024 px. +- `assets/brand/light/lumbridge-mark-light-{size}.png` contains the light-UI + colorway at the same sizes. +- `assets/brand/dark/lumbridge-mark-dark-{size}.png` contains the dark-UI + colorway at the same sizes. + +The existing PNGs are RGBA and square. Packaging may derive platform-native +containers such as macOS `.icns` later, but those derived files should preserve +the supplied canvas and must not stretch or crop the symbol. + +## Placement + +- Preserve the mark's aspect ratio and transparent canvas. +- Outside an application-icon tile, keep clear space equal to at least one + eighth of the rendered mark height on every side. +- Prefer the prepared export nearest the requested display size. Do not scale a + 16 px export upward. +- At 16–24 px, use the canonical app-icon export without a wordmark, badge, or + status dot. The flame and gateway are the intended small-size reading; the + terminal chevron becomes secondary. +- Do not place detailed artwork or low-contrast orange immediately behind the + transparent mark. + +## Generation and production record + +The concept was generated once with the built-in `image_gen` mode using this +prompt: + +> Use case: logo-brand. Asset type: native desktop application icon master for +> Lumbridge, a very high-performance Rust terminal multiplexer and AI-agent +> workspace. Create one original symbol-only brand mark that expresses +> firemaking energy, a bridge or gateway, and a terminal prompt through a single +> strong silhouette. Use a compact upright ember-flame whose interior negative +> space forms a clean bridge arch or open gateway; incorporate one extremely +> subtle angular terminal chevron idea into the geometry without adding a +> separate floating glyph. Use a vector-friendly, flat, minimal geometric mark +> with precise edges, centered on a square transparent canvas. Use warm ember +> orange. Exactly one symbol; no text, letters, wordmark, emoji glyph, gradient, +> shadow, 3D, mockup, border tile, or watermark. Avoid RuneScape imagery or +> style, game trademarks, medieval crests, literal campfires, logs, swords, +> shields, runes, detailed sparks, characters, mascots, and decorative +> flourishes. + +The selected output's geometry was retained. Production exports were then made +deterministically: low-alpha detached pixels were removed, the mark was inset +onto a 1024 px transparent canvas, its generated shading was replaced with the +three exact flat colors above, and Lanczos downscales were produced. No +third-party artwork, typeface, or game asset is included. + +## Originality and release constraints + +This identity deliberately uses only generic fire, bridge, gateway, and terminal +ideas. Do not adapt or add artwork, interfaces, names, symbols, screenshots, or +other assets from RuneScape or another game. + +Generation and internal review are not a trademark search or legal clearance. +Before a public release, perform a similarity search for the symbol and obtain +product-name/trademark review; the original mark does not by itself clear the +name `Lumbridge`. Keep the prompt and this production record with the assets for +provenance, and review the image-generation provider's then-current output terms +before redistribution. diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index bbe7223..80166ab 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -11,10 +11,16 @@ Initial release artifacts: - Omarchy/Arch x86_64: signed package archive and an AUR recipe that downloads 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 notices, and the exact source commit. Reproducibility is a goal, but a release is not called reproducible until two clean builders produce matching artifacts. The updater must verify signatures before replacing binaries, retain the prior version for rollback, and never update a running session runtime in place. - diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md index c479495..ce625a5 100644 --- a/docs/PRODUCT_SPEC.md +++ b/docs/PRODUCT_SPEC.md @@ -25,6 +25,10 @@ The product should feel faster and calmer as concurrency rises. user-owned machine reached through SSH or Tailscale. 9. Follow a project's Buzz channel and intentionally share a redacted pane image 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 @@ -54,6 +58,39 @@ devices. 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 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 hosted account or mandatory Lumbridge cloud. - Replacing every coding harness with one Lumbridge-owned agent loop. +- Making Lumbridge Harness, trace analysis, or autopilot mandatory. - Windows support. - Mobile control or collaborative hosted sessions. - Arbitrary third-party remote compute provisioning; the first release connects diff --git a/docs/RESEARCH.md b/docs/RESEARCH.md index e8af2af..2b70db5 100644 --- a/docs/RESEARCH.md +++ b/docs/RESEARCH.md @@ -48,6 +48,16 @@ They are shallow snapshots for study, not dependencies or vendored source. - `openclaw/openclaw` - `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 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? 6. Can one event model cover PTY, ACP, Git, usage, permissions, and recovery without turning into untyped JSON everywhere? - diff --git a/docs/RESEARCH_SNAPSHOTS.md b/docs/RESEARCH_SNAPSHOTS.md index 9c1f5a6..958e71e 100644 --- a/docs/RESEARCH_SNAPSHOTS.md +++ b/docs/RESEARCH_SNAPSHOTS.md @@ -39,5 +39,9 @@ only when a question genuinely requires upstream history. crate before adapting anything. - The captured BBARIT Terminal root did not contain a top-level license file. 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 before reuse; a top-level license filename is not sufficient clearance. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 705ea55..fb1a914 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -12,6 +12,8 @@ - Prove Buzz SDK message, attachment, cursor, reconnect, and duplicate-delivery behavior against a disposable local relay. - 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. @@ -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 runtime; reconnect from a second Lumbridge installation. - 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. - Optional Buzz collaboration pane, workspace channel binding, and confirmed 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. - Codex, Claude Code, DeepSeek Harness, Pi, and Hermes adapters. - 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. diff --git a/docs/TESTING.md b/docs/TESTING.md index 048aca4..7db0103 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -111,9 +111,16 @@ who already have the final cargo-watch release installed. dimensions, and exclusion of provider credentials from the child environment. - `lumbridge-runtime` tests ordered actor output, serialized input and resize, event polling, invalid configuration, and bounded-time cleanup of a hung PTY. -- The GPUI slice tests byte line-framing across CRLF, chunk boundaries, split - UTF-8, and invalid bytes. It renders one real actor-owned PTY and keeps five - surfaces deterministic; line framing is explicitly not VT emulation. + A cross-crate integration test drives a real synthetic shell through actor + output, VT parsing, terminal key encoding, and a 41×101 resize. +- `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 platform input-handler installation. Unit tests cover its semantic tree and UTF-16/UTF-8 composed-text mutations. OS screen readers, IME candidate windows, diff --git a/docs/UI_SPIKE_SCORECARD.md b/docs/UI_SPIKE_SCORECARD.md index a883b7c..6cc9020 100644 --- a/docs/UI_SPIKE_SCORECARD.md +++ b/docs/UI_SPIKE_SCORECARD.md @@ -21,9 +21,11 @@ from macOS and Linux and the hard gates pass. | Native menu/window/clipboard/drag-and-drop | review | pending | pending | | API clarity and maintenance burden | review | pending | pending | -The current programs establish dependency, build, launch, and static layout -baselines. The next iteration adds deterministic streaming, input timestamps, -accessibility identifiers, a native Markdown editor, and one Wry browser child. +The current programs establish dependency, build, launch, and interaction +baselines. GPUI now routes a real PTY through a VT engine and keyboard encoder; +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 @@ -85,7 +87,7 @@ fallback. Both renderers retain the same all-deterministic six-surface action stream for 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 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 diff --git a/docs/UX_VERTICAL_SLICE.md b/docs/UX_VERTICAL_SLICE.md index 5ec6976..36154ef 100644 --- a/docs/UX_VERTICAL_SLICE.md +++ b/docs/UX_VERTICAL_SLICE.md @@ -67,11 +67,18 @@ surface rather than a hosted Lumbridge control plane. in a stable order. 6. The footer groups connection state, selected-harness identity, usage-window 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 -- Arrow keys or `h/j/k/l`: move focus through the 2×3 pane grid. -- `1` through `6`: focus a pane directly. +- `Alt+Arrow` or `Alt+h/j/k/l`: move focus through the 2×3 pane grid while + 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. - Typing while the palette is open changes its query; arrows change the result; `Enter` runs it; `Escape` closes it and restores pane focus. diff --git a/docs/decisions/0004-runtime-actor-owns-each-pty.md b/docs/decisions/0004-runtime-actor-owns-each-pty.md index 72dad20..2b80c7b 100644 --- a/docs/decisions/0004-runtime-actor-owns-each-pty.md +++ b/docs/decisions/0004-runtime-actor-owns-each-pty.md @@ -16,9 +16,10 @@ arrives. Process-local session IDs are correlation IDs only; durable IDs are assigned by the persisted runtime protocol later. 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, -scrollback, and terminal input modes belong to `lumbridge-terminal`. The UI must -not infer terminal semantics from raw strings. +fixture output for deterministic surfaces, but the live pane sends bytes through +`lumbridge-terminal`. ANSI/VT parsing, screen state, cursor behavior, scrollback, +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 blocked by event backpressure, terminates the process group through diff --git a/docs/decisions/0005-terminal-engine-boundary.md b/docs/decisions/0005-terminal-engine-boundary.md new file mode 100644 index 0000000..58d862c --- /dev/null +++ b/docs/decisions/0005-terminal-engine-boundary.md @@ -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. diff --git a/docs/decisions/0006-workspace-command-plane.md b/docs/decisions/0006-workspace-command-plane.md new file mode 100644 index 0000000..865f9c2 --- /dev/null +++ b/docs/decisions/0006-workspace-command-plane.md @@ -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. diff --git a/docs/decisions/0007-optional-lumbridge-harness.md b/docs/decisions/0007-optional-lumbridge-harness.md new file mode 100644 index 0000000..1626c80 --- /dev/null +++ b/docs/decisions/0007-optional-lumbridge-harness.md @@ -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. diff --git a/spikes/README.md b/spikes/README.md index be20dfe..94909b1 100644 --- a/spikes/README.md +++ b/spikes/README.md @@ -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 `lumbridge-runtime`; the other five surfaces remain deterministic. Floem and the 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: diff --git a/spikes/gpui-shell/Cargo.lock b/spikes/gpui-shell/Cargo.lock index 9917d34..ccf07d7 100644 --- a/spikes/gpui-shell/Cargo.lock +++ b/spikes/gpui-shell/Cargo.lock @@ -42,6 +42,30 @@ 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 1.1.4", + "rustix-openpty", + "signal-hook", + "unicode-width", + "vte", + "windows-sys 0.59.0", +] + [[package]] name = "aligned" version = "0.4.3" @@ -1252,6 +1276,12 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "data-url" version = "0.3.2" @@ -3007,12 +3037,21 @@ dependencies = [ "gpui", "lumbridge-runtime", "lumbridge-spike-model", + "lumbridge-terminal", ] [[package]] name = "lumbridge-spike-model" version = "0.0.1" +[[package]] +name = "lumbridge-terminal" +version = "0.0.1" +dependencies = [ + "alacritty_terminal", + "thiserror 2.0.20", +] + [[package]] name = "lyon" version = "1.0.19" @@ -3198,6 +3237,15 @@ dependencies = [ "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]] name = "moxcms" version = "0.8.1" @@ -4499,6 +4547,17 @@ dependencies = [ "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]] name = "rustls" version = "0.23.43" @@ -4907,6 +4966,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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" @@ -5985,6 +6054,19 @@ dependencies = [ "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]] name = "waker-fn" version = "1.2.0" diff --git a/spikes/gpui-shell/Cargo.toml b/spikes/gpui-shell/Cargo.toml index c6cd72e..a529d11 100644 --- a/spikes/gpui-shell/Cargo.toml +++ b/spikes/gpui-shell/Cargo.toml @@ -11,5 +11,6 @@ publish = false gpui = "0.2.2" lumbridge-runtime = { path = "../../crates/lumbridge-runtime" } lumbridge-spike-model = { path = "../ui-shell-model" } +lumbridge-terminal = { path = "../../crates/lumbridge-terminal" } [workspace] diff --git a/spikes/gpui-shell/src/main.rs b/spikes/gpui-shell/src/main.rs index 818e3ba..9cbc4a0 100644 --- a/spikes/gpui-shell/src/main.rs +++ b/spikes/gpui-shell/src/main.rs @@ -6,12 +6,17 @@ use gpui::{ WindowOptions, actions, div, prelude::*, px, rgb, size, }; use lumbridge_runtime::{ - CommandConfig, PtyOptions, RuntimeActor, RuntimeActorError, RuntimeActorOptions, RuntimeEvent, + CommandConfig, PtyOptions, RuntimeActor, RuntimeActorError, RuntimeActorOptions, + RuntimeCommand, RuntimeEvent, TerminalSize, }; use lumbridge_spike_model::{ ActionOutcome, FOOTER_RIGHT, FocusDirection, OutputSource, PaneId, PaneState, ShellAction, ShellModel, SurfaceKind, WORKSPACES, }; +use lumbridge_terminal::{ + KeyModifiers, TerminalDimensions, TerminalEngine, TerminalEngineOptions, TerminalKey, + TerminalKeyEvent, +}; const BG: u32 = 0x090c12; 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_DRAIN_LIMIT: usize = 64; 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!( lumbridge, @@ -45,6 +52,10 @@ actions!( SelectPane4, SelectPane5, SelectPane6, + TerminalTaller, + TerminalShorter, + TerminalWider, + TerminalNarrower, ] ); @@ -53,7 +64,7 @@ struct LumbridgeShell { timing: RenderTiming, runtime: Option, runtime_status: LiveRuntimeStatus, - runtime_lines: PtyLineFramer, + terminal: TerminalEngine, last_runtime_sequence: u64, root_focus: FocusHandle, pane_focus: [FocusHandle; 6], @@ -106,38 +117,6 @@ impl LiveRuntimeStatus { } } -#[derive(Default)] -struct PtyLineFramer { - pending: Vec, -} - -impl PtyLineFramer { - fn push(&mut self, bytes: &[u8]) -> Vec { - 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::>(); - 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 { - 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)] struct RenderTiming { pending_dispatch: Option, @@ -228,7 +207,8 @@ impl LumbridgeShell { }) .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), Err(error) => (None, LiveRuntimeStatus::Fault(error.to_string())), }; @@ -239,7 +219,7 @@ impl LumbridgeShell { timing: RenderTiming::default(), runtime, runtime_status, - runtime_lines: PtyLineFramer::default(), + terminal, last_runtime_sequence: 0, root_focus, pane_focus, @@ -296,24 +276,20 @@ impl LumbridgeShell { break; } self.last_runtime_sequence = sequence; - let lines = self.runtime_lines.push(&bytes); - if !lines.is_empty() { - self.dispatch(ShellAction::AppendExternalOutput { - pane: LIVE_PANE, - lines, - }); - changed = true; + let update = self.terminal.process(&bytes); + for response in update.outbound { + if let Err(error) = + self.send_runtime_command(RuntimeCommand::Input(response)) + { + self.runtime_status = LiveRuntimeStatus::Fault(error.to_string()); + return true; + } } + self.publish_terminal_snapshot(); + changed = true; } RuntimeEvent::InputClosed { .. } => {} RuntimeEvent::Exited { status, .. } => { - let lines = self.runtime_lines.finish(); - if !lines.is_empty() { - self.dispatch(ShellAction::AppendExternalOutput { - pane: LIVE_PANE, - lines, - }); - } self.runtime_status = LiveRuntimeStatus::Exited(format!("PTY exited with code {}", status.code)); changed = true; @@ -332,6 +308,36 @@ impl LumbridgeShell { 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) { + 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.dispatch(ShellAction::SelectPane(pane)); window.focus(&self.pane_focus[pane.index()]); @@ -375,8 +381,66 @@ impl LumbridgeShell { cx.notify(); } + fn terminal_taller(&mut self, _: &TerminalTaller, _: &mut Window, cx: &mut Context) { + 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) { + 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) { + 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) { + 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) { 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; } @@ -390,6 +454,11 @@ impl LumbridgeShell { cx.notify(); return; } + "escape" => { + self.dispatch(ShellAction::CloseCommandPalette); + cx.notify(); + return; + } _ => { if let Some(character) = event.keystroke.key_char.as_deref() && !event.keystroke.modifiers.control @@ -409,6 +478,7 @@ impl LumbridgeShell { pane: &PaneState, selected: bool, runtime_status: LiveRuntimeStatus, + terminal_dimensions: TerminalDimensions, focus: FocusHandle, cx: &mut Context, ) -> gpui::AnyElement { @@ -478,7 +548,7 @@ impl LumbridgeShell { .child(format!("{}", id.index() + 1)), ) .child(if external { - "Shell · runtime actor" + "Shell · VT session" } else { pane.fixture().title }), @@ -495,16 +565,45 @@ impl LumbridgeShell { .text_xs() .text_color(rgb(MUTED)) .child(if external { - runtime_status.detail() + format!( + "{} · {}×{} · Alt+Shift+arrows resize", + runtime_status.detail(), + terminal_dimensions.columns(), + terminal_dimensions.rows() + ) } else { pane.fixture().target.to_owned() }) .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( div() .flex() .flex_col() + .flex_1() + .min_h_0() + .overflow_hidden() .gap_1() .px_3() .pb_3() @@ -512,6 +611,36 @@ impl LumbridgeShell { .text_color(rgb(TEXT)) .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() } @@ -767,7 +896,7 @@ impl Render for LumbridgeShell { .px_3() .text_xs() .text_color(rgb(MUTED)) - .child("1 live PTY · 5 deterministic · 1 waiting"), + .child("1 interactive VT · 5 deterministic · 1 waiting"), ); let pane_cards = self @@ -779,6 +908,7 @@ impl Render for LumbridgeShell { pane, self.model.selected_pane() == pane.id(), self.runtime_status.clone(), + self.terminal.dimensions(), self.pane_focus[pane.id().index()].clone(), cx, ) @@ -795,11 +925,11 @@ impl Render for LumbridgeShell { let counters = self.model.counters(); let footer_left = format!( - "rev {} · {} focus · {} external batches · {} PTY lines · {} total lines", + "rev {} · VT rev {} · {} focus · {} VT snapshots · {} synthetic lines", self.model.revision(), + self.terminal.revision(), counters.focus_moves, - counters.external_output_batches, - counters.external_lines_appended, + counters.external_snapshot_updates, counters.terminal_lines_appended ); 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::open_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| { shell.select_pane(PaneId::CodexRuntime, window, cx); })) @@ -908,13 +1042,62 @@ impl Render for LumbridgeShell { } } -fn start_live_runtime() -> Result { +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 { + 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::().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 { let command = CommandConfig::new("/bin/sh") .map_err(RuntimeActorError::Start)? .args(["-c", LIVE_PTY_SCRIPT]); + let pty_size = TerminalSize::new(dimensions.rows(), dimensions.columns()) + .map_err(RuntimeActorError::Start)?; RuntimeActor::spawn( command, - PtyOptions::default(), + PtyOptions::new(pty_size), RuntimeActorOptions::default(), ) } @@ -922,22 +1105,21 @@ fn start_live_runtime() -> Result { fn main() { Application::new().run(|cx: &mut App| { cx.bind_keys([ - KeyBinding::new("left", FocusLeft, Some("LumbridgeShell")), - KeyBinding::new("right", FocusRight, Some("LumbridgeShell")), - KeyBinding::new("up", FocusUp, Some("LumbridgeShell")), - KeyBinding::new("down", FocusDown, Some("LumbridgeShell")), - KeyBinding::new("h", FocusLeft, Some("LumbridgeShell")), - KeyBinding::new("l", FocusRight, Some("LumbridgeShell")), - KeyBinding::new("k", FocusUp, Some("LumbridgeShell")), - KeyBinding::new("j", FocusDown, Some("LumbridgeShell")), + KeyBinding::new("alt-left", FocusLeft, Some("LumbridgeShell")), + KeyBinding::new("alt-right", FocusRight, Some("LumbridgeShell")), + KeyBinding::new("alt-up", FocusUp, Some("LumbridgeShell")), + KeyBinding::new("alt-down", FocusDown, Some("LumbridgeShell")), KeyBinding::new("secondary-k", OpenPalette, Some("LumbridgeShell")), - KeyBinding::new("escape", ClosePalette, Some("LumbridgeShell")), - KeyBinding::new("1", SelectPane1, Some("LumbridgeShell")), - KeyBinding::new("2", SelectPane2, Some("LumbridgeShell")), - KeyBinding::new("3", SelectPane3, Some("LumbridgeShell")), - KeyBinding::new("4", SelectPane4, Some("LumbridgeShell")), - KeyBinding::new("5", SelectPane5, Some("LumbridgeShell")), - KeyBinding::new("6", SelectPane6, Some("LumbridgeShell")), + KeyBinding::new("alt-1", SelectPane1, Some("LumbridgeShell")), + KeyBinding::new("alt-2", SelectPane2, Some("LumbridgeShell")), + KeyBinding::new("alt-3", SelectPane3, Some("LumbridgeShell")), + KeyBinding::new("alt-4", SelectPane4, Some("LumbridgeShell")), + KeyBinding::new("alt-5", SelectPane5, Some("LumbridgeShell")), + KeyBinding::new("alt-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); @@ -959,22 +1141,21 @@ fn main() { #[cfg(test)] mod tests { - use super::PtyLineFramer; + use super::{KeyModifiers, TerminalKey, terminal_key_from_parts}; #[test] - fn frames_split_crlf_and_utf8_chunks_without_losing_bytes() { - let mut framer = PtyLineFramer::default(); - assert!(framer.push(b"first\r").is_empty()); - assert_eq!(framer.push(b"\nsecond\npart"), ["first", "second"]); - assert!(framer.push(&[0xf0, 0x9f]).is_empty()); - assert!(framer.push(&[0x91, 0xa9]).is_empty()); - assert_eq!(framer.finish(), ["part👩"]); + fn maps_named_and_composed_gpui_keys_to_terminal_input() { + let enter = terminal_key_from_parts("enter", None, KeyModifiers::default()).unwrap(); + assert_eq!(enter.key, TerminalKey::Enter); + + let composed = terminal_key_from_parts("é", Some("é"), KeyModifiers::default()).unwrap(); + assert_eq!(composed.key, TerminalKey::Text("é".into())); } #[test] - fn replaces_invalid_utf8_only_after_a_complete_line() { - let mut framer = PtyLineFramer::default(); - assert!(framer.push(&[0xff]).is_empty()); - assert_eq!(framer.push(b"\n"), ["�"]); + fn reserves_platform_text_shortcuts_for_the_workspace() { + assert!(terminal_key_from_parts("c", Some("c"), KeyModifiers::PLATFORM).is_none()); + let control = terminal_key_from_parts("c", Some("c"), KeyModifiers::CONTROL).unwrap(); + assert_eq!(control.key, TerminalKey::Text("c".into())); } } diff --git a/spikes/ui-shell-model/src/lib.rs b/spikes/ui-shell-model/src/lib.rs index 3f0583f..2266f2e 100644 --- a/spikes/ui-shell-model/src/lib.rs +++ b/spikes/ui-shell-model/src/lib.rs @@ -288,6 +288,7 @@ pub enum ShellAction { SetCommandPaletteQuery(String), SyntheticStreamTick, AppendExternalOutput { pane: PaneId, lines: Vec }, + ReplaceExternalOutput { pane: PaneId, lines: Vec }, } /// Counters prove both candidates replayed the same workload. Framework @@ -308,6 +309,7 @@ pub struct MeasurementCounters { pub terminal_lines_appended: u64, pub external_output_batches: u64, pub external_lines_appended: u64, + pub external_snapshot_updates: u64, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -536,6 +538,21 @@ impl ShellModel { 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 { self.revision += 1; @@ -786,6 +803,14 @@ mod tests { assert_eq!(model.counters().surface_updates, 6); assert_eq!(model.counters().external_output_batches, 1); 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]